Collapse the Params-panel ParamDeclaration and the metric-embedded RegexToMetricPrimitiveMatcher into a single ParamDeclaration type (name, regex/capture-group-or-json-field-path, kind). MetricDeclaration now references params by name via label_params: Vec<String> and value_param: Option<String> instead of embedding its own extractor per param, so a param declared once in the Params panel can be reused as a label or value by any number of metrics. Declaring a metric that references an undeclared param name is now rejected at declare-time. Backend: - src/data_value.rs: unified ParamDeclaration struct; MetricDeclaration data value now carries label_params/value_param instead of a Vec of inline matchers. - src/engine.rs: DeclareParamRequest is now an alias for ParamDeclaration; DeclareMetricRequest carries label_params/ value_param; validate_metric_request checks referenced param names against the declared-param store; apply_metric_to_log resolves label_params/value_param against a per-ingest resolved-param map instead of reading an inline matcher list. - src/profile.rs: MetricDeclarationRecord carries label_params/ value_param plus a private, serialize-skipped legacy_params field that only ever gets populated by deserializing a pre-037 profile file's old "params" key. ProfileStore::load migrates any such legacy metric into the unified shape (reusing an existing same-shaped bare param by name, or creating one implicitly named "<metric_name>.<param_name>"), then persists the migrated store so the on-disk file only carries the legacy shape once. Migration is idempotent (deterministic naming) and covered by dedicated tests. - src/api.rs / src/dto.rs: MetricDeclarationDto and the declare/update metric response bodies updated to the new shape. Frontend: - webapp/src/lib/api.ts: MetricDeclaration type now has label_params/ value_param; MetricParamMatcher/ParamRole types removed. - webapp/src/components/Metrics.tsx: the declaration form now fetches declared params and lets you check off label params / pick a value param from that list, instead of re-entering a regex/capture-group/ kind per metric param. Leaves ticket 038 (duration as a derived param kind) and 039 (param query resolving declared params) untouched, as scoped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
344 lines
11 KiB
Rust
344 lines
11 KiB
Rust
use crate::alerts::AlertCondition;
|
|
use crate::data_value::MetricPrimitiveKind;
|
|
use crate::metric_primitive::MetricPrimitive;
|
|
use crate::profile::{ChartType, Dashboard, Panel, Profile};
|
|
use crate::settings::{ColorScheme, CssEntry, Settings};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::BTreeMap;
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct LogEntryDto {
|
|
pub sequence: u64,
|
|
pub timestamp_ms: i64,
|
|
pub text: String,
|
|
/// Only params whose regex matched (and parsed as the declared kind) are present — omitted rather than sent as null, so the frontend can render a blank for anything missing.
|
|
pub params: BTreeMap<String, MetricPrimitive>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ParamDeclarationDto {
|
|
pub name: String,
|
|
pub message_match_regex: String,
|
|
pub capture_group: String,
|
|
/// See `crate::data_value::ParamDeclaration::json_field_path`.
|
|
pub json_field_path: Option<String>,
|
|
pub kind: MetricPrimitiveKind,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct MetricDeclarationDto {
|
|
pub name: String,
|
|
pub message_match_regex: String,
|
|
/// Names of standalone `ParamDeclaration`s used as this metric's bucket labels.
|
|
pub label_params: Vec<String>,
|
|
/// Name of the standalone `ParamDeclaration` (if any) whose primitive becomes this metric's per-event bucket value instead of a label.
|
|
pub value_param: Option<String>,
|
|
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
|
|
pub matched_labels: Option<BTreeMap<String, String>>,
|
|
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
|
|
pub unmatched_labels: Option<BTreeMap<String, String>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct TimelineBucketDto {
|
|
pub start_ms: i64,
|
|
pub count: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct StatsDto {
|
|
pub log_count: u64,
|
|
pub first_sequence: Option<u64>,
|
|
pub last_sequence: Option<u64>,
|
|
pub first_timestamp_ms: Option<i64>,
|
|
pub last_timestamp_ms: Option<i64>,
|
|
/// Ticket 011: a running, *approximate* estimate of the in-memory heap
|
|
/// held by currently-ingested logs, in bytes. "Approximate" because it
|
|
/// only counts each `IngestedLine::text`'s UTF-8 byte length — it does
|
|
/// not account for `String`'s allocator overhead/capacity slack, the
|
|
/// `BTreeMap`/`IndexKey` node overhead the text is stored under, or any
|
|
/// derived data (bucket values, params) the line produces elsewhere in
|
|
/// the index. That's judged the best cheap proxy for "how much memory is
|
|
/// this log's text costing us": it's O(1) to compute per log (`text.len()`,
|
|
/// already an interned byte count for UTF-8 `String`), moves in lockstep
|
|
/// with the dominant cost (raw log text, which is usually much larger
|
|
/// than the fixed per-entry bookkeeping), and needs no per-entry
|
|
/// bookkeeping beyond what eviction already tracks.
|
|
pub approx_bytes_total: u64,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct LogsParams {
|
|
pub since_sequence: Option<u64>,
|
|
pub start_ms: Option<i64>,
|
|
pub end_ms: Option<i64>,
|
|
pub limit: Option<usize>,
|
|
/// Raw FilterBar query string, e.g. `regex("error") and sequence<5`.
|
|
pub filter: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct TimelineParams {
|
|
pub start_ms: Option<i64>,
|
|
pub end_ms: Option<i64>,
|
|
pub bucket_ms: Option<i64>,
|
|
/// Same FilterBar query string as `LogsParams::filter`; only matching logs are counted into timeline buckets.
|
|
pub filter: Option<String>,
|
|
}
|
|
|
|
/// One (sequence, timestamp, value) occurrence within a metric's bucket for a given label combination.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct BucketValueDto {
|
|
pub labels: BTreeMap<String, MetricPrimitive>,
|
|
pub sequence: u64,
|
|
pub timestamp_ms: i64,
|
|
pub value: MetricPrimitive,
|
|
}
|
|
|
|
/// `labels` is a comma-separated `key=value` list (not repeated params) since axum's `Query` extractor needs a fixed set of field names.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct BucketValuesParams {
|
|
pub labels: Option<String>,
|
|
pub since_sequence: Option<u64>,
|
|
pub start_ms: Option<i64>,
|
|
pub end_ms: Option<i64>,
|
|
pub limit: Option<usize>,
|
|
}
|
|
|
|
/// `labels` uses plain `String` (already Display-formatted) since charts just need something to show; `value` is always `f64` for plottability.
|
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
|
pub struct PromqlSeriesDto {
|
|
pub labels: BTreeMap<String, String>,
|
|
pub points: Vec<(i64, f64)>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PromqlParams {
|
|
pub promql: String,
|
|
pub start_ms: Option<i64>,
|
|
pub end_ms: Option<i64>,
|
|
}
|
|
|
|
/// JSON error body returned by API handlers on a 4xx/5xx response.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ErrorDto {
|
|
pub error: String,
|
|
}
|
|
|
|
/// Every imported CSS entry is a selectable theme (ticket 020) — there's no separate
|
|
/// always-on-overlay mode to opt into.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ImportCssRequest {
|
|
pub filename: String,
|
|
pub content: String,
|
|
}
|
|
|
|
/// A reference to an external CSS path/URL that ottyl doesn't copy or own.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct LinkCssRequest {
|
|
pub path_or_url: String,
|
|
}
|
|
|
|
/// `id` is the real id for `Imported` entries or a stable id derived from the path/URL for `Linked` ones, so the frontend can key both kinds uniformly (only `Imported` entries are deletable/content-fetchable).
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct CssEntryDto {
|
|
pub id: String,
|
|
pub kind: CssEntryKind,
|
|
pub filename: Option<String>,
|
|
pub path_or_url: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum CssEntryKind {
|
|
Imported,
|
|
Linked,
|
|
}
|
|
|
|
impl From<&CssEntry> for CssEntryDto {
|
|
fn from(entry: &CssEntry) -> Self {
|
|
match entry {
|
|
CssEntry::Imported { id, filename } => CssEntryDto {
|
|
id: id.clone(),
|
|
kind: CssEntryKind::Imported,
|
|
filename: Some(filename.clone()),
|
|
path_or_url: None,
|
|
},
|
|
CssEntry::Linked { path_or_url } => CssEntryDto {
|
|
id: crate::settings::css_entry_id(entry),
|
|
kind: CssEntryKind::Linked,
|
|
filename: None,
|
|
path_or_url: Some(path_or_url.clone()),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `css_entries` is omitted here since it has its own `GET /api/settings/css` endpoint.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct SettingsDto {
|
|
pub color_scheme: ColorScheme,
|
|
pub active_profile: Option<String>,
|
|
/// `None` means unlimited.
|
|
pub max_logs: Option<usize>,
|
|
/// Id of the selected custom theme entry, if any. `None` means `color_scheme` alone determines the active palette.
|
|
pub custom_theme_id: Option<String>,
|
|
/// Ntfy topic every alert rule delivers to; `None` until configured.
|
|
pub ntfy_topic: Option<String>,
|
|
/// Ntfy server override; `None` means `https://ntfy.sh`.
|
|
pub ntfy_server: Option<String>,
|
|
}
|
|
|
|
impl From<&Settings> for SettingsDto {
|
|
fn from(settings: &Settings) -> Self {
|
|
SettingsDto {
|
|
color_scheme: settings.color_scheme,
|
|
active_profile: settings.profiles.active_profile.clone(),
|
|
max_logs: settings.max_logs,
|
|
custom_theme_id: settings.custom_theme_id.clone(),
|
|
ntfy_topic: settings.ntfy_topic.clone(),
|
|
ntfy_server: settings.ntfy_server.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `max_logs`/`custom_theme_id`/`ntfy_topic`/`ntfy_server` distinguish "omitted" (leave unchanged) from "present" (null clears, value sets), via `deserialize_double_option`.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateSettingsRequest {
|
|
pub color_scheme: Option<ColorScheme>,
|
|
#[serde(default, deserialize_with = "deserialize_double_option")]
|
|
pub max_logs: Option<Option<usize>>,
|
|
#[serde(default, deserialize_with = "deserialize_double_option")]
|
|
pub custom_theme_id: Option<Option<String>>,
|
|
#[serde(default, deserialize_with = "deserialize_double_option")]
|
|
pub ntfy_topic: Option<Option<String>>,
|
|
#[serde(default, deserialize_with = "deserialize_double_option")]
|
|
pub ntfy_server: Option<Option<String>>,
|
|
}
|
|
|
|
/// Distinguishes a missing field (outer `None`) from present `null` (`Some(None)`) or present value (`Some(Some(value))`), which plain `Option<Option<T>>` can't do alone.
|
|
fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
T: Deserialize<'de>,
|
|
{
|
|
Option::<T>::deserialize(deserializer).map(Some)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateAlertRuleRequest {
|
|
pub name: String,
|
|
pub enabled: bool,
|
|
pub condition: AlertCondition,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateAlertRuleRequest {
|
|
pub name: String,
|
|
pub enabled: bool,
|
|
pub condition: AlertCondition,
|
|
}
|
|
|
|
/// `server` defaults to `https://ntfy.sh` when absent.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct TestAlertNotificationRequest {
|
|
pub topic: String,
|
|
pub server: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ProfileSummaryDto {
|
|
pub name: String,
|
|
pub param_count: usize,
|
|
pub metric_count: usize,
|
|
pub dashboard_count: usize,
|
|
}
|
|
|
|
impl From<&Profile> for ProfileSummaryDto {
|
|
fn from(profile: &Profile) -> Self {
|
|
ProfileSummaryDto {
|
|
name: profile.name.clone(),
|
|
param_count: profile.params.len(),
|
|
metric_count: profile.metrics.len(),
|
|
dashboard_count: profile.dashboards.len(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateProfileRequest {
|
|
pub name: String,
|
|
}
|
|
|
|
/// Mirrors `profile::Panel`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PanelDto {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub promql: String,
|
|
pub chart_type: ChartType,
|
|
}
|
|
|
|
impl From<&Panel> for PanelDto {
|
|
fn from(panel: &Panel) -> Self {
|
|
PanelDto {
|
|
id: panel.id.clone(),
|
|
title: panel.title.clone(),
|
|
promql: panel.promql.clone(),
|
|
chart_type: panel.chart_type,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&PanelDto> for Panel {
|
|
fn from(dto: &PanelDto) -> Self {
|
|
Panel {
|
|
id: dto.id.clone(),
|
|
title: dto.title.clone(),
|
|
promql: dto.promql.clone(),
|
|
chart_type: dto.chart_type,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mirrors `profile::Dashboard`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DashboardDto {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub panels: Vec<PanelDto>,
|
|
}
|
|
|
|
impl From<&Dashboard> for DashboardDto {
|
|
fn from(dashboard: &Dashboard) -> Self {
|
|
DashboardDto {
|
|
id: dashboard.id.clone(),
|
|
name: dashboard.name.clone(),
|
|
panels: dashboard.panels.iter().map(PanelDto::from).collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&DashboardDto> for Dashboard {
|
|
fn from(dto: &DashboardDto) -> Self {
|
|
Dashboard {
|
|
id: dto.id.clone(),
|
|
name: dto.name.clone(),
|
|
panels: dto.panels.iter().map(Panel::from).collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateDashboardRequest {
|
|
pub name: String,
|
|
}
|
|
|
|
/// The dashboard `id` in the URL is authoritative; any `id` in the body is ignored.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct SaveDashboardRequest {
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub panels: Vec<PanelDto>,
|
|
}
|