Files
ottyl/src/dto.rs
T
Hugh KaznowskiandClaude Sonnet 5 e397f9fbdb Unify param declarations: metrics reference params by name (ticket 037)
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
2026-09-05 22:41:14 +01:00

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>,
}