From 10f299110b8b2f0022a55aefe352c1b446ad4f25 Mon Sep 17 00:00:00 2001 From: Quan Huynh Date: Mon, 6 Jul 2026 23:17:57 +0700 Subject: [PATCH 1/2] ui: enhance ux/ui --- config/config.yaml | 27 +- .../cloudwatch/config/config.yaml | 18 +- .../elasticsearch/config/config.yaml | 18 +- .../docker-compose/file/config/config.yaml | 18 +- .../docker-compose/graylog/config/config.yaml | 18 +- .../docker-compose/loki/config/config.yaml | 18 +- .../docker-compose/metrics/config/config.yaml | 18 +- .../docker-compose/splunk/config/config.yaml | 18 +- examples/metrics-source/config/config.yaml | 18 +- helm/versus-incident/Chart.yaml | 4 +- pkg/agent/brain.go | 2 +- pkg/agent/brain_log.go | 161 ++++++-- pkg/agent/brain_log_readiness_test.go | 8 +- pkg/agent/brain_log_test.go | 25 +- pkg/agent/catalog.go | 163 +++++++- pkg/agent/catalog_pg_store.go | 103 ++++- pkg/agent/catalog_pg_store_test.go | 221 ++++++++++ pkg/agent/catalog_reassign_test.go | 2 +- pkg/agent/catalog_store_test.go | 2 +- pkg/agent/detect_log.go | 27 +- pkg/agent/learnfilter_test.go | 2 +- pkg/agent/matcher_kind_test.go | 2 +- pkg/agent/sample_redaction_test.go | 2 +- pkg/agent/spike.go | 194 +++++++++ pkg/agent/spike_baseline_mode_test.go | 170 ++++++++ pkg/agent/spikesettings.go | 102 +++++ pkg/agent/spikesettings_test.go | 68 ++++ pkg/agent/worker.go | 53 +-- pkg/agent/worker_aisettings_test.go | 4 +- pkg/agent/worker_emit_test.go | 6 +- pkg/agent/worker_seam_test.go | 2 +- pkg/agent/worker_test.go | 197 +++++---- pkg/config/agent.go | 43 +- pkg/config/clone_config.go | 5 +- pkg/config/default_config.yaml | 4 +- pkg/config/spike_baseline_mode_test.go | 65 +++ pkg/controllers/agent.go | 38 +- .../agent_patterns_readiness_test.go | 5 +- pkg/controllers/agent_test.go | 159 ++++++++ pkg/controllers/spike_admin.go | 82 ++++ pkg/controllers/spike_admin_test.go | 155 +++++++ pkg/routes/routes.go | 1 + pkg/stats/ewma.go | 128 ++++++ pkg/stats/ewma_test.go | 132 ++++++ .../migrations/005_log_baseline_variance.sql | 25 ++ pkg/storage/migrations/006_log_seasonal.sql | 29 ++ .../migrations/007_log_baseline_mode.sql | 37 ++ pkg/storage/signal_schema_test.go | 33 ++ src/agent/spike.md | 211 +++++----- ui/src/components/AssignDialog.tsx | 46 ++- ui/src/components/KpiTile.tsx | 46 ++- ui/src/components/PatternBaselines.test.tsx | 82 ++++ ui/src/components/PatternBaselines.tsx | 125 ++++++ ui/src/components/PeekPanel.tsx | 18 + ui/src/components/SpikeSettingsControl.tsx | 150 +++++++ ui/src/lib/agentChannels.test.ts | 14 +- ui/src/lib/agentChannels.ts | 6 - ui/src/lib/api.ts | 57 ++- ui/src/lib/tableConsistency.test.tsx | 3 +- ui/src/lib/tableParityAndAlerting.test.ts | 138 +++++++ ui/src/pages/AgentOverviewPage.tsx | 32 +- ui/src/pages/AnalysesListPage.test.tsx | 83 ++++ ui/src/pages/AnalysesListPage.tsx | 174 +++++++- ui/src/pages/DecisionsPage.tsx | 381 +++++++++++++++++- ui/src/pages/DetectDetailPage.tsx | 3 + ui/src/pages/IncidentsConfigPage.test.tsx | 102 +++++ ui/src/pages/IncidentsConfigPage.tsx | 8 +- ui/src/pages/IncidentsPage.test.tsx | 118 ++++++ ui/src/pages/IncidentsPage.tsx | 320 +++++++++++++-- ui/src/pages/PatternDetailPage.tsx | 21 +- ui/src/pages/PatternsPage.test.tsx | 153 +++++++ ui/src/pages/PatternsPage.tsx | 59 ++- ui/src/pages/ServiceDetailPage.tsx | 110 ++++- ui/src/pages/ServicesPage.test.tsx | 250 ++++++++++++ ui/src/pages/ServicesPage.tsx | 247 ++++++++---- ui/src/pages/SettingsPage.tsx | 38 +- ui/src/pages/ShadowDetailPage.tsx | 4 +- ui/tsconfig.app.tsbuildinfo | 2 +- 78 files changed, 5060 insertions(+), 573 deletions(-) create mode 100644 pkg/agent/spike.go create mode 100644 pkg/agent/spike_baseline_mode_test.go create mode 100644 pkg/agent/spikesettings.go create mode 100644 pkg/agent/spikesettings_test.go create mode 100644 pkg/config/spike_baseline_mode_test.go create mode 100644 pkg/controllers/spike_admin.go create mode 100644 pkg/controllers/spike_admin_test.go create mode 100644 pkg/stats/ewma.go create mode 100644 pkg/stats/ewma_test.go create mode 100644 pkg/storage/migrations/005_log_baseline_variance.sql create mode 100644 pkg/storage/migrations/006_log_seasonal.sql create mode 100644 pkg/storage/migrations/007_log_baseline_mode.sql create mode 100644 ui/src/components/PatternBaselines.test.tsx create mode 100644 ui/src/components/PatternBaselines.tsx create mode 100644 ui/src/components/SpikeSettingsControl.tsx create mode 100644 ui/src/lib/tableParityAndAlerting.test.ts create mode 100644 ui/src/pages/AnalysesListPage.test.tsx create mode 100644 ui/src/pages/IncidentsConfigPage.test.tsx create mode 100644 ui/src/pages/IncidentsPage.test.tsx create mode 100644 ui/src/pages/PatternsPage.test.tsx create mode 100644 ui/src/pages/ServicesPage.test.tsx diff --git a/config/config.yaml b/config/config.yaml index d162419..006bcdc 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -196,16 +196,23 @@ agent: catalog: persist_interval: 30s auto_promote_after: 100 # in detect mode, this many sightings = "known" - # Spike detection: a known pattern is re-flagged when its tick-level - # frequency suddenly exceeds the EWMA (Exponentially Weighted Moving Average) baseline by `spike_multiplier`. - # Two safety floors keep noise out: - # - spike_min_frequency: tick must have at least this many matches - # (avoids triggering on tiny absolute counts). - # - spike_min_baseline_count: pattern must have been seen this many - # times overall (avoids treating a barely-seen pattern's first big - # tick as a spike). - # Set spike_multiplier: 0 to disable spike detection entirely. - spike_multiplier: 5.0 + # Spike detection: a known pattern is re-flagged when its per-second match + # RATE jumps well above the learned normal. The bar is a z-score, so it + # self-scales to each pattern's own volatility — a burst on a high-volume + # pattern trips even though its baseline is large. + # - spike_z: fire when the rate is this many standard deviations above + # the learned baseline. + # - spike_abs_ceiling: a hard safety net — a tick with at least this many + # matches always surfaces (0 = disabled / opt-in). + # - spike_sustain_ticks: require this many CONSECUTIVE spiking ticks + # before firing (1 = fire on the first tick, no debounce). + # - spike_min_frequency: absolute noise floor — the tick must have at + # least this many matches. + # - spike_min_baseline_count: warmup gate — the pattern must have been + # seen this many times before the z-score is trusted. + spike_z: 3.0 + spike_abs_ceiling: 0 + spike_sustain_ticks: 1 spike_min_frequency: 5 spike_min_baseline_count: 20 diff --git a/examples/docker-compose/cloudwatch/config/config.yaml b/examples/docker-compose/cloudwatch/config/config.yaml index 24b54c6..18b16cd 100644 --- a/examples/docker-compose/cloudwatch/config/config.yaml +++ b/examples/docker-compose/cloudwatch/config/config.yaml @@ -56,7 +56,23 @@ agent: catalog: persist_interval: 30s auto_promote_after: 50 - spike_multiplier: 5.0 + # Spike detection: a known pattern is re-flagged when its per-second match + # RATE jumps well above the learned normal. The bar is a z-score, so it + # self-scales to each pattern's own volatility — a burst on a high-volume + # pattern trips even though its baseline is large. + # - spike_z: fire when the rate is this many standard deviations above + # the learned baseline. + # - spike_abs_ceiling: a hard safety net — a tick with at least this many + # matches always surfaces (0 = disabled / opt-in). + # - spike_sustain_ticks: require this many CONSECUTIVE spiking ticks + # before firing (1 = fire on the first tick, no debounce). + # - spike_min_frequency: absolute noise floor — the tick must have at + # least this many matches. + # - spike_min_baseline_count: warmup gate — the pattern must have been + # seen this many times before the z-score is trusted. + spike_z: 3.0 + spike_abs_ceiling: 0 + spike_sustain_ticks: 1 spike_min_frequency: 5 spike_min_baseline_count: 20 diff --git a/examples/docker-compose/elasticsearch/config/config.yaml b/examples/docker-compose/elasticsearch/config/config.yaml index 24b54c6..18b16cd 100644 --- a/examples/docker-compose/elasticsearch/config/config.yaml +++ b/examples/docker-compose/elasticsearch/config/config.yaml @@ -56,7 +56,23 @@ agent: catalog: persist_interval: 30s auto_promote_after: 50 - spike_multiplier: 5.0 + # Spike detection: a known pattern is re-flagged when its per-second match + # RATE jumps well above the learned normal. The bar is a z-score, so it + # self-scales to each pattern's own volatility — a burst on a high-volume + # pattern trips even though its baseline is large. + # - spike_z: fire when the rate is this many standard deviations above + # the learned baseline. + # - spike_abs_ceiling: a hard safety net — a tick with at least this many + # matches always surfaces (0 = disabled / opt-in). + # - spike_sustain_ticks: require this many CONSECUTIVE spiking ticks + # before firing (1 = fire on the first tick, no debounce). + # - spike_min_frequency: absolute noise floor — the tick must have at + # least this many matches. + # - spike_min_baseline_count: warmup gate — the pattern must have been + # seen this many times before the z-score is trusted. + spike_z: 3.0 + spike_abs_ceiling: 0 + spike_sustain_ticks: 1 spike_min_frequency: 5 spike_min_baseline_count: 20 diff --git a/examples/docker-compose/file/config/config.yaml b/examples/docker-compose/file/config/config.yaml index 24b54c6..18b16cd 100644 --- a/examples/docker-compose/file/config/config.yaml +++ b/examples/docker-compose/file/config/config.yaml @@ -56,7 +56,23 @@ agent: catalog: persist_interval: 30s auto_promote_after: 50 - spike_multiplier: 5.0 + # Spike detection: a known pattern is re-flagged when its per-second match + # RATE jumps well above the learned normal. The bar is a z-score, so it + # self-scales to each pattern's own volatility — a burst on a high-volume + # pattern trips even though its baseline is large. + # - spike_z: fire when the rate is this many standard deviations above + # the learned baseline. + # - spike_abs_ceiling: a hard safety net — a tick with at least this many + # matches always surfaces (0 = disabled / opt-in). + # - spike_sustain_ticks: require this many CONSECUTIVE spiking ticks + # before firing (1 = fire on the first tick, no debounce). + # - spike_min_frequency: absolute noise floor — the tick must have at + # least this many matches. + # - spike_min_baseline_count: warmup gate — the pattern must have been + # seen this many times before the z-score is trusted. + spike_z: 3.0 + spike_abs_ceiling: 0 + spike_sustain_ticks: 1 spike_min_frequency: 5 spike_min_baseline_count: 20 diff --git a/examples/docker-compose/graylog/config/config.yaml b/examples/docker-compose/graylog/config/config.yaml index 24b54c6..18b16cd 100644 --- a/examples/docker-compose/graylog/config/config.yaml +++ b/examples/docker-compose/graylog/config/config.yaml @@ -56,7 +56,23 @@ agent: catalog: persist_interval: 30s auto_promote_after: 50 - spike_multiplier: 5.0 + # Spike detection: a known pattern is re-flagged when its per-second match + # RATE jumps well above the learned normal. The bar is a z-score, so it + # self-scales to each pattern's own volatility — a burst on a high-volume + # pattern trips even though its baseline is large. + # - spike_z: fire when the rate is this many standard deviations above + # the learned baseline. + # - spike_abs_ceiling: a hard safety net — a tick with at least this many + # matches always surfaces (0 = disabled / opt-in). + # - spike_sustain_ticks: require this many CONSECUTIVE spiking ticks + # before firing (1 = fire on the first tick, no debounce). + # - spike_min_frequency: absolute noise floor — the tick must have at + # least this many matches. + # - spike_min_baseline_count: warmup gate — the pattern must have been + # seen this many times before the z-score is trusted. + spike_z: 3.0 + spike_abs_ceiling: 0 + spike_sustain_ticks: 1 spike_min_frequency: 5 spike_min_baseline_count: 20 diff --git a/examples/docker-compose/loki/config/config.yaml b/examples/docker-compose/loki/config/config.yaml index 24b54c6..18b16cd 100644 --- a/examples/docker-compose/loki/config/config.yaml +++ b/examples/docker-compose/loki/config/config.yaml @@ -56,7 +56,23 @@ agent: catalog: persist_interval: 30s auto_promote_after: 50 - spike_multiplier: 5.0 + # Spike detection: a known pattern is re-flagged when its per-second match + # RATE jumps well above the learned normal. The bar is a z-score, so it + # self-scales to each pattern's own volatility — a burst on a high-volume + # pattern trips even though its baseline is large. + # - spike_z: fire when the rate is this many standard deviations above + # the learned baseline. + # - spike_abs_ceiling: a hard safety net — a tick with at least this many + # matches always surfaces (0 = disabled / opt-in). + # - spike_sustain_ticks: require this many CONSECUTIVE spiking ticks + # before firing (1 = fire on the first tick, no debounce). + # - spike_min_frequency: absolute noise floor — the tick must have at + # least this many matches. + # - spike_min_baseline_count: warmup gate — the pattern must have been + # seen this many times before the z-score is trusted. + spike_z: 3.0 + spike_abs_ceiling: 0 + spike_sustain_ticks: 1 spike_min_frequency: 5 spike_min_baseline_count: 20 diff --git a/examples/docker-compose/metrics/config/config.yaml b/examples/docker-compose/metrics/config/config.yaml index f9d47ce..e7b36f8 100644 --- a/examples/docker-compose/metrics/config/config.yaml +++ b/examples/docker-compose/metrics/config/config.yaml @@ -65,7 +65,23 @@ agent: catalog: persist_interval: 30s auto_promote_after: 50 - spike_multiplier: 5.0 + # Spike detection: a known pattern is re-flagged when its per-second match + # RATE jumps well above the learned normal. The bar is a z-score, so it + # self-scales to each pattern's own volatility — a burst on a high-volume + # pattern trips even though its baseline is large. + # - spike_z: fire when the rate is this many standard deviations above + # the learned baseline. + # - spike_abs_ceiling: a hard safety net — a tick with at least this many + # matches always surfaces (0 = disabled / opt-in). + # - spike_sustain_ticks: require this many CONSECUTIVE spiking ticks + # before firing (1 = fire on the first tick, no debounce). + # - spike_min_frequency: absolute noise floor — the tick must have at + # least this many matches. + # - spike_min_baseline_count: warmup gate — the pattern must have been + # seen this many times before the z-score is trusted. + spike_z: 3.0 + spike_abs_ceiling: 0 + spike_sustain_ticks: 1 spike_min_frequency: 5 spike_min_baseline_count: 20 diff --git a/examples/docker-compose/splunk/config/config.yaml b/examples/docker-compose/splunk/config/config.yaml index 24b54c6..18b16cd 100644 --- a/examples/docker-compose/splunk/config/config.yaml +++ b/examples/docker-compose/splunk/config/config.yaml @@ -56,7 +56,23 @@ agent: catalog: persist_interval: 30s auto_promote_after: 50 - spike_multiplier: 5.0 + # Spike detection: a known pattern is re-flagged when its per-second match + # RATE jumps well above the learned normal. The bar is a z-score, so it + # self-scales to each pattern's own volatility — a burst on a high-volume + # pattern trips even though its baseline is large. + # - spike_z: fire when the rate is this many standard deviations above + # the learned baseline. + # - spike_abs_ceiling: a hard safety net — a tick with at least this many + # matches always surfaces (0 = disabled / opt-in). + # - spike_sustain_ticks: require this many CONSECUTIVE spiking ticks + # before firing (1 = fire on the first tick, no debounce). + # - spike_min_frequency: absolute noise floor — the tick must have at + # least this many matches. + # - spike_min_baseline_count: warmup gate — the pattern must have been + # seen this many times before the z-score is trusted. + spike_z: 3.0 + spike_abs_ceiling: 0 + spike_sustain_ticks: 1 spike_min_frequency: 5 spike_min_baseline_count: 20 diff --git a/examples/metrics-source/config/config.yaml b/examples/metrics-source/config/config.yaml index 8ae0760..942be12 100644 --- a/examples/metrics-source/config/config.yaml +++ b/examples/metrics-source/config/config.yaml @@ -68,7 +68,23 @@ agent: catalog: persist_interval: 30s auto_promote_after: 50 - spike_multiplier: 5.0 + # Spike detection: a known pattern is re-flagged when its per-second match + # RATE jumps well above the learned normal. The bar is a z-score, so it + # self-scales to each pattern's own volatility — a burst on a high-volume + # pattern trips even though its baseline is large. + # - spike_z: fire when the rate is this many standard deviations above + # the learned baseline. + # - spike_abs_ceiling: a hard safety net — a tick with at least this many + # matches always surfaces (0 = disabled / opt-in). + # - spike_sustain_ticks: require this many CONSECUTIVE spiking ticks + # before firing (1 = fire on the first tick, no debounce). + # - spike_min_frequency: absolute noise floor — the tick must have at + # least this many matches. + # - spike_min_baseline_count: warmup gate — the pattern must have been + # seen this many times before the z-score is trusted. + spike_z: 3.0 + spike_abs_ceiling: 0 + spike_sustain_ticks: 1 spike_min_frequency: 5 spike_min_baseline_count: 20 diff --git a/helm/versus-incident/Chart.yaml b/helm/versus-incident/Chart.yaml index e97374e..2f5be4b 100644 --- a/helm/versus-incident/Chart.yaml +++ b/helm/versus-incident/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: versus-incident description: A Helm chart for Versus Incident Management System type: application -version: 1.4.4 -appVersion: "1.4.4" +version: 1.4.9 +appVersion: "1.4.9" # Dependencies dependencies: diff --git a/pkg/agent/brain.go b/pkg/agent/brain.go index ff58e36..7b92569 100644 --- a/pkg/agent/brain.go +++ b/pkg/agent/brain.go @@ -143,7 +143,7 @@ func (w *Worker) brainFor(name string) (core.SignalLearner, core.SignalDetector) if b, ok := w.brains[name]; ok { return b.learner, b.detector } - lb := newLogBrain(name, w.miner, w.catalog, w.matcherForSource(name), w.services, w.ewmaAlpha, w.cfg.Catalog, w.redactor) + lb := newLogBrain(name, w.miner, w.catalog, w.matcherForSource(name), w.services, w.ewmaAlpha, w.cfg.Catalog, w.redactor, w.pollInterval.Seconds()) w.brains[name] = typedBrain{learner: lb, detector: lb} return lb, lb } diff --git a/pkg/agent/brain_log.go b/pkg/agent/brain_log.go index 22c799b..07efba3 100644 --- a/pkg/agent/brain_log.go +++ b/pkg/agent/brain_log.go @@ -2,10 +2,13 @@ package agent import ( "context" + "fmt" + "sync" "time" "github.com/VersusControl/versus-incident/pkg/config" "github.com/VersusControl/versus-incident/pkg/core" + "github.com/VersusControl/versus-incident/pkg/stats" ) // logBrain is the OSS default SignalLearner + SignalDetector: the drain-miner @@ -25,16 +28,25 @@ type logBrain struct { services *ServiceMatcher ewmaAlpha float64 cat config.AgentCatalogConfig + // params are the resolved spike-detection knobs (defaults applied, poll + // interval folded in) the detector scores against. + params spikeParams // scrubber re-scrubs the captured sample line at the storage boundary // (defence-in-depth) inside RecordSample. It is the worker's pipeline // redactor; nil disables the re-scrub (input is already redacted). scrubber core.Scrubber + + // spikeMu guards streak, the per-pattern consecutive-spike counter that + // implements the sustained-tick debounce. + spikeMu sync.Mutex + streak map[string]int } // newLogBrain wires a log brain over the worker's shared miner/catalog plus the // per-source name used for catalog attribution. scrub is the pipeline redactor, // re-applied when the brain records a redacted sample line for the pattern. -func newLogBrain(source string, miner *Miner, catalog *Catalog, matcher *RegexMatcher, services *ServiceMatcher, ewmaAlpha float64, cat config.AgentCatalogConfig, scrub core.Scrubber) *logBrain { +// pollSeconds is the worker's tick duration, used to score a per-second rate. +func newLogBrain(source string, miner *Miner, catalog *Catalog, matcher *RegexMatcher, services *ServiceMatcher, ewmaAlpha float64, cat config.AgentCatalogConfig, scrub core.Scrubber, pollSeconds float64) *logBrain { if ewmaAlpha <= 0 { ewmaAlpha = 0.2 } @@ -46,7 +58,9 @@ func newLogBrain(source string, miner *Miner, catalog *Catalog, matcher *RegexMa services: services, ewmaAlpha: ewmaAlpha, cat: cat, + params: resolveSpikeParams(cat, pollSeconds), scrubber: scrub, + streak: make(map[string]int), } } @@ -133,15 +147,80 @@ func (b *logBrain) Group(ctx context.Context, batch []core.Signal) ([]core.Obser return obs, nil } -// Expected returns the pre-fold EWMA baseline for a pattern. Logs have no -// per-key training gate (the lifecycle gates on MODE, not on a confidence -// flag), so confident is always true — log novelty is expressed through the -// Detector's VerdictUnknown, not through suppression. std is unused for logs. +// Expected returns the pre-fold baseline for a pattern at ts. It is the mean +// and standard deviation the spike detector scores against — the current +// seasonal bucket's rate once that bucket is confident, otherwise the global +// baseline. Logs have no per-key training gate (the lifecycle gates on MODE, +// not a confidence flag), so confident is always true — log novelty is +// expressed through the Detector's VerdictUnknown, not through suppression. func (b *logBrain) Expected(ctx context.Context, key string, ts time.Time) (mean, std float64, confident bool) { - if p := b.catalog.Get(key); p != nil { - return p.BaselineFrequency, 0, true + p := b.catalog.Get(key) + if p == nil { + return 0, 0, true + } + mean, std = b.baselineExpected(p, ts) + return mean, std, true +} + +// baselineExpected picks the mean/std to score against at ts per the pattern's +// baseline mode. It reuses resolveBaseline (which also produces the human label +// the detector names in its explanation) and drops the label. +func (b *logBrain) baselineExpected(p *Pattern, ts time.Time) (mean, std float64) { + mean, std, _ = b.resolveBaseline(p, ts) + return mean, std +} + +// resolveBaseline selects WHICH learned baseline the spike z-score is measured +// against for this pattern at ts, and returns a human label naming it for the +// explanation string. The mode is the pattern's own spike_baseline_mode when +// set, else the config default; an unrecognized value folds to "default". +// +// Time-of-day buckets are always folded, so any mode can be chosen with no +// re-learn: +// - default → the global rate baseline (mean = baseline_frequency, +// spread = its variance); no label (reads as the plain baseline). +// - average → the cumulative arithmetic mean of the rate as the CENTER, +// reusing the global EWMA spread as the scale. There is deliberately one +// dispersion source: the arithmetic average is the center, the EWMA spread +// is the wobble, so "average" and "default" differ only in the center. +// - time_of_day → the current hour-of-day bucket once it has enough samples, +// else the global baseline (a sparse hour never fires a spurious spike). +func (b *logBrain) resolveBaseline(p *Pattern, ts time.Time) (mean, std float64, label string) { + global := stats.EWMA{Mean: p.BaselineFrequency, Variance: p.BaselineVariance, Count: p.Count} + // The stored GLOBAL default applies unless the pattern carries its own + // explicit override. + mode := b.globalBaselineMode() + if p.SpikeBaselineMode != "" { + mode = normalizeBaselineMode(p.SpikeBaselineMode) + } + switch mode { + case baselineModeAverage: + return p.BaselineAvg, global.Std(), "the average baseline" + case baselineModeTimeOfDay: + if len(p.Seasonal) == stats.HoursPerDay { + idx := stats.SeasonalIndex(ts, stats.HoursPerDay) + bucket := p.Seasonal[idx] + if bucket.Count >= b.params.MinBucketSamples { + return bucket.Mean, bucket.Std(), fmt.Sprintf("the %02d:00 baseline", idx) + } + } + // Sparse (or not-yet-folded) hour: fall back to the global baseline. + return global.Mean, global.Std(), "the baseline" + default: + return global.Mean, global.Std(), "" + } +} + +// globalBaselineMode returns the operator-configured GLOBAL default baseline +// mode, read through the storage-backed spike settings on every call — the +// same read-through the report settings use, so there is no process-wide +// mutable settings global. A nil catalog (or an install with no persistence) +// yields the built-in default. +func (b *logBrain) globalBaselineMode() string { + if b.catalog == nil { + return baselineModeDefault } - return 0, 0, true + return LoadSpikeSettings(b.catalog.store).BaselineMode } // Learn folds the tick's observations into the catalog (one Upsert per @@ -167,12 +246,13 @@ func (b *logBrain) Learn(ctx context.Context, obs []core.Observation) error { return nil } -// Classify reproduces the pre-seam classify()/isSpike() decision exactly. It is -// called AFTER Learn has folded the tick, so the catalog holds the post-upsert -// count; the pre-fold baseline arrives via mean (snapshotted by Expected before -// Learn ran) and the pre-fold count is recovered as post-count − tick frequency. -// Logs always report Confident=true (see Expected); the worker suppresses a -// non-spiking known pattern via the VerdictKnownPattern gate, as before. +// Classify scores an already-folded observation against the pre-fold baseline +// snapshot (mean/std captured by Expected before Learn ran). A known pattern +// that is not spiking is normal; a burst — the tick's per-second rate sitting +// spike_z standard deviations above the learned normal, or crossing the +// absolute ceiling — supersedes "known" and flows to detect-AI. Logs always +// report Confident=true (see Expected); the worker suppresses a non-spiking +// known pattern via the VerdictKnownPattern gate. func (b *logBrain) Classify(obs core.Observation, mean, std float64, confident bool) core.TypedVerdict { tickFreq := obs.Frequency prevBaseline := mean @@ -206,17 +286,19 @@ func (b *logBrain) Classify(obs core.Observation, mean, std float64, confident b // // A known pattern can still spike — a steady drip suddenly flooding is // exactly what detect-AI should see. Spike supersedes known. - if isSpike(prevBaseline, prevCount, tickFreq, - b.cat.SpikeMultiplier, b.cat.SpikeMinFrequency, b.cat.SpikeMinBaselineCount) { - score := 0.0 - if prevBaseline > 0 { - score = float64(tickFreq) / prevBaseline - } + res := evalSpike(mean, std, prevCount, tickFreq, b.params) + if b.confirmSpike(obs.Key, res) { + // Name the baseline the tick was scored against in the explanation. + // The scored mean/std are the pre-fold snapshot the worker captured + // (passed in); only the textual label — which mode, and the hour for + // time-of-day — is re-derived from the post-fold pattern here. + _, _, label := b.resolveBaseline(p, obs.Timestamp) return core.TypedVerdict{ Class: core.VerdictSpike, Confident: true, - Score: score, + Score: res.Z, Baseline: prevBaseline, + Reason: spikeReason(res, mean, std, tickFreq, b.params.PollSeconds, label), } } return core.TypedVerdict{Class: core.VerdictKnownPattern, Confident: true, Baseline: prevBaseline} @@ -224,6 +306,26 @@ func (b *logBrain) Classify(obs core.Observation, mean, std float64, confident b return core.TypedVerdict{Class: core.VerdictUnknown, Confident: true, Baseline: prevBaseline} } +// confirmSpike applies the sustained-tick debounce to a spike candidate. A +// non-spike resets the streak. An absolute-ceiling hit fires immediately (the +// deterministic safety net bypasses the debounce). Otherwise the z-score spike +// must persist for spike_sustain_ticks consecutive ticks before firing; the +// default of 1 fires on the first tick (no debounce). Once sustained it keeps +// firing every tick until a non-spiking tick resets the streak. +func (b *logBrain) confirmSpike(key string, res spikeResult) bool { + b.spikeMu.Lock() + defer b.spikeMu.Unlock() + if !res.Spike { + delete(b.streak, key) + return false + } + if res.Ceiling { + return true + } + b.streak[key]++ + return b.streak[key] >= b.params.SustainTicks +} + // Promote persists a count-based auto-promotion on the LEARN path so it runs in // EVERY mode — training, shadow and detect — not only when Classify runs. The // worker calls it (via the signalPromoter seam) once per folded observation, @@ -275,11 +377,11 @@ func isLogKnown(prevVerdict string, count, autoPromoteAfter int) bool { // manual-only). // - Ready = isLogKnown(...), so an already-"known"/"spike"-marked or // count-promoted pattern reports Ready. -// - RatePerMin = the pattern's per-tick sighting EWMA (BaselineFrequency) -// converted to sightings/minute via pollInterval, used to derive an ETA. -// 0 is the stalled/unknown sentinel (no rate yet, no flow, or no poll -// interval wired) — the UI then shows no ETA. It is only computed while the -// pattern is not yet Ready, since a Ready pattern has no countdown. +// - RatePerMin = the pattern's learned per-second sighting rate +// (BaselineFrequency) rendered as sightings/minute (rate × 60), used to +// derive an ETA. 0 is the stalled/unknown sentinel (no rate yet, no flow, +// or no poll interval wired) — the UI then shows no ETA. It is only computed +// while the pattern is not yet Ready, since a Ready pattern has no countdown. func LogReadiness(p *Pattern, autoPromoteAfter int, pollInterval time.Duration) core.Readiness { if p == nil { return core.Readiness{} @@ -292,8 +394,11 @@ func LogReadiness(p *Pattern, autoPromoteAfter int, pollInterval time.Duration) r.Needed = autoPromoteAfter } // else Needed=0 → indeterminate (manual-only promotion) if !r.Ready && pollInterval > 0 && p.BaselineFrequency > 0 { - // BaselineFrequency is sightings/tick; convert to sightings/min. - r.RatePerMin = p.BaselineFrequency / pollInterval.Minutes() + // BaselineFrequency is now a per-second sighting rate, so sightings/min + // is just rate × 60 — poll-interval-independent. pollInterval is kept as + // the "worker wired?" guard: unset (0) means no live agent, so degrade + // to no ETA. + r.RatePerMin = p.BaselineFrequency * 60 } return r } diff --git a/pkg/agent/brain_log_readiness_test.go b/pkg/agent/brain_log_readiness_test.go index b644b78..a013e33 100644 --- a/pkg/agent/brain_log_readiness_test.go +++ b/pkg/agent/brain_log_readiness_test.go @@ -216,12 +216,12 @@ func TestLogReadiness_EdgeCases(t *testing.T) { } }) - t.Run("learning with a live baseline → RatePerMin = EWMA/pollMinutes", func(t *testing.T) { - // BaselineFrequency is sightings/tick; poll=30s=0.5min ⇒ 6/0.5 = 12/min. - p := &Pattern{Count: 10, BaselineFrequency: 6} + t.Run("learning with a live baseline → RatePerMin = rate × 60", func(t *testing.T) { + // BaselineFrequency is now a per-second rate; 0.2/s ⇒ 0.2 × 60 = 12/min. + p := &Pattern{Count: 10, BaselineFrequency: 0.2} r := LogReadiness(p, 100, poll) if r.RatePerMin != 12 { - t.Errorf("RatePerMin = %v, want 12 (6 sightings/tick ÷ 0.5 min/tick)", r.RatePerMin) + t.Errorf("RatePerMin = %v, want 12 (0.2/s × 60)", r.RatePerMin) } }) } diff --git a/pkg/agent/brain_log_test.go b/pkg/agent/brain_log_test.go index 2bb0022..7ef1b9f 100644 --- a/pkg/agent/brain_log_test.go +++ b/pkg/agent/brain_log_test.go @@ -24,7 +24,21 @@ func newLogBrainForTest(t *testing.T, cat config.AgentCatalogConfig) (*logBrain, if len(errs) > 0 { t.Fatalf("NewServiceMatcher: %v", errs) } - return newLogBrain("es:test", NewMiner(0.4, 4, 100), c, m, svc, 0.2, cat, nil), c + // Wire the fold the way the worker does (30s tick), so Upsert folds a + // per-second rate through the same estimator the detector scores against. + const pollSeconds = 30.0 + c.SetBaselineFold(resolveSpikeParams(cat, pollSeconds).fold()) + return newLogBrain("es:test", NewMiner(0.4, 4, 100), c, m, svc, 0.2, cat, nil, pollSeconds), c +} + +// setGlobalBaselineMode persists the GLOBAL spike baseline mode into the +// brain's catalog store, the same seam resolveBaseline reads through as the +// runtime default (the mode is no longer a YAML config key). +func setGlobalBaselineMode(t *testing.T, c *Catalog, mode string) { + t.Helper() + if err := SaveSpikeSettings(c.store, SpikeSettings{BaselineMode: mode}); err != nil { + t.Fatalf("SaveSpikeSettings(%q): %v", mode, err) + } } func TestLogBrain_Kind(t *testing.T) { @@ -85,7 +99,7 @@ func TestLogBrain_GroupRespectsRegexFilter(t *testing.T) { t.Fatalf("NewRegexMatcher: %v", errs) } svc, _ := NewServiceMatcher(nil) - b := newLogBrain("es:test", NewMiner(0.4, 4, 100), c, m, svc, 0.2, config.AgentCatalogConfig{}, nil) + b := newLogBrain("es:test", NewMiner(0.4, 4, 100), c, m, svc, 0.2, config.AgentCatalogConfig{}, nil, 30) obs, err := b.Group(context.Background(), []core.Signal{ {Message: "ERROR boom happened code=1"}, @@ -182,7 +196,7 @@ func logObs(key string, freq int) core.Observation { func TestLogBrain_ClassifyLifecycle(t *testing.T) { cat := config.AgentCatalogConfig{ AutoPromoteAfter: 100, - SpikeMultiplier: 5, + SpikeZ: 3.0, SpikeMinFrequency: 5, SpikeMinBaselineCount: 20, } @@ -234,8 +248,9 @@ func TestLogBrain_ClassifyLifecycle(t *testing.T) { } // --- auto_promote_after threshold semantics -------------------------- -// Spike is left disabled (SpikeMultiplier == 0) throughout so it can never mask -// the count-based promotion verdict under test. +// The freq=1 ticks below sit under spike_min_frequency (5), so the spike +// detector can never fire and mask the count-based promotion verdict under +// test. // (a) The shipped default (100, supplied by the embedded default_config layer // for an unset key) still promotes exactly at the 100th sighting. diff --git a/pkg/agent/catalog.go b/pkg/agent/catalog.go index d725dfc..440780d 100644 --- a/pkg/agent/catalog.go +++ b/pkg/agent/catalog.go @@ -4,11 +4,13 @@ import ( "encoding/json" "fmt" "log" + "math" "sort" "sync" "time" "github.com/VersusControl/versus-incident/pkg/core" + "github.com/VersusControl/versus-incident/pkg/stats" "github.com/VersusControl/versus-incident/pkg/storage" ) @@ -28,9 +30,39 @@ type Pattern struct { FirstSeen time.Time `json:"first_seen"` LastSeen time.Time `json:"last_seen"` Count int `json:"count"` - // BaselineFrequency is the EWMA of per-tick counts. Computed during - // training; consumed by the spike detector in detect mode. + // BaselineFrequency is the EWMA of the pattern's per-second match RATE + // (tick matches ÷ poll seconds). Folding a rate rather than the raw per-tick + // count makes the number intuitive (~38/s, not ~1151/30s-tick) and + // independent of the poll interval. Computed during training; consumed by + // the spike detector in detect mode. (When no fold config is wired — the + // pre-rate default, exercised by unit tests — it folds the raw per-tick + // count, unchanged.) BaselineFrequency float64 `json:"baseline_frequency"` + // BaselineVariance is the EWMA variance folded alongside BaselineFrequency + // (West's incremental form), giving the spike detector the dispersion its + // z-score needs. 0 until the pattern re-learns after the variance migration + // (`omitempty` keeps a pre-feature catalog byte-identical on read-back). + BaselineVariance float64 `json:"baseline_variance,omitempty"` + // BaselineAvg is the cumulative arithmetic mean of the per-second match RATE + // (total ÷ number of folded ticks), updated incrementally each fold. It is + // the CENTER the "average" baseline mode scores against; that mode reuses + // the global EWMA spread (BaselineVariance) as its scale, so there is one + // dispersion source and only the center differs from the default mode. 0 + // until the pattern re-learns after the baseline-mode migration + // (`omitempty` keeps a pre-feature catalog byte-identical on read-back). + BaselineAvg float64 `json:"baseline_avg,omitempty"` + // Seasonal is the per-time-bucket EWMA rate (hour-of-day = 24 buckets, or + // hour-of-week = 168, UTC), letting the detector score a tick against the + // normal-for-this-hour rate. Empty unless seasonal spike detection is + // enabled; length is the configured period. `omitempty` keeps a pre-feature + // catalog byte-identical on read-back. + Seasonal []stats.EWMA `json:"seasonal,omitempty"` + // SpikeBaselineMode is the operator's per-pattern override of which learned + // baseline the spike z-score is scored against ("default" | "average" | + // "time_of_day"). Empty means inherit the agent.catalog.spike_baseline_mode + // config default. `omitempty` keeps a pre-feature catalog byte-identical on + // read-back. + SpikeBaselineMode string `json:"spike_baseline_mode,omitempty"` // Verdict is the agent's classification of this pattern: "known" once // it is part of baseline (auto-promoted by count or set explicitly via // the admin API), otherwise empty. Operators flip a pattern to @@ -105,6 +137,32 @@ type Catalog struct { patterns map[string]*Pattern services map[string]*ServiceInfo dirty bool + fold BaselineFold +} + +// BaselineFold configures how Catalog.Upsert folds a tick into a pattern's +// baseline. The zero value is the legacy mean-only fold of the raw per-tick +// count (so a catalog with no fold wired — every unit test, and any pre-rate +// caller — behaves exactly as before). The agent worker wires the real config +// from the poll interval + spike settings via SetBaselineFold. +type BaselineFold struct { + // PollSeconds is the tick duration; the folded value is tickCount / + // PollSeconds, a per-second rate. <= 0 folds the raw per-tick count (legacy). + PollSeconds float64 + // RejectZ holds out a tick whose rate sits >= this many σ from the pre-fold + // baseline once the estimator is confident, so a burst can't drag the mean. + // <= 0 disables the hold-out (every tick folds). + RejectZ float64 + // SeasonalPeriod selects the seasonal buckets: 0 = off (global only), + // 24 = hour-of-day, 168 = hour-of-week. + SeasonalPeriod int + // MinBaselineCount is the confidence gate for the global outlier hold-out: + // the estimator is confident once the pattern's total sighting count reaches + // this. <= 0 leaves the global fold always cold (never rejects). + MinBaselineCount int + // MinBucketSamples is the confidence gate for a seasonal bucket's own + // hold-out (mirrors the detector's bucket-fallback threshold). + MinBucketSamples int } // catalogFile is the on-disk schema. Versioned so we can evolve the @@ -339,17 +397,110 @@ func (c *Catalog) Upsert(patternID, template, source string, tickCount int, alph } p.Template = template // keep template fresh as miner refines it p.LastSeen = now + prevCount := p.Count // pre-fold total sightings — the confidence gate p.Count += tickCount if alpha <= 0 { alpha = 0.2 } + c.foldBaseline(p, tickCount, alpha, now, prevCount) + c.dirty = true + return p +} + +// SetBaselineFold wires the baseline-fold configuration the agent worker +// derives from its poll interval + spike settings. It is set once at +// construction; the zero value (never set) keeps the legacy mean-only +// per-tick-count fold. +func (c *Catalog) SetBaselineFold(f BaselineFold) { + c.mu.Lock() + defer c.mu.Unlock() + c.fold = f +} + +// foldBaseline updates a pattern's learned baseline with this tick. Caller +// holds c.mu. +// +// With no fold config wired it is the original mean-only EWMA of the raw +// per-tick count — byte-identical to the pre-rate behavior. With a fold config +// it folds a per-SECOND rate (poll-independent), tracks the variance the +// z-score needs, holds out a strong outlier once confident so a burst can't +// drag the mean, and (when seasonal is enabled) folds the same rate into the +// current time bucket. +func (c *Catalog) foldBaseline(p *Pattern, tickCount int, alpha float64, now time.Time, prevCount int) { + f := c.fold + + // Legacy path: no fold config → the original mean-only fold of the raw + // per-tick count. No variance, no seasonal, so a pre-feature catalog reads + // back byte-identical. + if f.PollSeconds <= 0 && f.SeasonalPeriod <= 0 && f.RejectZ <= 0 { + if p.BaselineFrequency == 0 { + p.BaselineFrequency = float64(tickCount) + } else { + p.BaselineFrequency = alpha*float64(tickCount) + (1-alpha)*p.BaselineFrequency + } + return + } + + // The value we fold: a per-second rate when a poll interval is configured. + value := float64(tickCount) + if f.PollSeconds > 0 { + value = float64(tickCount) / f.PollSeconds + } + + // Global mean + variance. The first fold seeds the mean at the value (as + // the pre-rate seed did); later folds run the incremental update unless the + // tick is a confident outlier, in which case it is held out. if p.BaselineFrequency == 0 { - p.BaselineFrequency = float64(tickCount) + p.BaselineFrequency = value + p.BaselineVariance = 0 } else { - p.BaselineFrequency = alpha*float64(tickCount) + (1-alpha)*p.BaselineFrequency + std := math.Sqrt(math.Max(0, p.BaselineVariance)) + confident := f.MinBaselineCount > 0 && prevCount >= f.MinBaselineCount + if !stats.ShouldReject(value, p.BaselineFrequency, std, confident, f.RejectZ) { + g := stats.EWMA{Mean: p.BaselineFrequency, Variance: p.BaselineVariance, Count: 2} + g.Observe(value, alpha) + p.BaselineFrequency = g.Mean + p.BaselineVariance = g.Variance + } } - c.dirty = true - return p + + // Seasonal buckets: fold the same rate into the current hour's bucket, with + // the same confident-outlier hold-out per bucket. + if f.SeasonalPeriod > 0 { + if len(p.Seasonal) != f.SeasonalPeriod { + grown := make([]stats.EWMA, f.SeasonalPeriod) + copy(grown, p.Seasonal) + p.Seasonal = grown + } + idx := stats.SeasonalIndex(now, f.SeasonalPeriod) + bucket := p.Seasonal[idx] + bConfident := f.MinBucketSamples > 0 && bucket.Count >= f.MinBucketSamples + if !stats.ShouldReject(value, bucket.Mean, bucket.Std(), bConfident, f.RejectZ) { + bucket.Observe(value, alpha) + p.Seasonal[idx] = bucket + // Cumulative arithmetic mean of the rate, folded over the SAME + // accepted ticks the seasonal buckets are. The fold count is exactly + // the total seasonal observation count (every accepted tick bumps + // one bucket), so no separate counter column is needed and the + // backends stay byte-identical: avg += (value − avg) / n, where n is + // the seasonal total AFTER this tick. A strong outlier held out of + // the seasonal baseline is held out of the average too. + if n := seasonalCount(p.Seasonal); n > 0 { + p.BaselineAvg += (value - p.BaselineAvg) / float64(n) + } + } + } +} + +// seasonalCount sums the observation counts across every seasonal bucket — the +// total number of ticks folded into the seasonal baseline, which doubles as the +// fold count for the cumulative arithmetic mean (BaselineAvg). +func seasonalCount(buckets []stats.EWMA) int { + total := 0 + for _, b := range buckets { + total += b.Count + } + return total } // RecordSample appends a redacted example log line to a pattern's bounded diff --git a/pkg/agent/catalog_pg_store.go b/pkg/agent/catalog_pg_store.go index 7394396..740533e 100644 --- a/pkg/agent/catalog_pg_store.go +++ b/pkg/agent/catalog_pg_store.go @@ -9,6 +9,7 @@ import ( "time" "github.com/VersusControl/versus-incident/pkg/core" + "github.com/VersusControl/versus-incident/pkg/stats" "github.com/VersusControl/versus-incident/pkg/storage" ) @@ -72,7 +73,9 @@ const ( sqlCatalogLoadLogs = ` SELECT p.id, COALESCE(p.service, ''), COALESCE(p.verdict, ''), p.tags, p.deleted, l.template, COALESCE(l.source, ''), COALESCE(l.rule_name, ''), - l.count, l.baseline_frequency, l.samples, l.first_seen, l.last_seen + l.count, l.baseline_frequency, l.baseline_variance, l.baseline_avg, + COALESCE(l.spike_baseline_mode, ''), l.seasonal, + l.samples, l.first_seen, l.last_seen FROM vs_logs l JOIN vs_patterns p ON p.org_id = l.org_id AND p.id = l.pattern_id AND p.kind = 'log' @@ -102,14 +105,19 @@ const ( sqlCatalogUpsertLog = ` INSERT INTO vs_logs (org_id, pattern_id, instance_index, template, source, rule_name, - count, baseline_frequency, samples, first_seen, last_seen) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + count, baseline_frequency, baseline_variance, baseline_avg, + spike_baseline_mode, seasonal, samples, first_seen, last_seen) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) ON CONFLICT (org_id, pattern_id, instance_index) DO UPDATE SET template = EXCLUDED.template, source = EXCLUDED.source, rule_name = EXCLUDED.rule_name, count = EXCLUDED.count, baseline_frequency = EXCLUDED.baseline_frequency, + baseline_variance = EXCLUDED.baseline_variance, + baseline_avg = EXCLUDED.baseline_avg, + spike_baseline_mode = EXCLUDED.spike_baseline_mode, + seasonal = EXCLUDED.seasonal, samples = EXCLUDED.samples, last_seen = EXCLUDED.last_seen` @@ -120,12 +128,24 @@ const ( VALUES ($1, $2, $3, $4) ON CONFLICT (org_id, name) DO NOTHING` - // Snapshot: fleet-wide read view — SUM/MIN/MAX across partitions, joined - // to the curated root, scalar attributes from the lowest-index partition. + // Snapshot: fleet-wide read view. Fleet-additive counters (count and the + // per-second-rate baseline_frequency) are SUMmed across every instance's + // partition, first/last-seen are MIN/MAX across them, and the remaining + // scalar attributes — template, source, rule_name, samples AND the learned + // dispersion baselines (baseline_variance, baseline_avg, spike_baseline_mode, + // seasonal) — are all taken from the single lowest-instance_index partition. + // Under the single-writer-per-partition model each instance owns its own + // partition, so those non-additive learned stats cannot be meaningfully + // summed; reading them all from the same lowest-index partition keeps them + // mutually consistent, and for the common single-instance case + // (instance_index = 0, one row per pattern) this makes Snapshot return the + // exact same baseline columns as the per-partition Load. sqlCatalogSnapshotLogs = ` SELECT p.id, COALESCE(p.service, ''), COALESCE(p.verdict, ''), p.tags, agg.total_count, agg.first_seen, agg.last_seen, agg.total_baseline, - lo.template, COALESCE(lo.source, ''), COALESCE(lo.rule_name, ''), lo.samples + lo.template, COALESCE(lo.source, ''), COALESCE(lo.rule_name, ''), + lo.baseline_variance, lo.baseline_avg, + COALESCE(lo.spike_baseline_mode, ''), lo.seasonal, lo.samples FROM vs_patterns p JOIN LATERAL ( SELECT SUM(count) AS total_count, MIN(first_seen) AS first_seen, @@ -134,7 +154,8 @@ const ( WHERE org_id = p.org_id AND pattern_id = p.id ) agg ON agg.total_count IS NOT NULL JOIN LATERAL ( - SELECT template, source, rule_name, samples + SELECT template, source, rule_name, + baseline_variance, baseline_avg, spike_baseline_mode, seasonal, samples FROM vs_logs WHERE org_id = p.org_id AND pattern_id = p.id ORDER BY instance_index ASC @@ -231,15 +252,20 @@ func (s *pgCatalogStore) Load() (map[string]*Pattern, map[string]*ServiceInfo, e var ( id, service, verdict string tagsRaw, samplesRaw []byte + seasonalRaw []byte deleted bool template, source, ruleName string count int64 baselineFreq float64 + baselineVar float64 + baselineAvg float64 + spikeBaselineMode string firstSeen, lastSeen time.Time ) if err := rows.Scan(&id, &service, &verdict, &tagsRaw, &deleted, - &template, &source, &ruleName, &count, &baselineFreq, - &samplesRaw, &firstSeen, &lastSeen); err != nil { + &template, &source, &ruleName, &count, &baselineFreq, &baselineVar, + &baselineAvg, &spikeBaselineMode, &seasonalRaw, &samplesRaw, + &firstSeen, &lastSeen); err != nil { rows.Close() return patterns, services, fmt.Errorf("agent: pg catalog scan log: %w", err) } @@ -251,6 +277,10 @@ func (s *pgCatalogStore) Load() (map[string]*Pattern, map[string]*ServiceInfo, e LastSeen: lastSeen, Count: int(count), BaselineFrequency: baselineFreq, + BaselineVariance: baselineVar, + BaselineAvg: baselineAvg, + SpikeBaselineMode: spikeBaselineMode, + Seasonal: decodeSeasonal(seasonalRaw), Verdict: verdict, RuleName: ruleName, Source: source, @@ -321,8 +351,10 @@ func (s *pgCatalogStore) Persist(patterns map[string]*Pattern, services map[stri } if _, err := tx.Exec(sqlCatalogUpsertLog, s.orgID, id, s.instanceIndex, p.Template, nullIfEmpty(p.Source), - nullIfEmpty(p.RuleName), int64(p.Count), p.BaselineFrequency, - encodeStringSlice(rescrubSamples(p.Samples, s.scrub)), utcOrNow(p.FirstSeen), utcOrNow(p.LastSeen), + nullIfEmpty(p.RuleName), int64(p.Count), p.BaselineFrequency, p.BaselineVariance, + p.BaselineAvg, p.SpikeBaselineMode, encodeSeasonal(p.Seasonal), + encodeStringSlice(rescrubSamples(p.Samples, s.scrub)), + utcOrNow(p.FirstSeen), utcOrNow(p.LastSeen), ); err != nil { return fmt.Errorf("agent: pg catalog persist log %q: %w", id, err) } @@ -343,9 +375,12 @@ func (s *pgCatalogStore) Persist(patterns map[string]*Pattern, services map[stri return nil } -// Snapshot returns the unified fleet-wide read view: counts SUMmed across -// partitions, scalar attributes from the lowest-index partition, curated root -// columns applied, tombstoned patterns/services excluded. +// Snapshot returns the unified fleet-wide read view: counts and baseline +// frequency SUMmed across partitions, the remaining scalar attributes (template, +// source, rule_name, samples, and the learned dispersion baselines +// variance/avg/mode/seasonal) taken from the lowest-index partition, curated +// root columns applied, tombstoned patterns/services excluded. For the common +// single-instance case this returns the exact same baseline columns as Load. func (s *pgCatalogStore) Snapshot() ([]*Pattern, map[string]ServiceInfo, error) { rows, err := s.db.Query(sqlCatalogSnapshotLogs, s.orgID) if err != nil { @@ -356,14 +391,19 @@ func (s *pgCatalogStore) Snapshot() ([]*Pattern, map[string]ServiceInfo, error) var ( id, service, verdict string tagsRaw, samplesRaw []byte + seasonalRaw []byte totalCount int64 firstSeen, lastSeen time.Time totalBaseline float64 + baselineVar float64 + baselineAvg float64 + spikeBaselineMode string template, source, ruleName string ) if err := rows.Scan(&id, &service, &verdict, &tagsRaw, &totalCount, &firstSeen, &lastSeen, &totalBaseline, - &template, &source, &ruleName, &samplesRaw); err != nil { + &template, &source, &ruleName, &baselineVar, &baselineAvg, + &spikeBaselineMode, &seasonalRaw, &samplesRaw); err != nil { rows.Close() return nil, nil, fmt.Errorf("agent: pg catalog snapshot scan: %w", err) } @@ -375,6 +415,10 @@ func (s *pgCatalogStore) Snapshot() ([]*Pattern, map[string]ServiceInfo, error) LastSeen: lastSeen, Count: int(totalCount), BaselineFrequency: totalBaseline, + BaselineVariance: baselineVar, + BaselineAvg: baselineAvg, + SpikeBaselineMode: spikeBaselineMode, + Seasonal: decodeSeasonal(seasonalRaw), Verdict: verdict, RuleName: ruleName, Source: source, @@ -595,6 +639,35 @@ func decodeStringSlice(raw []byte) []string { return out } +// encodeSeasonal marshals the seasonal EWMA buckets to their JSONB payload, +// mirroring the enterprise vs_metrics/vs_traces seasonal encoding (an array of +// {mean,variance,count}). An empty/nil slice emits "[]" so the column never +// stores SQL NULL or JSON "null" (the NOT NULL / DEFAULT '[]' contract). +func encodeSeasonal(v []stats.EWMA) []byte { + if len(v) == 0 { + return []byte("[]") + } + b, err := json.Marshal(v) + if err != nil { + return []byte("[]") + } + return b +} + +// decodeSeasonal unmarshals the seasonal JSONB payload back to the bucket +// slice, normalizing empty/"[]"/"null" to nil so a pattern with no seasonal +// buckets reads back byte-identical (omitempty). +func decodeSeasonal(raw []byte) []stats.EWMA { + if len(raw) == 0 { + return nil + } + var out []stats.EWMA + if err := json.Unmarshal(raw, &out); err != nil || len(out) == 0 { + return nil + } + return out +} + // nullIfEmpty maps "" to a SQL NULL for the nullable source/rule_name columns // so an empty attribution reads back as "" via COALESCE. func nullIfEmpty(s string) interface{} { diff --git a/pkg/agent/catalog_pg_store_test.go b/pkg/agent/catalog_pg_store_test.go index 7bd6ea7..7393bcb 100644 --- a/pkg/agent/catalog_pg_store_test.go +++ b/pkg/agent/catalog_pg_store_test.go @@ -17,6 +17,8 @@ import ( "testing" "time" + "github.com/VersusControl/versus-incident/pkg/config" + "github.com/VersusControl/versus-incident/pkg/stats" "github.com/VersusControl/versus-incident/pkg/storage" _ "github.com/jackc/pgx/v5/stdlib" ) @@ -115,6 +117,12 @@ func newPGCatalog(t *testing.T) (*Catalog, *sql.DB) { if err != nil { t.Fatalf("LoadCatalog: %v", err) } + // Wire the same baseline fold the agent worker installs at boot + // (worker.go), so Upsert folds a real per-second rate: the global + // EWMA + variance, the 24 hour-of-day seasonal buckets, and the + // cumulative arithmetic average. Without this the catalog uses the + // legacy mean-only fold and baseline_avg/variance/seasonal never move. + cat.SetBaselineFold(resolveSpikeParams(config.AgentCatalogConfig{}, 30).fold()) return cat, db } @@ -327,6 +335,117 @@ func TestPGCatalog_ReloadRoundTrip(t *testing.T) { if len(got.Tags) != 1 || got.Tags[0] != "routine" { t.Fatalf("reloaded tags = %v, want [routine]", got.Tags) } + // The arithmetic-average baseline is folded during Upsert and must survive + // the round-trip through the new vs_logs.baseline_avg column. An unset + // per-pattern mode round-trips as '' (inherit the config default). + if got.BaselineAvg <= 0 { + t.Fatalf("reloaded baseline_avg = %v, want > 0 (folded during learn)", got.BaselineAvg) + } + if got.SpikeBaselineMode != "" { + t.Fatalf("reloaded spike_baseline_mode = %q, want empty (inherit config default)", got.SpikeBaselineMode) + } +} + +// TestPGCatalog_BaselineModeRoundTrip proves every spike-baseline column added +// by migration 007 — baseline_avg (DOUBLE) and spike_baseline_mode (TEXT) — +// together with the pre-existing baseline_variance and the 24 hour-of-day +// seasonal buckets, survives a write→reload cycle through the typed vs_logs +// row unchanged, in EACH of the three per-pattern modes. It constructs the +// patterns directly (not via the learner) so the asserted values are exact and +// the mode string is the one persisted, isolating the column round-trip. +func TestPGCatalog_BaselineModeRoundTrip(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN not set; skipping postgres tests") + } + store, err := storage.NewPostgres(storage.PostgresOptions{DSN: dsn}) + if err != nil { + t.Fatalf("NewPostgres: %v", err) + } + acc, ok := store.(storage.SQLAccessor) + if !ok { + t.Fatal("postgres provider must implement storage.SQLAccessor") + } + db := acc.DB() + if _, err := db.Exec(`TRUNCATE TABLE vs_patterns, vs_logs, vs_services CASCADE`); err != nil { + t.Fatalf("truncate signal tables: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + + // One pattern per mode. Each carries a distinct global stat, a cumulative + // average, and a populated hour-of-day seasonal bucket, so a lost/rewritten + // column shows up as a mismatched read-back. + seasonal := make([]stats.EWMA, stats.HoursPerDay) + seasonal[2] = stats.EWMA{Mean: 44, Variance: 6.25, Count: 100} + seasonal[9] = stats.EWMA{Mean: 12.5, Variance: 1.5, Count: 37} + now := time.Now().UTC() + want := map[string]struct { + mode string + freq float64 + variance float64 + avg float64 + }{ + "p-default": {"default", 10, 4, 20}, + "p-average": {"average", 8.5, 2.25, 17.75}, + "p-time-of-day": {"time_of_day", 44, 6.25, 30.5}, + } + patterns := make(map[string]*Pattern, len(want)) + for id, w := range want { + patterns[id] = &Pattern{ + ID: id, + OrgID: storage.DefaultOrgID, + Template: "boom <*>", + Count: 200, + BaselineFrequency: w.freq, + BaselineVariance: w.variance, + BaselineAvg: w.avg, + SpikeBaselineMode: w.mode, + Seasonal: seasonal, + FirstSeen: now, + LastSeen: now, + } + } + + cs := NewPostgresCatalogStore(db, storage.DefaultOrgID, 0) + if err := cs.Persist(patterns, nil); err != nil { + t.Fatalf("Persist: %v", err) + } + + // Fresh store + Load simulates a process restart: the columns are read + // straight back from vs_logs, not served from an in-memory cache. + reloaded := NewPostgresCatalogStore(db, storage.DefaultOrgID, 0) + got, _, err := reloaded.Load() + if err != nil { + t.Fatalf("reload Load: %v", err) + } + for id, w := range want { + p := got[id] + if p == nil { + t.Fatalf("pattern %q missing after reload", id) + } + if p.SpikeBaselineMode != w.mode { + t.Fatalf("%s spike_baseline_mode = %q, want %q", id, p.SpikeBaselineMode, w.mode) + } + if p.BaselineAvg != w.avg { + t.Fatalf("%s baseline_avg = %v, want %v", id, p.BaselineAvg, w.avg) + } + if p.BaselineFrequency != w.freq { + t.Fatalf("%s baseline_frequency = %v, want %v", id, p.BaselineFrequency, w.freq) + } + if p.BaselineVariance != w.variance { + t.Fatalf("%s baseline_variance = %v, want %v", id, p.BaselineVariance, w.variance) + } + // The 24 seasonal buckets survive intact (values + counts). + if len(p.Seasonal) != stats.HoursPerDay { + t.Fatalf("%s seasonal len = %d, want %d", id, len(p.Seasonal), stats.HoursPerDay) + } + if p.Seasonal[2] != seasonal[2] { + t.Fatalf("%s seasonal[2] = %+v, want %+v", id, p.Seasonal[2], seasonal[2]) + } + if p.Seasonal[9] != seasonal[9] { + t.Fatalf("%s seasonal[9] = %+v, want %+v", id, p.Seasonal[9], seasonal[9]) + } + } } // redactScrubber redacts a fixed secret token so the storage-boundary re-scrub @@ -410,3 +529,105 @@ func TestPGCatalog_RedactionAtStorageBoundary(t *testing.T) { t.Fatalf("secret survived the storage-boundary re-scrub: %v", got.Samples) } } + +// TestPGCatalog_SnapshotLoadBaselineParity guards the fleet-read/per-pattern-read +// column-parity bug: for a single-instance pattern (instance_index = 0, the +// common OSS case) the fleet-wide Snapshot (backing the list/peek) must return +// the exact same learned baseline fields as the per-partition Load (backing the +// detail page) — baseline_frequency, baseline_variance, baseline_avg, +// spike_baseline_mode, and the hour-of-day seasonal buckets. A regression that +// drops any of these columns from the Snapshot query/scan (leaving the list with +// an empty seasonal baseline or a zeroed average/variance while the detail is +// correct) fails here. +func TestPGCatalog_SnapshotLoadBaselineParity(t *testing.T) { + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN not set; skipping postgres tests") + } + store, err := storage.NewPostgres(storage.PostgresOptions{DSN: dsn}) + if err != nil { + t.Fatalf("NewPostgres: %v", err) + } + acc, ok := store.(storage.SQLAccessor) + if !ok { + t.Fatal("postgres provider must implement storage.SQLAccessor") + } + db := acc.DB() + if _, err := db.Exec(`TRUNCATE TABLE vs_patterns, vs_logs, vs_services CASCADE`); err != nil { + t.Fatalf("truncate signal tables: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + + // A single-instance pattern carrying every learned baseline: a global + // EWMA + variance, a cumulative average, a per-pattern mode override, and + // two populated hour-of-day seasonal buckets. + seasonal := make([]stats.EWMA, stats.HoursPerDay) + seasonal[3] = stats.EWMA{Mean: 44, Variance: 6.25, Count: 100} + seasonal[14] = stats.EWMA{Mean: 12.5, Variance: 1.5, Count: 37} + now := time.Now().UTC() + want := &Pattern{ + ID: "p-parity", + OrgID: storage.DefaultOrgID, + Template: "boom <*>", + Count: 321, + BaselineFrequency: 3.82, + BaselineVariance: 63.4, + BaselineAvg: 11.63, + SpikeBaselineMode: "average", + Seasonal: seasonal, + FirstSeen: now, + LastSeen: now, + } + + cs := NewPostgresCatalogStore(db, storage.DefaultOrgID, 0) + if err := cs.Persist(map[string]*Pattern{want.ID: want}, nil); err != nil { + t.Fatalf("Persist: %v", err) + } + + // Load (detail path) and Snapshot (list path) must agree on every baseline. + loaded, _, err := cs.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + det := loaded[want.ID] + if det == nil { + t.Fatalf("pattern %q missing from Load", want.ID) + } + snap, _, err := cs.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + lst := patternByID(snap, want.ID) + if lst == nil { + t.Fatalf("pattern %q missing from Snapshot", want.ID) + } + + if lst.BaselineFrequency != det.BaselineFrequency { + t.Fatalf("baseline_frequency: snapshot %v != load %v", lst.BaselineFrequency, det.BaselineFrequency) + } + if lst.BaselineVariance != det.BaselineVariance { + t.Fatalf("baseline_variance: snapshot %v != load %v", lst.BaselineVariance, det.BaselineVariance) + } + if lst.BaselineAvg != det.BaselineAvg { + t.Fatalf("baseline_avg: snapshot %v != load %v", lst.BaselineAvg, det.BaselineAvg) + } + if lst.SpikeBaselineMode != det.SpikeBaselineMode { + t.Fatalf("spike_baseline_mode: snapshot %q != load %q", lst.SpikeBaselineMode, det.SpikeBaselineMode) + } + if len(lst.Seasonal) != len(det.Seasonal) { + t.Fatalf("seasonal len: snapshot %d != load %d", len(lst.Seasonal), len(det.Seasonal)) + } + for i := range det.Seasonal { + if lst.Seasonal[i] != det.Seasonal[i] { + t.Fatalf("seasonal[%d]: snapshot %+v != load %+v", i, lst.Seasonal[i], det.Seasonal[i]) + } + } + + // And each round-trips to the value written (Default is populated, not 0). + if det.BaselineFrequency != want.BaselineFrequency { + t.Fatalf("baseline_frequency load = %v, want %v", det.BaselineFrequency, want.BaselineFrequency) + } + if det.BaselineAvg != want.BaselineAvg { + t.Fatalf("baseline_avg load = %v, want %v", det.BaselineAvg, want.BaselineAvg) + } +} diff --git a/pkg/agent/catalog_reassign_test.go b/pkg/agent/catalog_reassign_test.go index b2a3197..fb3b8cc 100644 --- a/pkg/agent/catalog_reassign_test.go +++ b/pkg/agent/catalog_reassign_test.go @@ -71,7 +71,7 @@ func TestLogBrain_ReObservationRepointsServiceViaOverride(t *testing.T) { miner := NewMiner(0.4, 4, 100) // nil matcher (no regex pre-filter) + nil service matcher (Extract → "", // so auto-detection yields "_unknown") isolate the override behaviour. - brain := newLogBrain("src", miner, cat, nil, nil, 0.2, config.AgentCatalogConfig{}, nil) + brain := newLogBrain("src", miner, cat, nil, nil, 0.2, config.AgentCatalogConfig{}, nil, 0) ctx := context.Background() const msg = "checkout failed for order 123" diff --git a/pkg/agent/catalog_store_test.go b/pkg/agent/catalog_store_test.go index e0f0e75..2512292 100644 --- a/pkg/agent/catalog_store_test.go +++ b/pkg/agent/catalog_store_test.go @@ -233,7 +233,7 @@ func TestCatalog_InstalledStore_HotPathNeverCallsStore(t *testing.T) { _ = cat.Get("p1") _ = cat.Get("missing") - brain := newLogBrain("src", nil, cat, nil, nil, 0.2, config.AgentCatalogConfig{}, nil) + brain := newLogBrain("src", nil, cat, nil, nil, 0.2, config.AgentCatalogConfig{}, nil, 0) _, _, _ = brain.Expected(context.Background(), "p1", time.Now()) _, _, _ = brain.Expected(context.Background(), "missing", time.Now()) diff --git a/pkg/agent/detect_log.go b/pkg/agent/detect_log.go index 0e1079c..1c658a2 100644 --- a/pkg/agent/detect_log.go +++ b/pkg/agent/detect_log.go @@ -27,14 +27,25 @@ type DetectEvent struct { Timestamp time.Time `json:"timestamp"` // worker decision time // Pattern context - Source string `json:"source"` - PatternID string `json:"pattern_id"` - Template string `json:"template"` - Service string `json:"service,omitempty"` - Verdict string `json:"verdict"` // unknown | spike - Frequency int `json:"frequency"` - Baseline float64 `json:"baseline"` - Samples []string `json:"samples,omitempty"` // up to 3, redacted + Source string `json:"source"` + PatternID string `json:"pattern_id"` + Template string `json:"template"` + Service string `json:"service,omitempty"` + Verdict string `json:"verdict"` // unknown | spike + Frequency int `json:"frequency"` + Baseline float64 `json:"baseline"` + // BaselineStd is the learned standard deviation the spike z-score was + // computed against (0 when not a spike or no dispersion learned yet). + // Together with Baseline (the mean) and Score (the z-score) it makes every + // spike verdict reconstructable from the stored numbers. + BaselineStd float64 `json:"baseline_std,omitempty"` + // Score is the spike z-score: how many standard deviations the tick's + // per-second rate sat above the learned baseline. + Score float64 `json:"score,omitempty"` + // Explanation is the deterministic human-readable spike math, e.g. + // "47.3/s = 4.2σ above 38.4/s ± 3.1". + Explanation string `json:"explanation,omitempty"` + Samples []string `json:"samples,omitempty"` // up to 3, redacted // RuleSeverity is the operator-declared severity floor carried from the // source signals (empty for auto-discovered signals). Persisted so the // declared-severity-honoured path is auditable/testable. diff --git a/pkg/agent/learnfilter_test.go b/pkg/agent/learnfilter_test.go index 3f50356..236df6b 100644 --- a/pkg/agent/learnfilter_test.go +++ b/pkg/agent/learnfilter_test.go @@ -340,7 +340,7 @@ func newModeOutputWorker(t *testing.T, mode string, src core.SignalSource, bundl Mode: mode, Catalog: config.AgentCatalogConfig{ AutoPromoteAfter: 100, - SpikeMultiplier: 5, + SpikeZ: 3.0, SpikeMinFrequency: 5, SpikeMinBaselineCount: 20, }, diff --git a/pkg/agent/matcher_kind_test.go b/pkg/agent/matcher_kind_test.go index a04079d..d0b3957 100644 --- a/pkg/agent/matcher_kind_test.go +++ b/pkg/agent/matcher_kind_test.go @@ -116,7 +116,7 @@ func TestKindOf_AgreesWithLogBrainSeam(t *testing.T) { if err != nil { t.Fatalf("LoadCatalog: %v", err) } - var lb core.SignalLearner = newLogBrain("x", NewMiner(0.4, 4, 100), cat, nil, nil, 0.2, config.AgentCatalogConfig{}, nil) + var lb core.SignalLearner = newLogBrain("x", NewMiner(0.4, 4, 100), cat, nil, nil, 0.2, config.AgentCatalogConfig{}, nil, 0) for _, typ := range []string{"elasticsearch", "file", "loki", "cloudwatchlogs", "graylog", "splunk"} { if got := string(signalsources.KindOf(typ)); got != lb.Kind() { t.Errorf("KindOf(%q) = %q, log brain Kind() = %q (drift)", typ, got, lb.Kind()) diff --git a/pkg/agent/sample_redaction_test.go b/pkg/agent/sample_redaction_test.go index 2ace9b8..ced9a4f 100644 --- a/pkg/agent/sample_redaction_test.go +++ b/pkg/agent/sample_redaction_test.go @@ -22,7 +22,7 @@ func learnOneSample(t *testing.T, red core.Scrubber, sig core.Signal) (*Catalog, if err != nil { t.Fatalf("LoadCatalog: %v", err) } - b := newLogBrain("src", NewMiner(0.4, 4, 100), cat, nil, nil, 0.2, config.AgentCatalogConfig{}, red) + b := newLogBrain("src", NewMiner(0.4, 4, 100), cat, nil, nil, 0.2, config.AgentCatalogConfig{}, red, 0) ctx := context.Background() obs, err := b.Group(ctx, []core.Signal{sig}) if err != nil { diff --git a/pkg/agent/spike.go b/pkg/agent/spike.go new file mode 100644 index 0000000..9ae6d12 --- /dev/null +++ b/pkg/agent/spike.go @@ -0,0 +1,194 @@ +package agent + +import ( + "fmt" + "strings" + + "github.com/VersusControl/versus-incident/pkg/config" + "github.com/VersusControl/versus-incident/pkg/stats" +) + +// spike.go — the log volume-spike detector. +// +// A known log pattern is re-flagged as a spike when its per-second match RATE +// jumps well above the learned normal. The bar is a z-score — how many standard +// deviations above the baseline this tick sits — so it self-scales to each +// pattern's own volatility: a +1000 burst on a chatty, high-volume pattern +// trips even though its mean is large, which the old volume-blind +// mean × multiplier ratio could not. An absolute ceiling is a deterministic +// safety net that always surfaces a tick past a hard count, even during warmup. +// +// All inputs are the numbers the fold already tracks (rate, mean, std) so a +// verdict is fully reconstructable from stored state — deterministic and +// auditable, no LLM in the decision. + +// logMinBucketSamples is how many observations a seasonal bucket needs before +// the detector scores against it directly (below it, it falls back to the +// global baseline). Mirrors the enterprise brain's bucket-confidence gate. +const logMinBucketSamples = 5 + +// Baseline modes select WHICH learned baseline the spike z-score is measured +// against. Seasonal buckets are always folded, so the mode can be flipped with +// no re-learn. +const ( + // baselineModeDefault scores against the global rate baseline: mean = + // baseline_frequency, spread = baseline_variance. + baselineModeDefault = "default" + // baselineModeAverage scores against the cumulative arithmetic mean of the + // rate as the center, reusing the global EWMA spread as the scale. + baselineModeAverage = "average" + // baselineModeTimeOfDay scores against the current hour-of-day bucket + // (24 UTC buckets), falling back to the global baseline until that hour is + // confident. + baselineModeTimeOfDay = "time_of_day" +) + +// normalizeBaselineMode maps a raw config/pattern mode value to one of the +// three known modes, folding any unset or unrecognized value to the global +// default so a typo can never silently disable scoring. +func normalizeBaselineMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case baselineModeAverage: + return baselineModeAverage + case baselineModeTimeOfDay: + return baselineModeTimeOfDay + default: + return baselineModeDefault + } +} + +// spikeParams are the resolved (defaults applied) spike-detection knobs. They +// drive both the baseline fold (the outlier-reject cutoff, the seasonal period) +// and the detector, so the two paths agree on what "normal" and "Nσ" mean. +type spikeParams struct { + Z float64 // z-score threshold; also the fold's outlier-reject cutoff + AbsCeiling int // absolute safety net; 0 = disabled + SustainTicks int // consecutive spiking ticks before firing; 1 = no debounce + MinFrequency int // absolute noise floor on the tick count + MinBaselineCount int // warmup/confidence gate on total sightings + PollSeconds float64 // rate = tickCount / PollSeconds; 0 = score the raw count + SeasonalPeriod int // hour-of-day buckets folded every tick (always 24 for logs) + MinBucketSamples int // seasonal bucket confidence gate +} + +// resolveSpikeParams reads the catalog config, applies the founder-approved +// defaults for any unset key, and folds in the worker's poll interval. +func resolveSpikeParams(cat config.AgentCatalogConfig, pollSeconds float64) spikeParams { + p := spikeParams{ + Z: cat.SpikeZ, + AbsCeiling: cat.SpikeAbsCeiling, + SustainTicks: cat.SpikeSustainTicks, + MinFrequency: cat.SpikeMinFrequency, + MinBaselineCount: cat.SpikeMinBaselineCount, + PollSeconds: pollSeconds, + // Time-of-day seasonal is always folded, fixed to hour-of-day. The + // operator picks whether to SCORE against it via the stored baseline + // mode, but the 24 buckets are always kept up to date so the choice + // needs no re-learn. + SeasonalPeriod: stats.HoursPerDay, + MinBucketSamples: logMinBucketSamples, + } + if p.Z <= 0 { + p.Z = config.DefaultSpikeZ + } + if p.SustainTicks <= 0 { + p.SustainTicks = config.DefaultSpikeSustainTicks + } + if p.MinFrequency <= 0 { + p.MinFrequency = config.DefaultSpikeMinFrequency + } + if p.MinBaselineCount <= 0 { + p.MinBaselineCount = config.DefaultSpikeMinBaselineCount + } + if p.AbsCeiling < 0 { + p.AbsCeiling = 0 + } + return p +} + +// fold derives the baseline-fold configuration from the spike params so the +// fold's outlier-reject cutoff, seasonal period, and confidence gates match the +// detector's exactly. +func (p spikeParams) fold() BaselineFold { + return BaselineFold{ + PollSeconds: p.PollSeconds, + RejectZ: p.Z, + SeasonalPeriod: p.SeasonalPeriod, + MinBaselineCount: p.MinBaselineCount, + MinBucketSamples: p.MinBucketSamples, + } +} + +// spikeResult is the detector's read on one tick: whether it is a spike, the +// z-score of its rate, the rate itself, and whether it tripped the absolute +// ceiling (which bypasses the sustained-tick debounce, being the hard net). +type spikeResult struct { + Spike bool + Ceiling bool + Z float64 + Rate float64 +} + +// evalSpike scores one tick against the pre-fold baseline (mean/std for the +// tick's time slot). It is pure: the same inputs always yield the same result. +// +// rate = tickFreq / pollSeconds (per-second; poll-independent) +// z = (rate − mean) / max(std, floor) +// spike = (confident && z ≥ Z) || (AbsCeiling>0 && tickFreq ≥ AbsCeiling) +// +// The absolute ceiling fires regardless of the warmup gate. The min-frequency +// floor keeps a low-count pattern from paging on a handful of matches, and the +// warmup gate (total sightings ≥ MinBaselineCount) holds the z-score until the +// baseline is trustworthy. +func evalSpike(mean, std float64, prevCount, tickFreq int, p spikeParams) spikeResult { + rate := float64(tickFreq) + if p.PollSeconds > 0 { + rate = float64(tickFreq) / p.PollSeconds + } + res := spikeResult{Z: stats.ZScore(rate, mean, std), Rate: rate} + + // Absolute ceiling — the deterministic "always page above N" net, valid + // even during warmup. + if p.AbsCeiling > 0 && tickFreq >= p.AbsCeiling { + res.Spike = true + res.Ceiling = true + return res + } + + // Absolute noise floor. + if tickFreq < p.MinFrequency { + return res + } + // Warmup/confidence gate + real baseline, then the z-score bar. + confident := prevCount >= p.MinBaselineCount && mean > 0 + if confident && res.Z >= p.Z { + res.Spike = true + } + return res +} + +// spikeReason renders the deterministic, human-readable explanation stored in +// the detect audit log, e.g. "47.3/s = 4.2σ above the 02:00 baseline 38.4/s +// ± 3.1". The baselineLabel names which baseline was scored (empty for the +// global default, "the average baseline", or "the HH:00 baseline"); it is fully +// reconstructable from the stored numbers. +func spikeReason(res spikeResult, mean, std float64, tickFreq int, pollSeconds float64, baselineLabel string) string { + if res.Ceiling && !(pollSeconds > 0 && res.Z >= 0) { + // Ceiling fired during warmup (no trustworthy baseline to compare σ to). + if pollSeconds > 0 { + return fmt.Sprintf("%.1f/s (%d matches) at or above the absolute ceiling", res.Rate, tickFreq) + } + return fmt.Sprintf("%d matches at or above the absolute ceiling", tickFreq) + } + // A named baseline (average / time-of-day) is inserted between "above" and + // the numbers so the operator can see which normal the tick was judged + // against; the global default carries no label and reads as before. + label := "" + if baselineLabel != "" { + label = baselineLabel + " " + } + if pollSeconds > 0 { + return fmt.Sprintf("%.1f/s = %.1fσ above %s%.1f/s ± %.1f", res.Rate, res.Z, label, mean, std) + } + return fmt.Sprintf("%d = %.1fσ above %s%.1f ± %.1f", tickFreq, res.Z, label, mean, std) +} diff --git a/pkg/agent/spike_baseline_mode_test.go b/pkg/agent/spike_baseline_mode_test.go new file mode 100644 index 0000000..c83ecb7 --- /dev/null +++ b/pkg/agent/spike_baseline_mode_test.go @@ -0,0 +1,170 @@ +package agent + +import ( + "math" + "strings" + "testing" + "time" + + "github.com/VersusControl/versus-incident/pkg/config" + "github.com/VersusControl/versus-incident/pkg/stats" + "github.com/VersusControl/versus-incident/pkg/storage" +) + +// twoAM is a fixed 02:00 UTC instant so the hour-of-day bucket the detector +// scores against is deterministic (bucket index 2). +var twoAM = time.Date(2026, 1, 2, 2, 0, 0, 0, time.UTC) + +// modeTestPattern builds a pattern whose three baselines each read a distinct, +// clean number so a mode selection is unambiguous: +// +// global rate = 10/s ± 2 (default mode) +// average rate = 20/s (average mode; spread reuses the global ± 2) +// 02:00 bucket = 44/s ± 2 (time_of_day mode at 02:00) +func modeTestPattern() *Pattern { + seasonal := make([]stats.EWMA, stats.HoursPerDay) + seasonal[2] = stats.EWMA{Mean: 44, Variance: 4, Count: 100} + return &Pattern{ + ID: "k", + BaselineFrequency: 10, + BaselineVariance: 4, + BaselineAvg: 20, + Seasonal: seasonal, + Count: 200, + } +} + +// TestResolveBaselineModes proves each mode selects the baseline the founder's +// delta specifies — global for default, the arithmetic average (with the global +// spread) for average, and the current hour-of-day bucket for time_of_day — and +// that the explanation label names the baseline used. +func TestResolveBaselineModes(t *testing.T) { + tests := []struct { + mode string + wantMean float64 + wantStd float64 + wantLabel string + }{ + {"default", 10, 2, ""}, + {"average", 20, 2, "the average baseline"}, + {"time_of_day", 44, 2, "the 02:00 baseline"}, + } + for _, tc := range tests { + t.Run(tc.mode, func(t *testing.T) { + b, c := newLogBrainForTest(t, config.AgentCatalogConfig{}) + setGlobalBaselineMode(t, c, tc.mode) + mean, std, label := b.resolveBaseline(modeTestPattern(), twoAM) + if mean != tc.wantMean || std != tc.wantStd || label != tc.wantLabel { + t.Fatalf("resolveBaseline(%s) = (%.1f, %.1f, %q), want (%.1f, %.1f, %q)", + tc.mode, mean, std, label, tc.wantMean, tc.wantStd, tc.wantLabel) + } + }) + } +} + +// TestResolveBaselinePatternOverride proves a per-pattern spike_baseline_mode +// wins over the stored global default, and that an unset pattern property +// inherits it. +func TestResolveBaselinePatternOverride(t *testing.T) { + b, c := newLogBrainForTest(t, config.AgentCatalogConfig{}) + setGlobalBaselineMode(t, c, "default") + + p := modeTestPattern() + p.SpikeBaselineMode = "average" + if mean, _, label := b.resolveBaseline(p, twoAM); mean != 20 || label != "the average baseline" { + t.Fatalf("pattern override average = (%.1f, %q), want (20, the average baseline)", mean, label) + } + + p.SpikeBaselineMode = "" // inherit the stored global default (global) + if mean, _, label := b.resolveBaseline(p, twoAM); mean != 10 || label != "" { + t.Fatalf("inherited default = (%.1f, %q), want (10, \"\")", mean, label) + } +} + +// TestResolveBaselineUnknownNormalizes proves an unrecognized stored global mode +// (or per-pattern value) folds to the global default rather than silently +// disabling scoring. The bogus value is written as a raw blob to bypass the +// save-side sanitize and exercise the read-side normalization. +func TestResolveBaselineUnknownNormalizes(t *testing.T) { + b, c := newLogBrainForTest(t, config.AgentCatalogConfig{}) + if err := c.store.WriteBlob(SpikeSettingsBlobName, []byte(`{"baseline_mode":"wat"}`)); err != nil { + t.Fatalf("WriteBlob: %v", err) + } + if mean, _, label := b.resolveBaseline(modeTestPattern(), twoAM); mean != 10 || label != "" { + t.Fatalf("unknown stored mode = (%.1f, %q), want (10, \"\") (default)", mean, label) + } +} + +// TestResolveBaselineTimeOfDayFallback proves a sparse hour-of-day bucket (below +// the confidence gate) falls back to the global baseline, so switching a +// still-warming pattern to time_of_day never fires a spurious cold-bucket spike. +func TestResolveBaselineTimeOfDayFallback(t *testing.T) { + b, c := newLogBrainForTest(t, config.AgentCatalogConfig{}) + setGlobalBaselineMode(t, c, "time_of_day") + p := modeTestPattern() + p.Seasonal[2] = stats.EWMA{Mean: 44, Variance: 4, Count: 2} // below MinBucketSamples (5) + if mean, _, label := b.resolveBaseline(p, twoAM); mean != 10 || label != "the baseline" { + t.Fatalf("sparse bucket fallback = (%.1f, %q), want (10, the baseline)", mean, label) + } +} + +// TestSpikeBaselineModeFireHold proves the SAME burst is judged differently per +// mode: a tick of 47/s at 02:00 fires against the global and average baselines +// but is normal-for-2am against the hour-of-day bucket (44/s ± 2), so +// time_of_day holds. The explanation names the baseline that fired. +func TestSpikeBaselineModeFireHold(t *testing.T) { + const poll = 30.0 + tickFreq := int(47 * poll) // 47/s + + tests := []struct { + mode string + wantSpike bool + wantReason string // substring the explanation must contain when it fires + }{ + {"default", true, "above 10.0/s"}, + {"average", true, "above the average baseline 20.0/s"}, + {"time_of_day", false, ""}, + } + for _, tc := range tests { + t.Run(tc.mode, func(t *testing.T) { + b, c := newLogBrainForTest(t, config.AgentCatalogConfig{}) + setGlobalBaselineMode(t, c, tc.mode) + p := modeTestPattern() + mean, std, label := b.resolveBaseline(p, twoAM) + res := evalSpike(mean, std, p.Count, tickFreq, b.params) + if res.Spike != tc.wantSpike { + t.Fatalf("mode %s: spike = %v (z=%.1f), want %v", tc.mode, res.Spike, res.Z, tc.wantSpike) + } + if res.Spike { + reason := spikeReason(res, mean, std, tickFreq, poll, label) + if !strings.Contains(reason, tc.wantReason) { + t.Fatalf("mode %s reason = %q, want it to contain %q", tc.mode, reason, tc.wantReason) + } + } + }) + } +} + +// TestFoldBaselineAvg proves the cumulative arithmetic mean is folded correctly +// and incrementally: three ticks at 1/s, 2/s, 3/s leave baseline_avg at their +// arithmetic mean (2.0), and its fold count equals the total seasonal +// observation count. +func TestFoldBaselineAvg(t *testing.T) { + c, err := LoadCatalog(storage.NewMemory()) + if err != nil { + t.Fatalf("LoadCatalog: %v", err) + } + c.SetBaselineFold(resolveSpikeParams(config.AgentCatalogConfig{}, 30).fold()) + + for _, tickCount := range []int{30, 60, 90} { // rates 1/s, 2/s, 3/s at a 30s poll + c.Upsert("k", "tmpl", "src", tickCount, 0.2, "default", "") + } + + p := c.Get("k") + if got := p.BaselineAvg; math.Abs(got-2.0) > 1e-9 { + t.Fatalf("BaselineAvg = %v, want 2.0 (mean of 1,2,3)", got) + } + if n := seasonalCount(p.Seasonal); n != 3 { + t.Fatalf("seasonal fold count = %d, want 3 (one per accepted tick)", n) + } +} diff --git a/pkg/agent/spikesettings.go b/pkg/agent/spikesettings.go new file mode 100644 index 0000000..1f87dc1 --- /dev/null +++ b/pkg/agent/spikesettings.go @@ -0,0 +1,102 @@ +package agent + +import ( + "encoding/json" + "errors" + + "github.com/VersusControl/versus-incident/pkg/storage" +) + +// spikesettings.go — the runtime settings store for the log volume-spike +// detector's GLOBAL default baseline mode. It replaces the removed +// `spike_baseline_mode` YAML key: the baseline mode is a NON-SECRET operational +// choice, so it is set at runtime in the admin UI and persisted via the storage +// provider blob seam rather than baked into config at boot. +// +// The store is read-through: callers fetch a fresh value with LoadSpikeSettings +// each time, so there is no process-wide mutable settings global to guard. The +// precedence for a given pattern stays "per-pattern column → this stored global +// → built-in default"; a fresh install (no blob) uses the built-in default +// baseline. + +// SpikeSettingsBlobName is the single blob the spike baseline-mode setting +// lives under, in the models/settings/ namespace (a sibling of the report +// settings blob and the model-state artifacts). +const SpikeSettingsBlobName = "models/settings/spike-baseline" + +// ErrSpikeNoStorage is returned by SaveSpikeSettings when no backend is +// configured, so the API can map it to a 503. +var ErrSpikeNoStorage = errors.New("spike settings: storage not configured") + +// SpikeSettings is the non-secret runtime configuration for the spike +// detector's global default baseline mode. It is the JSON shape persisted in +// the settings blob and the shape the admin GET/PUT endpoints exchange. +type SpikeSettings struct { + // BaselineMode is the GLOBAL default baseline the spike z-score is measured + // against for any pattern that does not carry its own per-pattern override: + // "default" (global rate baseline), "average" (cumulative mean of the rate, + // never decays), or "time_of_day" (hour-of-day baseline). + BaselineMode string `json:"baseline_mode"` +} + +// DefaultSpikeSettings is the built-in floor applied when the store holds no +// value: the global rate baseline. A fresh install therefore scores spikes +// against the global baseline until an operator picks another mode in the UI. +func DefaultSpikeSettings() SpikeSettings { + return SpikeSettings{BaselineMode: baselineModeDefault} +} + +// KnownBaselineMode reports whether mode is exactly one of the three recognized +// baseline modes. The API uses it to reject an unknown mode with a 400 rather +// than silently folding it to the default, which is the store's job for a +// hand-edited or legacy blob. +func KnownBaselineMode(mode string) bool { + switch mode { + case baselineModeDefault, baselineModeAverage, baselineModeTimeOfDay: + return true + default: + return false + } +} + +// sanitize folds an unrecognized or absent mode to the built-in default. It is +// applied on both read and write so a hand-edited or legacy blob can never +// yield an out-of-range setting the detector would have to interpret. +func (s SpikeSettings) sanitize() SpikeSettings { + s.BaselineMode = normalizeBaselineMode(s.BaselineMode) + return s +} + +// LoadSpikeSettings returns the effective spike settings: the stored blob +// merged over the built-in default, sanitized. A nil store or an +// absent/empty/corrupt blob yields the built-in default — never an error, +// mirroring the ReadBlob "fresh start" contract. Callers get a fresh value each +// time, so there is no shared mutable state to guard. +func LoadSpikeSettings(st storage.Provider) SpikeSettings { + def := DefaultSpikeSettings() + if st == nil { + return def + } + data, err := st.ReadBlob(SpikeSettingsBlobName) + if err != nil || len(data) == 0 { + return def + } + got := def + if err := json.Unmarshal(data, &got); err != nil { + return def + } + return got.sanitize() +} + +// SaveSpikeSettings persists the settings blob after sanitizing it. It returns +// ErrSpikeNoStorage when no backend is configured so the API can map it to 503. +func SaveSpikeSettings(st storage.Provider, s SpikeSettings) error { + if st == nil { + return ErrSpikeNoStorage + } + data, err := json.Marshal(s.sanitize()) + if err != nil { + return err + } + return st.WriteBlob(SpikeSettingsBlobName, data) +} diff --git a/pkg/agent/spikesettings_test.go b/pkg/agent/spikesettings_test.go new file mode 100644 index 0000000..a33b815 --- /dev/null +++ b/pkg/agent/spikesettings_test.go @@ -0,0 +1,68 @@ +package agent + +import ( + "testing" + + "github.com/VersusControl/versus-incident/pkg/storage" +) + +func TestSpikeSettings_DefaultsWhenAbsent(t *testing.T) { + // nil store → built-in default (global rate baseline). + if got := LoadSpikeSettings(nil); got.BaselineMode != baselineModeDefault { + t.Fatalf("nil-store default = %+v, want baseline_mode=default", got) + } + // fresh store with no blob → same default. + if got := LoadSpikeSettings(storage.NewMemory()); got.BaselineMode != baselineModeDefault { + t.Fatalf("empty-store default = %+v, want baseline_mode=default", got) + } +} + +func TestSpikeSettings_SaveLoadRoundTrip(t *testing.T) { + st := storage.NewMemory() + for _, mode := range []string{baselineModeDefault, baselineModeAverage, baselineModeTimeOfDay} { + if err := SaveSpikeSettings(st, SpikeSettings{BaselineMode: mode}); err != nil { + t.Fatalf("SaveSpikeSettings(%q): %v", mode, err) + } + if got := LoadSpikeSettings(st); got.BaselineMode != mode { + t.Fatalf("roundtrip %q = %+v", mode, got) + } + } +} + +func TestSpikeSettings_UnknownNormalizesToDefault(t *testing.T) { + st := storage.NewMemory() + // Save-side sanitize folds an unknown mode to the default. + if err := SaveSpikeSettings(st, SpikeSettings{BaselineMode: "wat"}); err != nil { + t.Fatalf("SaveSpikeSettings: %v", err) + } + if got := LoadSpikeSettings(st); got.BaselineMode != baselineModeDefault { + t.Fatalf("save-side normalize = %+v, want default", got) + } + // Read-side normalize: a hand-edited/legacy blob with a bogus mode loads as + // the default rather than the raw value. + if err := st.WriteBlob(SpikeSettingsBlobName, []byte(`{"baseline_mode":"nonsense"}`)); err != nil { + t.Fatalf("WriteBlob: %v", err) + } + if got := LoadSpikeSettings(st); got.BaselineMode != baselineModeDefault { + t.Fatalf("read-side normalize = %+v, want default", got) + } +} + +func TestSaveSpikeSettings_NoStorage(t *testing.T) { + if err := SaveSpikeSettings(nil, SpikeSettings{BaselineMode: baselineModeAverage}); err != ErrSpikeNoStorage { + t.Fatalf("err = %v, want ErrSpikeNoStorage", err) + } +} + +func TestKnownBaselineMode(t *testing.T) { + for _, ok := range []string{baselineModeDefault, baselineModeAverage, baselineModeTimeOfDay} { + if !KnownBaselineMode(ok) { + t.Fatalf("KnownBaselineMode(%q) = false, want true", ok) + } + } + for _, bad := range []string{"", "wat", "Default", " average", "hour_of_week"} { + if KnownBaselineMode(bad) { + t.Fatalf("KnownBaselineMode(%q) = true, want false", bad) + } + } +} diff --git a/pkg/agent/worker.go b/pkg/agent/worker.go index ecd6df1..f6ad1b9 100644 --- a/pkg/agent/worker.go +++ b/pkg/agent/worker.go @@ -153,6 +153,19 @@ func NewWorker(opt WorkerOptions) (*Worker, error) { w.ewmaAlpha = 0.2 // configurable once spike detection lands w.newServiceGrace = parseDurationOr(opt.Cfg.NewServiceGrace, 0) + // Wire the baseline fold from the poll interval + spike settings so + // Catalog.Upsert folds a poll-independent per-second rate through the + // outlier-resistant EWMA (and seasonal buckets when enabled), sharing the + // detector's reject cutoff / seasonal period / confidence gate. + w.catalog.SetBaselineFold(resolveSpikeParams(opt.Cfg.Catalog, w.pollInterval.Seconds()).fold()) + + // One-time deprecation notice: spike_multiplier no longer drives detection + // (the threshold moved from a volume-blind ratio to the z-score). It is + // accepted-and-ignored so an existing config still loads. + if opt.Cfg.Catalog.SpikeMultiplier != 0 { + log.Printf("agent: config key `spike_multiplier` is DEPRECATED and ignored — spike detection now uses `spike_z` (a z-score). Remove spike_multiplier from your config; see docs/agent/spike.") + } + // Per-source-KIND regex default. The logs matcher is the global default // (w.matcher); metrics/traces get a build-once match-all matcher so their // non-"error" messages flow with zero operator config. The OPTIONAL @@ -575,7 +588,7 @@ func (w *Worker) handleObservation( log.Printf("%sagent[shadow]: would alert pattern=%s service=%s tag=%s verdict=%s freq=%d%s", colorGreen, o.Key, o.Service, w.shadowTag(o), v.Class, o.Frequency, colorReset) } else { - outcome := w.emitDetect(ctx, src.Name(), o.Key, o.Signal, o.Service, o.Samples, v.Class, v.Baseline) + outcome := w.emitDetect(ctx, src.Name(), o.Key, o.Signal, o.Service, o.Samples, v.Class, v.Baseline, v.Score, std, v.Reason) verdicts["emit_"+outcome]++ } @@ -624,6 +637,8 @@ func (w *Worker) emitDetect( signals []core.Signal, verdict core.AgentVerdict, prevBaseline float64, + score, baselineStd float64, + explanation string, ) string { result := core.AgentResult{ Verdict: verdict, @@ -645,6 +660,9 @@ func (w *Worker) emitDetect( Verdict: verdict.String(), Frequency: len(signals), Baseline: prevBaseline, + BaselineStd: baselineStd, + Score: score, + Explanation: explanation, Samples: sampleMessages(signals, 3), RuleSeverity: result.RuleSeverity, } @@ -797,39 +815,6 @@ func (w *Worker) preBrainMatcher(name string) *RegexMatcher { } } -// isSpike returns true when the current tick frequency exceeds the -// pattern's prior EWMA baseline by `multiplier`, subject to two safety -// floors: -// -// - tickFreq must be at least minFreq (don't trigger on absolute counts -// so small the ratio is meaningless). -// - prevCount must be at least minBaselineCount (don't treat a -// barely-seen pattern's first big tick as a spike; let it accumulate -// a real baseline first). -// -// multiplier <= 0 disables spike detection entirely. -func isSpike(prevBaseline float64, prevCount, tickFreq int, multiplier float64, minFreq, minBaselineCount int) bool { - if multiplier <= 0 { - return false - } - if minFreq <= 0 { - minFreq = 5 - } - if minBaselineCount <= 0 { - minBaselineCount = 20 - } - if tickFreq < minFreq { - return false - } - if prevCount < minBaselineCount { - return false - } - if prevBaseline <= 0 { - return false - } - return float64(tickFreq) > multiplier*prevBaseline -} - // ----------------------------------------------------------------------------- // cursor helpers // ----------------------------------------------------------------------------- diff --git a/pkg/agent/worker_aisettings_test.go b/pkg/agent/worker_aisettings_test.go index a1f3a78..5c09c4b 100644 --- a/pkg/agent/worker_aisettings_test.go +++ b/pkg/agent/worker_aisettings_test.go @@ -47,7 +47,7 @@ func TestEmitDetect_ResolverDisabled_RunsDry(t *testing.T) { context.Background(), "test", "pid-off", "boom", "svc-x", []core.Signal{{Message: "boom"}}, - core.VerdictUnknown, 0, + core.VerdictUnknown, 0, 0, 0, "", ) if outcome != "dry" { @@ -86,7 +86,7 @@ func TestEmitDetect_ResolverEnabled_RunsAI(t *testing.T) { context.Background(), "test", "pid-on", "boom", "svc-x", []core.Signal{{Message: "boom"}}, - core.VerdictUnknown, 0, + core.VerdictUnknown, 0, 0, 0, "", ) if outcome != "emitted" { diff --git a/pkg/agent/worker_emit_test.go b/pkg/agent/worker_emit_test.go index eaecb0b..d03c3d2 100644 --- a/pkg/agent/worker_emit_test.go +++ b/pkg/agent/worker_emit_test.go @@ -107,7 +107,7 @@ func TestWorker_EmitDetect_HappyPath(t *testing.T) { "test", "pid-1", "boom", "svc-x", signals, core.VerdictUnknown, - 0, + 0, 0, 0, "", ) if outcome != "emitted" { @@ -145,7 +145,7 @@ func TestWorker_EmitDetect_DryWhenNoAgent(t *testing.T) { "test", "pid-2", "boom", "svc-x", []core.Signal{{Message: "boom"}}, core.VerdictUnknown, - 0, + 0, 0, 0, "", ) if outcome != "dry" { @@ -201,7 +201,7 @@ func TestWorker_EmitDetect_HonorsDeclaredSeverityFloor(t *testing.T) { "demo-prom", "pid-sev", "metric <*> = <*>", "svc", signals, core.VerdictSpike, - 0, + 0, 0, 0, "", ) if outcome != "emitted" { diff --git a/pkg/agent/worker_seam_test.go b/pkg/agent/worker_seam_test.go index d8a690b..85c53e7 100644 --- a/pkg/agent/worker_seam_test.go +++ b/pkg/agent/worker_seam_test.go @@ -52,7 +52,7 @@ func newSeamWorker(t *testing.T, mode string, src core.SignalSource, bundle AIBu Mode: mode, Catalog: config.AgentCatalogConfig{ AutoPromoteAfter: 100, - SpikeMultiplier: 5, + SpikeZ: 3.0, SpikeMinFrequency: 5, SpikeMinBaselineCount: 20, }, diff --git a/pkg/agent/worker_test.go b/pkg/agent/worker_test.go index acf917d..6baf25a 100644 --- a/pkg/agent/worker_test.go +++ b/pkg/agent/worker_test.go @@ -1,129 +1,114 @@ package agent -import "testing" +import ( + "testing" + + "github.com/VersusControl/versus-incident/pkg/config" +) + +// TestEvalSpike covers the z-score volume-spike decision: the founder's +// high-baseline burst FIRES, low-count noise stays floored, and the absolute +// ceiling is a warmup-independent safety net. +func TestEvalSpike(t *testing.T) { + // 30s tick, defaults (z=3, min_freq=5, min_baseline=20, ceiling off). + p := resolveSpikeParams(config.AgentCatalogConfig{}, 30) -func TestIsSpike(t *testing.T) { tests := []struct { - name string - prevBaseline float64 - prevCount int - tickFreq int - multiplier float64 - minFreq int - minBaselineCount int - want bool + name string + mean, std float64 // pre-fold baseline (per-second rate) + prevCount int + tickFreq int + params spikeParams + want bool }{ { - name: "clear spike: 6x baseline well above floors", - prevBaseline: 2.0, - prevCount: 100, - tickFreq: 15, - multiplier: 5.0, - minFreq: 5, - minBaselineCount: 20, - want: true, - }, - { - name: "exactly at multiplier is not a spike (strict >)", - prevBaseline: 2.0, - prevCount: 100, - tickFreq: 10, - multiplier: 5.0, - minFreq: 5, - minBaselineCount: 20, - want: false, - }, - { - name: "below multiplier is not a spike", - prevBaseline: 2.0, - prevCount: 100, - tickFreq: 9, - multiplier: 5.0, - minFreq: 5, - minBaselineCount: 20, - want: false, - }, - { - name: "tick frequency below minFreq is not a spike", - prevBaseline: 0.5, - prevCount: 100, - tickFreq: 4, // 8x baseline but below minFreq=5 - multiplier: 5.0, - minFreq: 5, - minBaselineCount: 20, - want: false, - }, - { - name: "barely-seen pattern is not a spike (prevCount below floor)", - prevBaseline: 1.0, - prevCount: 10, // < minBaselineCount=20 - tickFreq: 100, - multiplier: 5.0, - minFreq: 5, - minBaselineCount: 20, - want: false, - }, - { - name: "zero baseline is not a spike", - prevBaseline: 0.0, - prevCount: 100, - tickFreq: 50, - multiplier: 5.0, - minFreq: 5, - minBaselineCount: 20, - want: false, + // The founder's case: a chatty pattern normally ~38/s (± ~1) gets a + // burst of 2000 matches in a 30s tick → ~67/s → many σ above → FIRES, + // even though the mean is large (the old mean × 5 ratio went blind + // here). + name: "founder high-baseline burst fires", mean: 38.4, std: 1.0, + prevCount: 10000, tickFreq: 2000, params: p, want: true, }, { - name: "multiplier=0 disables spike detection", - prevBaseline: 1.0, - prevCount: 500, - tickFreq: 500, - multiplier: 0, - minFreq: 5, - minBaselineCount: 20, - want: false, + // Low-count noise: baseline ~0.02/s, a tick of 3 is many σ but below + // the absolute min-frequency floor → stays quiet. + name: "low-count noise stays floored", mean: 0.02, std: 0.01, + prevCount: 500, tickFreq: 3, params: p, want: false, }, { - name: "negative multiplier disables spike detection", - prevBaseline: 1.0, - prevCount: 500, - tickFreq: 500, - multiplier: -1, - minFreq: 5, - minBaselineCount: 20, - want: false, + // A steady high-volume pattern with a normal-sized tick is not a + // spike: ~38.6/s vs a 38.4/s ± 2 baseline is well within band. + name: "steady tick within band", mean: 38.4, std: 2.0, + prevCount: 10000, tickFreq: 1160, params: p, want: false, }, { - name: "zero floors fall back to defaults (5 / 20)", - prevBaseline: 1.0, - prevCount: 50, - tickFreq: 10, // 10x baseline, above default minFreq=5 - multiplier: 5.0, - minFreq: 0, - minBaselineCount: 0, - want: true, + // Warmup: not enough total sightings yet → the z-score is not + // trusted, so even a big jump is held (the ceiling is its cover). + name: "under warmup gate does not fire on z", mean: 1.0, std: 0.1, + prevCount: 10, tickFreq: 100, params: p, want: false, }, { - name: "default-floor fallback still rejects under-trained pattern", - prevBaseline: 1.0, - prevCount: 15, // below default minBaselineCount=20 - tickFreq: 100, - multiplier: 5.0, - minFreq: 0, - minBaselineCount: 0, - want: false, + // Absolute ceiling: fires regardless of warmup or z-score. + name: "absolute ceiling fires during warmup", mean: 1.0, std: 0.1, + prevCount: 2, tickFreq: 1000, + params: resolveSpikeParams(config.AgentCatalogConfig{SpikeAbsCeiling: 500}, 30), + want: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := isSpike(tc.prevBaseline, tc.prevCount, tc.tickFreq, - tc.multiplier, tc.minFreq, tc.minBaselineCount) + got := evalSpike(tc.mean, tc.std, tc.prevCount, tc.tickFreq, tc.params).Spike if got != tc.want { - t.Errorf("isSpike(prevBaseline=%v, prevCount=%d, tickFreq=%d, mult=%v, minFreq=%d, minBaseline=%d) = %v, want %v", - tc.prevBaseline, tc.prevCount, tc.tickFreq, - tc.multiplier, tc.minFreq, tc.minBaselineCount, - got, tc.want) + t.Errorf("evalSpike(mean=%v std=%v prevCount=%d tickFreq=%d) = %v, want %v", + tc.mean, tc.std, tc.prevCount, tc.tickFreq, got, tc.want) } }) } } + +// TestEvalSpikePollIndependence proves the same log stream trips the same way +// regardless of poll interval, because the score is a per-second rate. A +// pattern normally ~10/s that jumps to ~40/s is a spike whether polled at 10s, +// 30s, or 60s, and the scored rate is the same. +func TestEvalSpikePollIndependence(t *testing.T) { + mean, std := 10.0, 1.0 // 10/s baseline + for _, poll := range []float64{10, 30, 60} { + p := resolveSpikeParams(config.AgentCatalogConfig{}, poll) + tickFreq := int(40 * poll) // 40/s for this tick = 40 * poll matches + res := evalSpike(mean, std, 10000, tickFreq, p) + if !res.Spike { + t.Errorf("poll=%vs: 40/s burst should fire (z=%.1f)", poll, res.Z) + } + if res.Rate < 39 || res.Rate > 41 { + t.Errorf("poll=%vs: rate = %.2f/s, want ~40 (poll-independent)", poll, res.Rate) + } + } +} + +// TestConfirmSpikeSustain proves the sustained-tick debounce holds a single +// noisy tick and fires only once the streak reaches spike_sustain_ticks, and +// that an absolute-ceiling hit bypasses the debounce entirely. +func TestConfirmSpikeSustain(t *testing.T) { + b, _ := newLogBrainForTest(t, config.AgentCatalogConfig{SpikeSustainTicks: 3}) + hit := spikeResult{Spike: true} + if b.confirmSpike("k", hit) { + t.Fatal("tick 1 of 3: should not fire yet") + } + if b.confirmSpike("k", hit) { + t.Fatal("tick 2 of 3: should not fire yet") + } + if !b.confirmSpike("k", hit) { + t.Fatal("tick 3 of 3: should fire (streak reached sustain)") + } + if b.confirmSpike("k", spikeResult{Spike: false}) { + t.Fatal("non-spike should not fire and should reset the streak") + } + if b.confirmSpike("k", hit) { + t.Fatal("after reset, tick 1 of 3 again: should not fire") + } + + ceil, _ := newLogBrainForTest(t, config.AgentCatalogConfig{SpikeSustainTicks: 5}) + if !ceil.confirmSpike("k", spikeResult{Spike: true, Ceiling: true}) { + t.Fatal("ceiling hit should fire on the first tick despite sustain=5") + } +} diff --git a/pkg/config/agent.go b/pkg/config/agent.go index 3e85476..809a739 100644 --- a/pkg/config/agent.go +++ b/pkg/config/agent.go @@ -56,20 +56,43 @@ type AgentRedactionConfig struct { type AgentCatalogConfig struct { PersistInterval string `mapstructure:"persist_interval"` // e.g. "30s" AutoPromoteAfter int `mapstructure:"auto_promote_after"` // 0 = never - // SpikeMultiplier flags a tick as a frequency spike when the tick - // count exceeds the pattern's prior EWMA baseline by this factor. - // 0 disables spike detection. Default 5.0. - SpikeMultiplier float64 `mapstructure:"spike_multiplier"` - // SpikeMinFrequency is the minimum tick count required before a spike - // can fire. Avoids triggering on tiny absolute counts (e.g. baseline - // 0.5 → tickFreq 3 is technically 6× but not interesting). Default 5. + // SpikeZ is the z-score threshold: a known pattern is re-flagged as a spike + // when its per-second rate sits this many standard deviations above the + // learned baseline (self-scaling, so a burst on a high-volume pattern trips + // even though its mean is large). Default 3.0. + SpikeZ float64 `mapstructure:"spike_z"` + // SpikeAbsCeiling is a deterministic safety net: a tick with at least this + // many matches always surfaces, regardless of the z-score or warmup. 0 + // disables it (opt-in). Default 0. + SpikeAbsCeiling int `mapstructure:"spike_abs_ceiling"` + // SpikeSustainTicks is how many CONSECUTIVE spiking ticks are required + // before firing — a debounce against single noisy ticks. 1 (the default) + // fires on the first tick (no debounce). + SpikeSustainTicks int `mapstructure:"spike_sustain_ticks"` + // SpikeMinFrequency is the minimum tick count required before a spike can + // fire — an absolute noise floor so a low-count pattern can't page on a + // couple of matches. Default 5. SpikeMinFrequency int `mapstructure:"spike_min_frequency"` - // SpikeMinBaselineCount is the minimum total observations required on - // a pattern before spike detection considers it. Avoids treating a - // barely-seen pattern's first big tick as a spike. Default 20. + // SpikeMinBaselineCount is the warmup/confidence gate: the pattern must + // have been seen this many times overall before the z-score is trusted + // (until then only the absolute ceiling can fire). Default 20. SpikeMinBaselineCount int `mapstructure:"spike_min_baseline_count"` + // SpikeMultiplier is DEPRECATED and no longer drives detection (the + // threshold moved from a volume-blind ratio to the z-score above). It is + // still parsed so an existing config loads without error; a one-time + // deprecation notice is logged when it is set. Remove it from your config. + SpikeMultiplier float64 `mapstructure:"spike_multiplier"` } +// Spike-detection defaults, applied when the corresponding config key is unset +// (zero). They mirror the founder-approved locked defaults. +const ( + DefaultSpikeZ = 3.0 + DefaultSpikeMinFrequency = 5 + DefaultSpikeMinBaselineCount = 20 + DefaultSpikeSustainTicks = 1 +) + // CatalogBlobName is the storage blob key used by the agent catalog. // Backends translate this into a path / redis key / row. const CatalogBlobName = "patterns" diff --git a/pkg/config/clone_config.go b/pkg/config/clone_config.go index 5a52d08..de8128b 100644 --- a/pkg/config/clone_config.go +++ b/pkg/config/clone_config.go @@ -317,9 +317,12 @@ func cloneAgentConfig(src AgentConfig) AgentConfig { Catalog: AgentCatalogConfig{ PersistInterval: src.Catalog.PersistInterval, AutoPromoteAfter: src.Catalog.AutoPromoteAfter, - SpikeMultiplier: src.Catalog.SpikeMultiplier, + SpikeZ: src.Catalog.SpikeZ, + SpikeAbsCeiling: src.Catalog.SpikeAbsCeiling, + SpikeSustainTicks: src.Catalog.SpikeSustainTicks, SpikeMinFrequency: src.Catalog.SpikeMinFrequency, SpikeMinBaselineCount: src.Catalog.SpikeMinBaselineCount, + SpikeMultiplier: src.Catalog.SpikeMultiplier, }, Miner: AgentMinerConfig{ SimilarityThreshold: src.Miner.SimilarityThreshold, diff --git a/pkg/config/default_config.yaml b/pkg/config/default_config.yaml index 5e5c8d3..563894a 100644 --- a/pkg/config/default_config.yaml +++ b/pkg/config/default_config.yaml @@ -169,7 +169,9 @@ agent: catalog: persist_interval: 30s auto_promote_after: 100 - spike_multiplier: 5.0 + spike_z: 3.0 + spike_abs_ceiling: 0 + spike_sustain_ticks: 1 spike_min_frequency: 5 spike_min_baseline_count: 20 diff --git a/pkg/config/spike_baseline_mode_test.go b/pkg/config/spike_baseline_mode_test.go new file mode 100644 index 0000000..7b37fa9 --- /dev/null +++ b/pkg/config/spike_baseline_mode_test.go @@ -0,0 +1,65 @@ +package config + +import ( + "reflect" + "strings" + "testing" + + "github.com/spf13/viper" +) + +// catalogMapstructureTags returns the set of mapstructure tags declared on +// AgentCatalogConfig, so a test can assert on the exact config surface without +// depending on a live YAML load. +func catalogMapstructureTags(t *testing.T) map[string]struct{} { + t.Helper() + tags := map[string]struct{}{} + rt := reflect.TypeOf(AgentCatalogConfig{}) + for i := 0; i < rt.NumField(); i++ { + if tag := rt.Field(i).Tag.Get("mapstructure"); tag != "" { + tags[tag] = struct{}{} + } + } + return tags +} + +// TestSpikeConfigKeysRemoved proves the retired spike_seasonal knob and the +// moved-to-storage spike_baseline_mode key are both gone from the catalog +// config surface, so no config can revive the old off/hour_of_day/hour_of_week +// granularity switch or set the baseline mode from YAML (it is now a +// storage-backed runtime setting). +func TestSpikeConfigKeysRemoved(t *testing.T) { + tags := catalogMapstructureTags(t) + if _, ok := tags["spike_seasonal"]; ok { + t.Fatal("AgentCatalogConfig still declares the removed spike_seasonal key") + } + if _, ok := tags["spike_baseline_mode"]; ok { + t.Fatal("AgentCatalogConfig still declares spike_baseline_mode — the baseline mode is now a storage-backed runtime setting, not a YAML key") + } +} + +// TestSpikeBaselineModeNotParsed proves a spike_baseline_mode key in a config +// no longer maps to any struct field (it is silently ignored, so an old config +// still loads without error) — the YAML surface no longer exposes it. +func TestSpikeBaselineModeNotParsed(t *testing.T) { + v := viper.New() + v.SetConfigType("yaml") + if err := v.ReadConfig(strings.NewReader(` +catalog: + spike_baseline_mode: time_of_day + spike_seasonal: hour_of_week +`)); err != nil { + t.Fatalf("read yaml: %v", err) + } + var cat AgentCatalogConfig + if err := v.UnmarshalKey("catalog", &cat); err != nil { + t.Fatalf("unmarshal catalog: %v", err) + } + // The struct has no field for either legacy key, so the config still loads + // and nothing is populated from them. + if got := reflect.ValueOf(cat); got.IsZero() != true { + // spike_z etc. are absent from this snippet, so the whole struct is the + // zero value — proving neither removed key wrote a field. + t.Fatalf("AgentCatalogConfig populated a field from a removed key: %+v", cat) + } +} diff --git a/pkg/controllers/agent.go b/pkg/controllers/agent.go index 27e6657..6ee04c6 100644 --- a/pkg/controllers/agent.go +++ b/pkg/controllers/agent.go @@ -493,23 +493,28 @@ const serviceRecentIncidentMax = 10 // the OSS shape carries no metric/trace fields. An unknown service (not in the // catalog) returns 404. // -// The pattern catalog, service registry, and incident store are single-tenant -// OSS state: every entry is keyed under storage.DefaultOrgID. We resolve org the -// same way the data-plane reads of that OSS-owned state do — to the catalog's -// single-tenant org — so a single-org licensed deployment serves this endpoint -// AND the enterprise /intel endpoint under the same deployment. (Enterprise -// learned baselines key under the deployment org separately; this read is the -// OSS catalog's, which is default.) +// Org resolution mirrors the service LIST (listServices): the service is looked +// up by name in the catalog's unified service view WITHOUT an org filter, so +// the invariant holds — any service the list surfaces resolves here too. The +// service's OWN stored org then scopes its patterns and incidents, so the read +// path's org always matches the list path's org. This serves both a +// single-tenant OSS deployment (everything keyed under the default org) and a +// deployment whose registry is keyed under a non-default org, where a hardcoded +// default filter would 404 a service the list still shows. func (a *AgentController) getServiceDetail(c *fiber.Ctx) error { name := c.Params("name") - org := storage.DefaultOrgID - // Service meta + grace. AllServices() is the trusted catalog view; the key - // is the catalog service name, never a redacted value. + // Service meta + grace. AllServices() is the same trusted catalog view + // listServices reads; the key is the catalog service name, never a redacted + // value. No org filter here — the list has none, so applying one would + // reject services the list still returns. info, ok := a.catalog.AllServices()[name] - if !ok || storage.NormalizeOrgID(info.OrgID) != org { + if !ok { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "service not found"}) } + // Scope the patterns and incidents to the service's OWN stored org so the + // read path aligns with the list path. + org := storage.NormalizeOrgID(info.OrgID) inGrace, graceRemaining := graceStatus(info.FirstSeen, serviceGraceDuration()) @@ -528,6 +533,17 @@ func (a *AgentController) getServiceDetail(c *fiber.Ctx) error { "source": p.Source, "last_seen": p.LastSeen, "tags": p.Tags, + // Redacted sample ring + learned baselines, matching the pattern-detail + // read (GET /patterns/:id) field-for-field so the UI can render the same + // detail/peek on a service-detail row. Samples ride the ring already + // scrubbed at the storage boundary (Catalog.RecordSample → PushSample); + // we surface that same redacted slice — never a raw line — bounded by the + // same SampleRingCap the pattern-detail read returns. + "samples": p.Samples, + "baseline_frequency": p.BaselineFrequency, + "baseline_avg": p.BaselineAvg, + "baseline_variance": p.BaselineVariance, + "seasonal": p.Seasonal, }) } diff --git a/pkg/controllers/agent_patterns_readiness_test.go b/pkg/controllers/agent_patterns_readiness_test.go index e41a284..46db3bb 100644 --- a/pkg/controllers/agent_patterns_readiness_test.go +++ b/pkg/controllers/agent_patterns_readiness_test.go @@ -33,6 +33,9 @@ func TestListPatterns_CarriesReadiness(t *testing.T) { if err != nil { t.Fatalf("LoadCatalog: %v", err) } + // Fold a per-second rate the way the worker does (30s tick), so + // BaselineFrequency reads as a rate and RatePerMin is poll-independent. + cat.SetBaselineFold(agent.BaselineFold{PollSeconds: 30}) // Count-promoted (150 >= 100 threshold) → Ready by the gate even though the // on-disk verdict is still "" (Classify never ran in this read-only test). cat.Upsert("p-known", "known thing <*>", "es:prod", 150, 0.2, "default", "api") @@ -103,7 +106,7 @@ func TestListPatterns_CarriesReadiness(t *testing.T) { if lr["needed"] != float64(100) { t.Errorf("p-learning readiness.needed = %v, want 100", lr["needed"]) } - // BaselineFrequency 40/tick ÷ 0.5 min/tick = 80/min. + // 40 matches in a 30s tick = 1.33/s; rendered as sightings/min = 1.33 × 60 = 80. if lr["rate_per_min"] != float64(80) { t.Errorf("p-learning readiness.rate_per_min = %v, want 80", lr["rate_per_min"]) } diff --git a/pkg/controllers/agent_test.go b/pkg/controllers/agent_test.go index 51a6fce..edc482d 100644 --- a/pkg/controllers/agent_test.go +++ b/pkg/controllers/agent_test.go @@ -268,6 +268,165 @@ func TestServiceDetail_ServesSingleTenantOrgUnderForeignDeploymentOrg(t *testing } } +// orgScopedCatalogStore is a minimal CatalogStore whose unified read view keys +// its service + patterns under a caller-chosen org, mirroring an enterprise +// partition store that keys a deployment's catalog under a non-default org. +// Load/Persist/Curate are no-ops; only Snapshot feeds the admin list + detail +// reads under test. +type orgScopedCatalogStore struct { + patterns []*agent.Pattern + services map[string]agent.ServiceInfo +} + +func (s *orgScopedCatalogStore) Load() (map[string]*agent.Pattern, map[string]*agent.ServiceInfo, error) { + return nil, nil, nil +} + +func (s *orgScopedCatalogStore) Persist(map[string]*agent.Pattern, map[string]*agent.ServiceInfo) error { + return nil +} + +func (s *orgScopedCatalogStore) Snapshot() ([]*agent.Pattern, map[string]agent.ServiceInfo, error) { + return s.patterns, s.services, nil +} + +func (s *orgScopedCatalogStore) Curate(agent.CatalogEdit) error { return nil } + +// TestServiceDetail_ResolvesServiceStoredUnderNonDefaultOrg proves the +// list/detail invariant: a service whose catalog entry is keyed under a +// NON-default org (an enterprise deployment org) both appears in listServices +// AND resolves via getServiceDetail. Before the fix the list showed it (no org +// filter) while the detail 404'd it (a hardcoded default-org filter), so a +// service in the list failed to open. +func TestServiceDetail_ResolvesServiceStoredUnderNonDefaultOrg(t *testing.T) { + loadServiceDetailConfig(t, "30m") + + const deploymentOrg = "b" + store := &orgScopedCatalogStore{ + patterns: []*agent.Pattern{ + {ID: "p-api", OrgID: deploymentOrg, Template: "api failed to <*>", Service: "api", Count: 7, Source: "es:prod"}, + }, + services: map[string]agent.ServiceInfo{ + "api": {OrgID: deploymentOrg, FirstSeen: time.Now().UTC()}, + }, + } + agent.SetCatalogStore(store) + t.Cleanup(func() { agent.SetCatalogStore(nil) }) + + cat, err := agent.LoadCatalog(storage.NewMemory()) + if err != nil { + t.Fatalf("LoadCatalog: %v", err) + } + services.SetStorage(storage.NewMemory()) + t.Cleanup(func() { services.SetStorage(nil) }) + + ctrl := NewAgentController(cat, nil, nil, nil, nil, false) + app := fiber.New() + app.Get("/api/agent/services", ctrl.listServices) + app.Get("/api/agent/services/:name", ctrl.getServiceDetail) + + // The list surfaces the non-default-org service... + code, listBody := getJSON(t, app, "/api/agent/services") + if code != fiber.StatusOK { + t.Fatalf("list status = %d, want 200; body=%v", code, listBody) + } + svcs, _ := listBody["services"].(map[string]any) + if _, ok := svcs["api"]; !ok { + t.Fatalf("listServices must surface the non-default-org service; got %v", svcs) + } + + // ...and the detail resolves it (was a 404 before the fix). + code, body := getJSON(t, app, "/api/agent/services/api") + if code != fiber.StatusOK { + t.Fatalf("detail status = %d, want 200 (a service in the list must resolve in the detail); body=%v", code, body) + } + if body["service"] != "api" { + t.Errorf("service = %v, want api", body["service"]) + } + if c, _ := body["counts"].(map[string]any); c["patterns"] != float64(1) { + t.Errorf("counts.patterns = %v, want 1 (non-default-org pattern resolved)", c["patterns"]) + } +} + +// TestServiceDetail_PatternRowsCarryRedactedSamplesAndBaselines proves the +// service-detail pattern rows now carry the same redacted sample ring + learned +// baseline numbers the pattern-detail read returns, and that redaction holds: a +// secret planted in a recorded raw line must never reach the response. +func TestServiceDetail_PatternRowsCarryRedactedSamplesAndBaselines(t *testing.T) { + loadServiceDetailConfig(t, "30m") + + cat, err := agent.LoadCatalog(storage.NewMemory()) + if err != nil { + t.Fatalf("LoadCatalog: %v", err) + } + red, errs := agent.NewRedactor(false, nil) + if len(errs) != 0 { + t.Fatalf("NewRedactor: %v", errs) + } + // Fold a per-second rate into hour-of-day buckets so the pattern carries a + // real average, variance, and seasonal baseline (not just the legacy + // frequency), matching what the log-patterns page shows. + cat.SetBaselineFold(agent.BaselineFold{PollSeconds: 1, SeasonalPeriod: 24}) + cat.RegisterService("api") + cat.Upsert("p-api", "api failed to <*>", "es:prod", 7, 0.2, "default", "api") // seed + cat.Upsert("p-api", "api failed to <*>", "es:prod", 3, 0.2, "default", "api") // fold → variance + // A secret in the recorded line must be re-scrubbed at the storage boundary + // so it never reaches the detail response. + cat.RecordSample("p-api", "api failed to auth password=hunter2 for order", red) + cat.RecordSample("p-api", "api failed to connect to db", red) + + services.SetStorage(storage.NewMemory()) + t.Cleanup(func() { services.SetStorage(nil) }) + + ctrl := NewAgentController(cat, nil, nil, nil, nil, false) + app := fiber.New() + app.Get("/api/agent/services/:name", ctrl.getServiceDetail) + + code, body := getJSON(t, app, "/api/agent/services/api") + if code != fiber.StatusOK { + t.Fatalf("status = %d, want 200; body=%v", code, body) + } + + patterns, _ := body["patterns"].([]any) + if len(patterns) != 1 { + t.Fatalf("patterns len = %d, want 1", len(patterns)) + } + row, _ := patterns[0].(map[string]any) + + // Samples: present, redacted, and the same oldest→newest ring the + // pattern-detail read returns. + samples, ok := row["samples"].([]any) + if !ok || len(samples) != 2 { + t.Fatalf("samples = %v, want a 2-entry redacted ring", row["samples"]) + } + for _, s := range samples { + if line, _ := s.(string); strings.Contains(line, "hunter2") { + t.Fatalf("secret leaked into a service-detail sample: %q", line) + } + } + if latest, _ := samples[len(samples)-1].(string); latest != "api failed to connect to db" { + t.Errorf("latest sample = %q, want the most-recently recorded line", latest) + } + + // Baseline numbers: every field the log-patterns page shows must be present + // under the same JSON names as the pattern model. + for _, k := range []string{"baseline_frequency", "baseline_avg", "baseline_variance", "seasonal"} { + if _, present := row[k]; !present { + t.Errorf("service-detail pattern row missing %q", k) + } + } + if f, _ := row["baseline_frequency"].(float64); f <= 0 { + t.Errorf("baseline_frequency = %v, want > 0 (folded during learn)", row["baseline_frequency"]) + } + if avg, _ := row["baseline_avg"].(float64); avg <= 0 { + t.Errorf("baseline_avg = %v, want > 0 (folded during learn)", row["baseline_avg"]) + } + seasonal, ok := row["seasonal"].([]any) + if !ok || len(seasonal) != 24 { + t.Fatalf("seasonal = %v, want 24 hour-of-day buckets", row["seasonal"]) + } +} + // TestClearPatterns_WipesPatternsKeepsServices proves DELETE /api/agent/patterns // empties every learned pattern, resets the shared miner, LEAVES discovered // services intact, and returns a count of the patterns cleared. diff --git a/pkg/controllers/spike_admin.go b/pkg/controllers/spike_admin.go new file mode 100644 index 0000000..5ac7067 --- /dev/null +++ b/pkg/controllers/spike_admin.go @@ -0,0 +1,82 @@ +package controllers + +import ( + "errors" + "strings" + + "github.com/VersusControl/versus-incident/pkg/agent" + "github.com/VersusControl/versus-incident/pkg/config" + "github.com/VersusControl/versus-incident/pkg/middleware" + "github.com/VersusControl/versus-incident/pkg/services" + + "github.com/gofiber/fiber/v2" +) + +// SpikeAdminController exposes the log volume-spike detector's GLOBAL default +// baseline mode as a runtime setting: read it and update it. The mode is a +// non-secret operational choice, persisted via the storage-backed spike +// settings store (not YAML). Same X-Gateway-Secret guard as the rest of the +// admin surface. +type SpikeAdminController struct{} + +// NewSpikeAdminController returns a controller. No state of its own; the +// settings store is reached via the services storage seam. +func NewSpikeAdminController() *SpikeAdminController { + return &SpikeAdminController{} +} + +// Register attaches the endpoints under /api/admin/agent. +// +// GET /api/admin/agent/spike-settings current global spike baseline mode +// PUT /api/admin/agent/spike-settings update the global spike baseline mode +func (sc *SpikeAdminController) Register(router fiber.Router) { + g := router.Group("/admin/agent", sc.authMiddleware) + g.Get("/spike-settings", sc.getSettings) + g.Put("/spike-settings", sc.putSettings) +} + +// authMiddleware reuses the agent gateway secret (constant-time compare), +// mirroring the rest of the admin surface. +func (sc *SpikeAdminController) authMiddleware(c *fiber.Ctx) error { + if middleware.RequestAuthorized(c) { + return c.Next() + } + cfg := config.GetConfig() + expected := cfg.GatewaySecret + got := c.Get("X-Gateway-Secret") + if expected == "" || !secureEqual(got, expected) { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) + } + return c.Next() +} + +// getSettings returns the current global spike baseline mode (or the built-in +// default when none is stored). +func (sc *SpikeAdminController) getSettings(c *fiber.Ctx) error { + return c.JSON(agent.LoadSpikeSettings(services.Storage())) +} + +// putSettings persists the updated global spike baseline mode. An unknown mode +// is rejected with a 400 (the store would otherwise fold it to the default); +// only the three recognized modes are accepted. +func (sc *SpikeAdminController) putSettings(c *fiber.Ctx) error { + var s agent.SpikeSettings + if err := c.BodyParser(&s); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid settings body"}) + } + // Copy the string off the pooled request buffer before it outlives the + // request. + mode := strings.Clone(strings.TrimSpace(s.BaselineMode)) + if !agent.KnownBaselineMode(mode) { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid baseline mode (want default|average|time_of_day)"}) + } + s.BaselineMode = mode + if err := agent.SaveSpikeSettings(services.Storage(), s); err != nil { + if errors.Is(err, agent.ErrSpikeNoStorage) { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "storage not configured"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + // Return the effective (sanitized) settings after the write. + return c.JSON(agent.LoadSpikeSettings(services.Storage())) +} diff --git a/pkg/controllers/spike_admin_test.go b/pkg/controllers/spike_admin_test.go new file mode 100644 index 0000000..954aa46 --- /dev/null +++ b/pkg/controllers/spike_admin_test.go @@ -0,0 +1,155 @@ +package controllers + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/VersusControl/versus-incident/pkg/agent" + "github.com/VersusControl/versus-incident/pkg/config" + "github.com/VersusControl/versus-incident/pkg/services" + "github.com/VersusControl/versus-incident/pkg/storage" + + "github.com/gofiber/fiber/v2" +) + +const spikeSecret = "test-gateway-secret" + +// TestSpikeSettingsRoutesRegistered guards the /admin/agent/spike-settings +// endpoints against silently dropping off the route table. +func TestSpikeSettingsRoutesRegistered(t *testing.T) { + app := fiber.New() + api := app.Group("/api") + NewSpikeAdminController().Register(api) + + have := map[string]bool{} + for _, r := range app.GetRoutes(true) { + have[r.Method+" "+r.Path] = true + } + for _, key := range []string{ + "GET /api/admin/agent/spike-settings", + "PUT /api/admin/agent/spike-settings", + } { + if !have[key] { + t.Errorf("route %q not registered; have:\n%v", key, have) + } + } +} + +// spikeApp mounts the controller over a memory store with the gateway secret +// configured, returning the app + store. +func spikeApp(t *testing.T) *fiber.App { + t.Helper() + loadGatewayConfig(t, spikeSecret) + config.GetConfig().GatewaySecret = spikeSecret + st := storage.NewMemory() + services.SetStorage(st) + t.Cleanup(func() { services.SetStorage(nil) }) + + app := fiber.New() + api := app.Group("/api") + NewSpikeAdminController().Register(api) + return app +} + +// TestSpikeSettings_GetPutRoundTrip drives the settings endpoints: GET returns +// the built-in default on a fresh store, PUT persists a valid mode, and a +// subsequent GET reflects it. +func TestSpikeSettings_GetPutRoundTrip(t *testing.T) { + app := spikeApp(t) + + // GET → default. + getReq := httptest.NewRequest("GET", "/api/admin/agent/spike-settings", nil) + getReq.Header.Set("X-Gateway-Secret", spikeSecret) + getResp, err := app.Test(getReq, -1) + if err != nil { + t.Fatalf("GET: %v", err) + } + var defaults agent.SpikeSettings + _ = json.NewDecoder(getResp.Body).Decode(&defaults) + getResp.Body.Close() + if defaults.BaselineMode != "default" { + t.Fatalf("defaults = %+v, want baseline_mode=default", defaults) + } + + // PUT → time_of_day. + putReq := httptest.NewRequest("PUT", "/api/admin/agent/spike-settings", strings.NewReader(`{"baseline_mode":"time_of_day"}`)) + putReq.Header.Set("X-Gateway-Secret", spikeSecret) + putReq.Header.Set("Content-Type", "application/json") + putResp, err := app.Test(putReq, -1) + if err != nil { + t.Fatalf("PUT: %v", err) + } + if putResp.StatusCode != fiber.StatusOK { + t.Fatalf("PUT status = %d, want 200", putResp.StatusCode) + } + putResp.Body.Close() + + // GET again → reflects the update. + getReq2 := httptest.NewRequest("GET", "/api/admin/agent/spike-settings", nil) + getReq2.Header.Set("X-Gateway-Secret", spikeSecret) + getResp2, err := app.Test(getReq2, -1) + if err != nil { + t.Fatalf("GET2: %v", err) + } + var updated agent.SpikeSettings + _ = json.NewDecoder(getResp2.Body).Decode(&updated) + getResp2.Body.Close() + if updated.BaselineMode != "time_of_day" { + t.Fatalf("updated = %+v, want baseline_mode=time_of_day", updated) + } +} + +// TestSpikeSettings_PutRejectsUnknownMode proves an unrecognized mode is +// rejected with a 400 and never persisted (the stored value stays the default). +func TestSpikeSettings_PutRejectsUnknownMode(t *testing.T) { + app := spikeApp(t) + + putReq := httptest.NewRequest("PUT", "/api/admin/agent/spike-settings", strings.NewReader(`{"baseline_mode":"wat"}`)) + putReq.Header.Set("X-Gateway-Secret", spikeSecret) + putReq.Header.Set("Content-Type", "application/json") + putResp, err := app.Test(putReq, -1) + if err != nil { + t.Fatalf("PUT: %v", err) + } + if putResp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("PUT status = %d, want 400", putResp.StatusCode) + } + putResp.Body.Close() + + // The bogus mode must not have been written — GET still returns the default. + if got := agent.LoadSpikeSettings(services.Storage()); got.BaselineMode != "default" { + t.Fatalf("stored after rejected PUT = %+v, want default", got) + } +} + +// TestSpikeSettings_GuardRejectsMissingSecret proves the endpoints share the +// same gateway-secret guard as the rest of the admin surface. +func TestSpikeSettings_GuardRejectsMissingSecret(t *testing.T) { + app := spikeApp(t) + + for _, tc := range []struct { + method, body string + }{ + {"GET", ""}, + {"PUT", `{"baseline_mode":"average"}`}, + } { + var reader *strings.Reader + if tc.body != "" { + reader = strings.NewReader(tc.body) + } else { + reader = strings.NewReader("") + } + req := httptest.NewRequest(tc.method, "/api/admin/agent/spike-settings", reader) + // No X-Gateway-Secret header. + resp, err := app.Test(req, -1) + if err != nil { + t.Fatalf("%s: %v", tc.method, err) + } + if resp.StatusCode != fiber.StatusUnauthorized { + t.Fatalf("%s unauthenticated status = %d, want 401", tc.method, resp.StatusCode) + } + resp.Body.Close() + } +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 543fcf3..57e776b 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -31,4 +31,5 @@ func SetupRoutes(app *fiber.App, teamsStore *teams.Store) { controllers.NewConfigAdminController().Register(api) controllers.NewTeamsAdminController(teamsStore).Register(api) controllers.NewReportsAdminController().Register(api) + controllers.NewSpikeAdminController().Register(api) } diff --git a/pkg/stats/ewma.go b/pkg/stats/ewma.go new file mode 100644 index 0000000..9fd47c3 --- /dev/null +++ b/pkg/stats/ewma.go @@ -0,0 +1,128 @@ +// Package stats holds the shared, dependency-free numeric primitives every +// signal brain in the suite folds its baseline through: an +// exponentially-weighted mean/variance estimator, the z-score with a spread +// floor, an outlier-reject predicate, and the hour-of-day / hour-of-week +// seasonal bucketing. It lives in the OSS tree so the OSS log brain and the +// enterprise metric/trace brains share ONE implementation of the fold — the +// enterprise module imports this package one-way; this package imports nothing +// from the suite. +// +// All math is local and deterministic (no LLM, no I/O), so a verdict computed +// from these functions is fully reconstructable from the stored numbers. +package stats + +import ( + "math" + "time" +) + +// HoursPerDay and HoursPerWeek are the two seasonal periods the suite uses: +// hour-of-day (a daily cycle — batch jobs, business hours) and hour-of-week +// (adds a weekly cycle — weekend-only jobs). +const ( + HoursPerDay = 24 + HoursPerWeek = 7 * 24 +) + +// EWMA is an exponentially-weighted running mean and variance in West's +// incremental form. It is O(1) in time and space: each fold updates the mean +// and the running variance from the new sample alone, weighting recent samples +// by alpha. Std is sqrt(Variance). +// +// The JSON tags are the persisted wire shape shared with the enterprise typed +// baseline tables (the `seasonal` JSONB array and the global stat columns), so +// they must not change without a migration. +type EWMA struct { + Mean float64 `json:"mean"` + Variance float64 `json:"variance"` + Count int `json:"count"` +} + +// Observe folds value into the running mean/variance with smoothing factor +// alpha. The first observation seeds the mean at value with zero variance; +// subsequent observations run the incremental update. +func (e *EWMA) Observe(value, alpha float64) { + if e.Count == 0 { + e.Mean = value + e.Variance = 0 + e.Count = 1 + return + } + diff := value - e.Mean + incr := alpha * diff + e.Mean += incr + // EWMA of the squared deviation — the running variance. + e.Variance = (1 - alpha) * (e.Variance + diff*incr) + e.Count++ +} + +// Std is the standard deviation: sqrt of the (non-negative) variance. +func (e EWMA) Std() float64 { return math.Sqrt(math.Max(0, e.Variance)) } + +// ZScore returns how many standard deviations value sits from mean: +// (value - mean) / max(std, floor). The floor is a 1% relative spread (at +// least 1e-9), so a near-constant series can't produce an infinite score and a +// zero-variance baseline can't divide by zero. +func ZScore(value, mean, std float64) float64 { + floor := math.Abs(mean) * 0.01 + if floor < 1e-9 { + floor = 1e-9 + } + if std < floor { + std = floor + } + return (value - mean) / std +} + +// ShouldReject reports whether value is a strong enough outlier to be held OUT +// of the fold so a single burst can't drag the baseline off the typical +// sample. It fires only once the estimator is confident (|z| >= rejectZ); +// during cold start it always returns false so the level can form. A rejectZ +// of zero or less disables rejection (every sample folds). +func ShouldReject(value, mean, std float64, confident bool, rejectZ float64) bool { + if !confident || rejectZ <= 0 { + return false + } + return math.Abs(ZScore(value, mean, std)) >= rejectZ +} + +// SeasonalIndex maps ts to its seasonal bucket in [0, period). period 24 is +// hour-of-day; period 168 is hour-of-week (weekday*24 + hour). Any other +// positive period buckets the hour-of-week index modulo period; a period of +// zero or less returns 0 (no seasonality). Bucketing is done in UTC so the +// mapping is host-timezone- and DST-stable and therefore deterministic. +func SeasonalIndex(ts time.Time, period int) int { + if period <= 0 { + return 0 + } + u := ts.UTC() + switch period { + case HoursPerDay: + return u.Hour() + case HoursPerWeek: + return int(u.Weekday())*24 + u.Hour() + default: + return (int(u.Weekday())*24 + u.Hour()) % period + } +} + +// Expected returns the mean/std to score against at ts and whether the model is +// confident enough to score at all. It uses the current seasonal bucket once +// that bucket has at least minBucketSamples observations, otherwise it falls +// back to the global level — so a sparse bucket never produces a spurious +// verdict. confident stays false until the global level has at least +// minGlobalSamples observations (the warmup gate). A period of zero (or a +// seasonal slice that is not exactly period long) skips the seasonal lookup and +// scores against the global level. +func Expected(global EWMA, seasonal []EWMA, ts time.Time, period, minBucketSamples, minGlobalSamples int) (mean, std float64, confident bool) { + if period > 0 && len(seasonal) == period { + bucket := seasonal[SeasonalIndex(ts, period)] + if minBucketSamples > 0 && bucket.Count >= minBucketSamples { + return bucket.Mean, bucket.Std(), true + } + } + if global.Count >= minGlobalSamples { + return global.Mean, global.Std(), true + } + return global.Mean, global.Std(), false +} diff --git a/pkg/stats/ewma_test.go b/pkg/stats/ewma_test.go new file mode 100644 index 0000000..5db72f7 --- /dev/null +++ b/pkg/stats/ewma_test.go @@ -0,0 +1,132 @@ +package stats + +import ( + "math" + "testing" + "time" +) + +// TestEWMAObserveSeedThenTrack proves the first observation seeds the mean at +// the value with zero variance, and later observations run the incremental +// mean/variance update (recent samples weighted by alpha). +func TestEWMAObserveSeedThenTrack(t *testing.T) { + var e EWMA + e.Observe(10, 0.2) + if e.Mean != 10 || e.Variance != 0 || e.Count != 1 { + t.Fatalf("seed: got mean=%v var=%v count=%d, want 10/0/1", e.Mean, e.Variance, e.Count) + } + // mean += alpha*(value-mean) = 10 + 0.2*(20-10) = 12. + e.Observe(20, 0.2) + if math.Abs(e.Mean-12) > 1e-9 { + t.Fatalf("mean after second fold = %v, want 12", e.Mean) + } + if e.Variance <= 0 { + t.Fatalf("variance after a jump = %v, want > 0", e.Variance) + } + if e.Count != 2 { + t.Fatalf("count = %d, want 2", e.Count) + } +} + +// TestEWMAConvergesToConstant proves a steady stream converges the mean to the +// level and the variance toward zero. +func TestEWMAConvergesToConstant(t *testing.T) { + var e EWMA + for i := 0; i < 200; i++ { + e.Observe(50, 0.3) + } + if math.Abs(e.Mean-50) > 1e-6 { + t.Fatalf("mean = %v, want ~50", e.Mean) + } + if e.Std() > 1e-3 { + t.Fatalf("std = %v, want ~0 on a constant stream", e.Std()) + } +} + +// TestZScoreSpreadFloor proves the 1% relative floor keeps a near-constant +// series from producing an infinite z-score. +func TestZScoreSpreadFloor(t *testing.T) { + // mean 100, std 0 → floor = 1% of 100 = 1 → z = (110-100)/1 = 10. + if z := ZScore(110, 100, 0); math.Abs(z-10) > 1e-9 { + t.Fatalf("z = %v, want 10 (spread floor 1%% of mean)", z) + } + // mean 0, std 0 → floor = 1e-9, finite (not NaN/Inf). + if z := ZScore(1, 0, 0); math.IsInf(z, 0) || math.IsNaN(z) { + t.Fatalf("z = %v, want a finite value even at mean=std=0", z) + } + // A real spread is used when it exceeds the floor. + if z := ZScore(120, 100, 5); math.Abs(z-4) > 1e-9 { + t.Fatalf("z = %v, want 4 (20/5)", z) + } +} + +// TestShouldReject proves the outlier hold-out fires only once confident and +// only beyond the cutoff, and is disabled by a non-positive cutoff. +func TestShouldReject(t *testing.T) { + // Not confident (cold start) → never reject. + if ShouldReject(1000, 100, 5, false, 3) { + t.Fatal("cold-start reject: want false, folds every sample while warming") + } + // Confident and 4σ out with a 3σ cutoff → reject. + if !ShouldReject(120, 100, 5, true, 3) { + t.Fatal("confident 4σ outlier: want reject") + } + // Confident but within the cutoff → keep. + if ShouldReject(110, 100, 5, true, 3) { + t.Fatal("confident 2σ sample: want keep") + } + // Cutoff disabled → never reject. + if ShouldReject(1e9, 100, 5, true, 0) { + t.Fatal("rejectZ<=0: want no rejection") + } +} + +// TestSeasonalIndex proves hour-of-day (24) and hour-of-week (168) bucketing in +// UTC, plus the disabled (period<=0) and modulo fallbacks. +func TestSeasonalIndex(t *testing.T) { + // 2026-07-06 is a Monday; 14:30 UTC. + ts := time.Date(2026, 7, 6, 14, 30, 0, 0, time.UTC) + if got := SeasonalIndex(ts, HoursPerDay); got != 14 { + t.Fatalf("hour-of-day index = %d, want 14", got) + } + // Monday = Weekday()==1 → 1*24 + 14 = 38. + if got := SeasonalIndex(ts, HoursPerWeek); got != 38 { + t.Fatalf("hour-of-week index = %d, want 38", got) + } + // A non-UTC zone with the same instant buckets identically (UTC-stable). + loc := time.FixedZone("UTC-5", -5*3600) + if got := SeasonalIndex(ts.In(loc), HoursPerDay); got != 14 { + t.Fatalf("hour-of-day index in another zone = %d, want 14 (UTC-bucketed)", got) + } + if got := SeasonalIndex(ts, 0); got != 0 { + t.Fatalf("disabled period index = %d, want 0", got) + } +} + +// TestExpectedSeasonalFallback proves Expected uses the seasonal bucket once it +// is confident and otherwise falls back to the global level, gating on the +// global warmup. +func TestExpectedSeasonalFallback(t *testing.T) { + ts := time.Date(2026, 7, 6, 2, 0, 0, 0, time.UTC) // 02:00 bucket (hour-of-day 2) + seasonal := make([]EWMA, HoursPerDay) + + // Global not yet warm (count < minGlobal) → not confident. + global := EWMA{Mean: 40, Variance: 4, Count: 10} + if _, _, confident := Expected(global, seasonal, ts, HoursPerDay, 5, 20); confident { + t.Fatal("global below warmup: want confident=false") + } + + // Global warm, bucket sparse → confident, scores against the global level. + global.Count = 30 + mean, _, confident := Expected(global, seasonal, ts, HoursPerDay, 5, 20) + if !confident || mean != 40 { + t.Fatalf("global fallback: got mean=%v confident=%v, want 40/true", mean, confident) + } + + // Bucket now confident → scores against the bucket, not the global. + seasonal[2] = EWMA{Mean: 120, Variance: 9, Count: 8} + mean, std, confident := Expected(global, seasonal, ts, HoursPerDay, 5, 20) + if !confident || mean != 120 || math.Abs(std-3) > 1e-9 { + t.Fatalf("bucket scoring: got mean=%v std=%v confident=%v, want 120/3/true", mean, std, confident) + } +} diff --git a/pkg/storage/migrations/005_log_baseline_variance.sql b/pkg/storage/migrations/005_log_baseline_variance.sql new file mode 100644 index 0000000..902ed9a --- /dev/null +++ b/pkg/storage/migrations/005_log_baseline_variance.sql @@ -0,0 +1,25 @@ +-- 005_log_baseline_variance.sql — Core, OSS. +-- Add the dispersion the spike detector needs: an EWMA variance folded +-- alongside the existing baseline_frequency mean on vs_logs. With it the +-- detector can ask "how many standard deviations above normal is this tick?" +-- (a self-scaling z-score) instead of the volume-blind mean × multiplier bar, +-- and the fold can hold out a burst tick so one spike can't drag the baseline. +-- +-- ADDITIVE, DEFAULT-SAFE: the column defaults to 0, so every existing vs_logs +-- row reads back byte-identically and simply re-learns its variance within a +-- learning window (0 variance → the z-score leans on the spread floor / the +-- absolute ceiling until it warms). There is NO backfill copy — this follows +-- the "migration = re-learn, no copy" precedent already set for +-- vs_metrics / vs_traces. +-- +-- This file is ledger-tracked by RunSQLMigrations (versus_schema_migrations) +-- and therefore runs EXACTLY ONCE. The file backend has no vs_logs table and +-- never runs this migration; it re-learns the variance in the whole-blob +-- patterns document instead. +-- +-- SQLi-safety: static DDL, no interpolated value. All runtime DML against +-- vs_logs binds every value as a $N parameter and names the table as a Go +-- constant. + +ALTER TABLE vs_logs + ADD COLUMN baseline_variance DOUBLE PRECISION NOT NULL DEFAULT 0; diff --git a/pkg/storage/migrations/006_log_seasonal.sql b/pkg/storage/migrations/006_log_seasonal.sql new file mode 100644 index 0000000..f8dd35d --- /dev/null +++ b/pkg/storage/migrations/006_log_seasonal.sql @@ -0,0 +1,29 @@ +-- 006_log_seasonal.sql — Core, OSS. +-- Add the per-time-bucket baseline the seasonal spike detector needs: an array +-- of hour-of-day (24) or hour-of-week (168) EWMA buckets on vs_logs, mirroring +-- the enterprise vs_metrics / vs_traces `seasonal` JSONB exactly. With it a +-- pattern that is busy at 02:00 (a nightly batch job) is scored against the +-- 02:00 bucket rather than a blended all-day mean, so the batch burst is +-- normal-for-2am while the same rate at 14:00 still pages. +-- +-- ADDITIVE, DEFAULT-SAFE: the column defaults to '[]' (no buckets), so every +-- existing vs_logs row reads back byte-identically and simply re-learns its +-- buckets on the next few ticks. There is NO backfill copy — "migration = +-- re-learn, no copy", the same precedent as vs_metrics / vs_traces. +-- +-- The bucket array is bounded (24 or 168) and always read whole, so JSONB is +-- the right shape (a child table was rejected — see the design doc). Each entry +-- is a {mean, variance, count} EWMA, identical to the enterprise seasonal +-- payload, decoded through the SAME pkg/stats.EWMA type. +-- +-- This file is ledger-tracked by RunSQLMigrations (versus_schema_migrations) +-- and therefore runs EXACTLY ONCE. The file backend has no vs_logs table and +-- never runs this migration; it re-learns the buckets in the whole-blob +-- patterns document instead. +-- +-- SQLi-safety: static DDL, no interpolated value. All runtime DML against +-- vs_logs binds every value as a $N parameter and names the table as a Go +-- constant. + +ALTER TABLE vs_logs + ADD COLUMN seasonal JSONB NOT NULL DEFAULT '[]'; diff --git a/pkg/storage/migrations/007_log_baseline_mode.sql b/pkg/storage/migrations/007_log_baseline_mode.sql new file mode 100644 index 0000000..22f6f1a --- /dev/null +++ b/pkg/storage/migrations/007_log_baseline_mode.sql @@ -0,0 +1,37 @@ +-- 007_log_baseline_mode.sql — Core, OSS. +-- Add the two remaining explicit spike-baseline columns to vs_logs so the +-- operator can flip WHICH learned baseline the spike z-score is scored against +-- with no re-learn: +-- - baseline_avg: the cumulative arithmetic mean of the per-second match +-- rate (total ÷ number of folded ticks). It is the CENTER the "average" +-- baseline mode scores against; that mode reuses the existing +-- baseline_variance as its spread, so there is one dispersion source. +-- - spike_baseline_mode: the per-pattern override of the baseline mode +-- ('' = inherit the agent.catalog.spike_baseline_mode config default; +-- otherwise 'default' | 'average' | 'time_of_day'). +-- +-- All three baselines (global frequency/variance, the arithmetic average, and +-- the 24 hour-of-day seasonal buckets) are now ALWAYS computed and stored, so +-- switching modes never needs a re-learn. +-- +-- ADDITIVE, DEFAULT-SAFE: baseline_avg defaults to 0 and spike_baseline_mode +-- defaults to '', so every existing vs_logs row reads back byte-identically — +-- the empty mode simply inherits the config default and the average re-learns +-- within a learning window. There is NO backfill copy: this rides the same +-- "migration = re-learn, no copy" pattern already used for the earlier +-- baseline_variance and seasonal signal-table columns. +-- +-- This file is ledger-tracked by RunSQLMigrations (versus_schema_migrations) +-- and therefore runs EXACTLY ONCE. The file backend has no vs_logs table and +-- never runs this migration; it re-learns the average in the whole-blob +-- patterns document instead. +-- +-- SQLi-safety: static DDL, no interpolated value. All runtime DML against +-- vs_logs binds every value as a $N parameter and names the table as a Go +-- constant. + +ALTER TABLE vs_logs + ADD COLUMN baseline_avg DOUBLE PRECISION NOT NULL DEFAULT 0; + +ALTER TABLE vs_logs + ADD COLUMN spike_baseline_mode TEXT NOT NULL DEFAULT ''; diff --git a/pkg/storage/signal_schema_test.go b/pkg/storage/signal_schema_test.go index 24105e2..a941ff9 100644 --- a/pkg/storage/signal_schema_test.go +++ b/pkg/storage/signal_schema_test.go @@ -125,6 +125,39 @@ func TestSignalSchema_NoSecretColumns(t *testing.T) { } } +// TestSignalSchema_BaselineModeColumns proves migration 007 landed the two +// remaining spike-baseline columns on vs_logs additively and default-safe: +// baseline_avg is a NOT NULL double defaulting to 0, and spike_baseline_mode is +// a NOT NULL text defaulting to ” — so every pre-migration row reads back +// byte-identically and simply re-learns. +func TestSignalSchema_BaselineModeColumns(t *testing.T) { + db := migratedDB(t) + for _, tc := range []struct { + column, dataType, defaultLike string + }{ + {"baseline_avg", "double precision", "0"}, + {"spike_baseline_mode", "text", "''"}, + } { + var dataType, isNullable, colDefault string + if err := db.QueryRow(` + SELECT data_type, is_nullable, COALESCE(column_default, '') + FROM information_schema.columns + WHERE table_name='vs_logs' AND column_name=$1`, tc.column, + ).Scan(&dataType, &isNullable, &colDefault); err != nil { + t.Fatalf("vs_logs.%s missing (migration 007 not applied?): %v", tc.column, err) + } + if dataType != tc.dataType { + t.Fatalf("vs_logs.%s data_type = %q, want %q", tc.column, dataType, tc.dataType) + } + if isNullable != "NO" { + t.Fatalf("vs_logs.%s is_nullable = %q, want NO", tc.column, isNullable) + } + if !strings.Contains(colDefault, tc.defaultLike) { + t.Fatalf("vs_logs.%s default = %q, want it to contain %q", tc.column, colDefault, tc.defaultLike) + } + } +} + // TestSignalSchema_KindInForeignKey proves migration 004 folded `kind` // into the child→root FK: vs_patterns carries a UNIQUE (org_id, id, kind) key, // vs_logs pins kind='log' via a CHECK, and vs_logs FK-references diff --git a/src/agent/spike.md b/src/agent/spike.md index 276d4dd..390d0da 100644 --- a/src/agent/spike.md +++ b/src/agent/spike.md @@ -13,137 +13,166 @@ suddenly jumps well above the pattern's normal rate. ## How it works -The agent keeps a running average for each pattern — specifically an -**EWMA** (Exponentially Weighted Moving Average). Each time the agent -polls for new logs, it updates the average with that tick's count, -giving more weight to recent ticks and fading out older ones. +For each pattern the agent learns two things from history, updated every +time it polls (a "tick"): + +- **A normal rate** — how many matches per second this pattern usually + produces. It is an **EWMA** (Exponentially Weighted Moving Average), so + recent ticks weigh more than old ones. Scoring a *rate* (matches ÷ poll + seconds) instead of a raw per-tick count means the baseline reads + intuitively (e.g. `~38/s`) and does not change when you change + `poll_interval`. +- **A normal spread** — how much that rate naturally wobbles (its standard + deviation), folded alongside the mean. + +Before folding a tick, the agent snapshots the current mean and spread and +asks: **how many standard deviations above normal is this tick's rate?** +That number is the **z-score**: -Before updating the average the agent takes a snapshot of the -current average and compares the incoming tick count against it. If -the tick count is far above the snapshot, the pattern is flagged as a -spike and written to the shadow log (or forwarded to the AI analyzer -in detect mode), even if it was previously `known`. +``` +z = (this tick's rate − normal rate) / normal spread +``` + +A pattern is flagged as a **spike** — even if it was previously `known` — +when either of these is true: -The comparison has three guards: +- **`z ≥ spike_z`** — the rate is far enough above its own learned normal + (the bar self-scales to each pattern's volatility, so a `+1000` burst on a + busy, high-volume pattern trips even though its average is large), **or** +- **the tick crosses `spike_abs_ceiling`** — a hard "always page above N + matches" safety net that fires regardless of the baseline or warmup. -1. **The tick must be above a raw minimum** — so a baseline of `0.5` - and a single-match tick doesn't look like a spike. -2. **The pattern must have been seen enough times overall** — so the - first big burst from a brand-new pattern doesn't get mislabeled - before any real average exists. -3. **The multiplier must be positive** — setting it to `0` turns the - whole feature off. +Two guards keep noise out: -All three must be true at the same time for a spike to be recorded. +1. **A raw minimum (`spike_min_frequency`)** — a tick must have at least this + many matches, so a near-silent pattern can't page on a couple of lines. +2. **A warmup gate (`spike_min_baseline_count`)** — the pattern must have been + seen enough times overall before the z-score is trusted. During warmup only + the absolute ceiling can fire. + +Two refinements make the baseline robust: + +- **Outlier-resistant learning.** Once the baseline is confident, a burst tick + is *held out* of the average, so one spike can't drag "normal" up and blind + the detector to the next one. +- **Time-of-day awareness (always on).** The agent always keeps a separate + normal for each hour of the day (24 UTC buckets), so a 2 a.m. batch job can be + treated as normal-for-2 a.m. while the same rate at 2 p.m. still pages. You + choose whether the spike score is measured against that hour-of-day baseline, + the global baseline, or the running average with `spike_baseline_mode` — + because all three baselines are always learned, switching between them takes + effect immediately, with no re-learn. ## Configuration -These three keys live under `agent.catalog` in `config.yaml`: +These keys live under `agent.catalog` in `config.yaml`: ```yaml agent: catalog: - spike_multiplier: 5.0 # how many times above average triggers a spike + spike_z: 3.0 # fire when the rate is this many σ above normal + spike_abs_ceiling: 0 # hard "always page above N matches" net (0 = off) + spike_sustain_ticks: 1 # consecutive spiking ticks before firing (1 = no debounce) + spike_baseline_mode: default # which baseline to score against: default | average | time_of_day spike_min_frequency: 5 # tick must have at least this many matches spike_min_baseline_count: 20 # pattern must have been seen this many times overall ``` -### Spike Multiplier - -**Default:** `5.0` +### Spike Z -Think of this as a sensitivity dial. It answers the question: -*"How much bigger than normal does a burst need to be before I -should care?"* +**Default:** `3.0` -If a certain error usually shows up about 2 times every time the -agent checks, and you set this to `5.0`, the agent will only flag -it when it suddenly sees more than **5 × 2 = 10** of them in one -check. Anything below that is treated as normal noise. +The sensitivity dial. It answers: *"How far above its own normal does a +burst have to be before I care?"* — measured in standard deviations (σ), so +it means the same thing for a quiet pattern and a chatty one. -- **Set it higher** (e.g. `8.0`) if you're getting too many false - alarms — only really big jumps will count. -- **Set it lower** (e.g. `3.0`) if you want to catch smaller - surges earlier. -- **Set it to `0`** to turn off spike detection completely. +- **Set it higher** (e.g. `4.0`) if you get too many false alarms — only + extreme jumps count. +- **Set it lower** (e.g. `2.5`) to catch smaller surges earlier. **Examples:** -| Normal rate | Multiplier | This check saw | Spike? | Why | +| Normal rate | Normal spread | This tick's rate | z | Spike at `spike_z: 3.0`? | |---|---|---|---|---| -| 2 | `5.0` | 8 | No | 8 is under 10 (5 × 2) | -| 2 | `5.0` | 11 | Yes | 11 is over 10 | -| 2 | `3.0` | 7 | Yes | 7 is over 6 (3 × 2) — lower bar catches it | -| 2 | `0` | 100 | No | Feature is off | -| 1 | `5.0` | 6 | Yes | 6 is over 5 (5 × 1) | +| 38.4/s | ± 1.0 | 47.3/s | 8.9σ | Yes | +| 38.4/s | ± 5.0 | 47.3/s | 1.8σ | No — within its normal wobble | +| 1.5/s | ± 0.3 | 6.0/s | 15σ | Yes (if past the min-frequency floor) | -### Spike Min Frequency +### Spike Absolute Ceiling -**Default:** `5` +**Default:** `0` (disabled) -This is a minimum count. Even if the multiplier math says "that's -a spike!", the agent will ignore it unless there are **at least -this many errors** in one check. +A deterministic safety net independent of the learned baseline: a tick with +at least this many matches always surfaces, even while the pattern is still +warming up. Leave it at `0` to rely purely on the z-score, or set a hard line +(e.g. `1000`) for "no matter what, page me if a single tick sees this many". -Why? Imagine an error that almost never happens — say 0.4 times -per check on average. If one check happens to have 3 of them, -the math says that's 7.5× the normal rate. But 3 errors is -probably just a coincidence, not a real problem. This setting -stops the agent from overreacting to tiny numbers. +### Spike Sustain Ticks -- **Set it higher** (e.g. `10`) if your logs are noisy and you - only want to hear about genuinely large bursts. -- **Set it to `1`** if you trust the multiplier alone to decide. +**Default:** `1` -**Examples:** +How many **consecutive** spiking ticks are required before firing — a +debounce against a single noisy tick. The default of `1` fires on the first +spiking tick (no debounce). Raise it (e.g. `3`) to page only on a sustained +surge; an absolute-ceiling hit always fires immediately, bypassing the +debounce. -| Normal rate | Multiplier | Min frequency | This check saw | Spike? | Why | -|---|---|---|---|---|---| -| 0.4 | `5.0` | `5` | 3 | No | 3 errors is under the minimum of 5 | -| 0.4 | `5.0` | `5` | 5 | Yes | 5 meets the minimum, and 5 > 2.0 | -| 1 | `5.0` | `10` | 8 | No | 8 errors is under the minimum of 10 | -| 1 | `5.0` | `10` | 12 | Yes | 12 meets the minimum, and 12 > 5 | -| 0.5 | `5.0` | `1` | 3 | Yes | Minimum is just 1, so the multiplier decides | +### Spike Baseline Mode -### Spike Min Baseline Count +**Default:** `default` -**Default:** `20` +Picks **which learned baseline** the z-score is measured against. All three +baselines are always learned, so you can switch modes at any time without a +re-learn: -The agent needs to see an error pattern enough times before it -knows what "normal" looks like. This setting says: *"Don't even -try to detect spikes until you've seen this pattern at least -N times total."* +- `"default"` — the **global** rate baseline (the pattern's overall normal rate + and spread). +- `"average"` — the **cumulative arithmetic mean** of the rate as the center, + reusing the global spread as the scale. Useful when you want the center to be + a plain running average rather than the recency-weighted EWMA. +- `"time_of_day"` — the **current hour-of-day** bucket (24 buckets, UTC), so a + nightly batch job is normal-for-that-hour. A sparse hour (not enough samples + yet) falls back to the global baseline, so turning this on never causes a + cold-bucket false-alarm flood. Bucketing is in UTC. -Think of it like a new employee. On their first week they have no -idea what a busy day looks like. After a few weeks they know the -difference between "a little more than usual" and "something is -actually wrong". This setting is that learning period. +The explanation in the audit log names the baseline that fired, e.g. +`"47.3/s = 1.5σ above the 02:00 baseline 44.0/s ± 2.0"` (time-of-day) or +`"47.3/s = 13.5σ above the average baseline 20.0/s ± 2.0"` (average). -- **Set it higher** (e.g. `50`) if you want the agent to learn - longer before it starts judging. -- **Set it lower** (e.g. `5`) if you have low-traffic services - where errors take a long time to add up. +### Spike Min Frequency -**Examples:** +**Default:** `5` -| Times seen so far | Min baseline count | This check saw | Spike? | Why | -|---|---|---|---|---| -| 3 | `20` | 30 | No | Only seen 3 times — still learning | -| 15 | `20` | 30 | No | Seen 15 times — not enough yet | -| 25 | `20` | 30 | Yes | Seen 25 times — ready to judge | -| 4 | `5` | 30 | No | Seen 4 times — almost ready but not yet | -| 100 | `20` | 30 | Yes | Well past the learning period | +A minimum count. Even if the z-score says "spike!", the agent ignores it +unless there are at least this many matches in one tick — so a near-silent +pattern (say `0.02/s`) can't page on a coincidental handful of lines. + +- **Set it higher** (e.g. `10`) if your logs are noisy and you only want + genuinely large bursts. +- **Set it to `1`** to let the z-score decide alone. + +### Spike Min Baseline Count + +**Default:** `20` + +The warmup gate: the agent won't trust the z-score until it has seen the +pattern at least this many times total. Like a new employee learning what a +busy day looks like before judging one. During warmup the absolute ceiling is +the only thing that can fire. + +- **Set it higher** (e.g. `50`) to learn longer before judging. +- **Set it lower** (e.g. `5`) for low-traffic services. ## Example -Say the error `db-conn-refused` normally shows up about 1–2 times -every check. After a few days of training, the agent's average -for this pattern settles at roughly `1.5`. +Say `db-conn-refused` normally runs about `1.5/s`, wobbling by about +`± 0.3/s`, and the agent has seen it well over 20 times. -With the default settings, a spike fires when the agent sees -**8 or more** of them in one check — because `8 > 5.0 (spike_multiplier) × 1.5 (baseline) = 7.5` -and `8 ≥ 5` (`spike_min_frequency`: the minimum count) and the pattern has been seen more -than 20 times (`spike_min_baseline_count`) overall (so the agent trusts its average). +With the defaults, a tick that jumps to `6.0/s` scores +`(6.0 − 1.5) / 0.3 = 15σ` — far past `spike_z: 3.0` — and (assuming the tick +has at least `spike_min_frequency` matches) is flagged as a spike, even though +the pattern is `known`. The audit log records the exact math: +`"6.0/s = 15.0σ above 1.5/s ± 0.3"`. ## What the shadow log shows diff --git a/ui/src/components/AssignDialog.tsx b/ui/src/components/AssignDialog.tsx index 1912bf5..74278ac 100644 --- a/ui/src/components/AssignDialog.tsx +++ b/ui/src/components/AssignDialog.tsx @@ -7,18 +7,24 @@ import { useToast } from "./toastContext"; // AssignDialog — set/clear the team and members on an incident. Rebased on // the accessible Modal; success now confirms via toast (previously a -// silent outcome). +// silent outcome). `incidentID` accepts a single id or a list: passing a list +// assigns the SAME team/members to every selected incident in one save, which +// is how the Incidents-page bulk "Assign" action reuses this dialog. export function AssignDialog({ incidentID, initialTeamID, initialMemberIDs, onClose, + onDone, }: { - incidentID: string; + incidentID: string | string[]; initialTeamID?: string; initialMemberIDs?: string[]; onClose: () => void; + onDone?: () => void; }) { + const ids = Array.isArray(incidentID) ? incidentID : [incidentID]; + const bulk = ids.length > 1; const qc = useQueryClient(); const toast = useToast(); const teamsQ = useQuery({ queryKey: ["teams"], queryFn: api.listTeams }); @@ -38,14 +44,27 @@ export function AssignDialog({ const save = useMutation({ mutationFn: () => - api.assignIncident(incidentID, { - team_id: team || null, - member_ids: memberIDs, - }), + Promise.all( + ids.map((id) => + api.assignIncident(id, { + team_id: team || null, + member_ids: memberIDs, + }), + ), + ), onSuccess: () => { qc.invalidateQueries({ queryKey: ["incidents"] }); - qc.invalidateQueries({ queryKey: ["incident", incidentID] }); - toast.push({ title: "Assignment saved", tone: "ok" }); + qc.invalidateQueries({ queryKey: ["incident-index"] }); + ids.forEach((id) => + qc.invalidateQueries({ queryKey: ["incident", id] }), + ); + toast.push({ + title: bulk + ? `Assignment saved for ${ids.length} incidents` + : "Assignment saved", + tone: "ok", + }); + onDone?.(); onClose(); }, }); @@ -79,7 +98,16 @@ export function AssignDialog({ >
- Incident {incidentID.slice(0, 8)} + {bulk ? ( + <> + {ids.length}{" "} + incidents selected + + ) : ( + <> + Incident {ids[0].slice(0, 8)} + + )}
- {metrics.length > 0 && ( - - )} @@ -820,9 +837,6 @@ function EnterpriseLearningSummary({ )} - {traces.length > 0 && ( - - )} diff --git a/ui/src/pages/AnalysesListPage.test.tsx b/ui/src/pages/AnalysesListPage.test.tsx new file mode 100644 index 0000000..c4fbea1 --- /dev/null +++ b/ui/src/pages/AnalysesListPage.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Routes, Route, useLocation } from "react-router-dom"; +import { AnalysesListPage } from "./AnalysesListPage"; +import { api, type AnalysisRecord } from "@/lib/api"; + +// The analyses table rows do NOT navigate — only the per-row eye opens a peek, +// whose footer button links to the analysis detail page. +vi.mock("@/lib/api", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + api: { + ...actual.api, + listAllAnalyses: vi.fn(), + listIncidents: vi.fn(), + }, + }; +}); + +afterEach(cleanup); + +function rec(overrides: Partial = {}): AnalysisRecord { + return { + id: "a1", + incident_id: "inc1", + requested_at: new Date().toISOString(), + status: "ok", + finding: { Title: "Root cause found" }, + ...overrides, + }; +} + +function LocationProbe() { + const loc = useLocation(); + return
{loc.pathname}
; +} + +function renderPage() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + } /> + analysis detail} + /> + + + , + ); +} + +describe("AnalysesListPage row actions", () => { + beforeEach(() => { + vi.mocked(api.listAllAnalyses).mockResolvedValue([rec()]); + vi.mocked(api.listIncidents).mockResolvedValue([]); + }); + + it("does not navigate on a plain row click", async () => { + renderPage(); + const eye = await screen.findByLabelText("View analysis a1"); + const row = eye.closest("tr") as HTMLTableRowElement; + fireEvent.click(row); + expect(screen.getByTestId("path").textContent).toBe("/analyses"); + }); + + it("opens a peek from the eye without navigating", async () => { + renderPage(); + fireEvent.click(await screen.findByLabelText("View analysis a1")); + expect(screen.getByTestId("path").textContent).toBe("/analyses"); + expect( + screen.getByRole("link", { name: /Open full page/ }), + ).toBeTruthy(); + }); +}); diff --git a/ui/src/pages/AnalysesListPage.tsx b/ui/src/pages/AnalysesListPage.tsx index d64e51f..a4dcb70 100644 --- a/ui/src/pages/AnalysesListPage.tsx +++ b/ui/src/pages/AnalysesListPage.tsx @@ -1,30 +1,36 @@ -import { useMemo } from "react"; -import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { useMemo, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { Search, X } from "lucide-react"; -import { api } from "@/lib/api"; +import { Eye, Search, X } from "lucide-react"; +import { api, type AnalysisRecord } from "@/lib/api"; import { fmtAbs, fmtRel, formatDuration, incidentTitle } from "@/lib/format"; import { useTableKeys } from "@/lib/hooks"; import { usePagination } from "@/lib/pagination"; +import { useBulkSelection } from "@/lib/useBulkSelection"; import { TopBar } from "@/components/TopBar"; import { Pill } from "@/components/Pill"; import { SeverityBadge } from "@/components/SeverityBadge"; import { SegmentedControl } from "@/components/SegmentedControl"; -import { ClickableRow } from "@/components/DataTable"; import { Pagination } from "@/components/Pagination"; +import { PeekPanel, PeekField } from "@/components/PeekPanel"; +import { + BulkActionBar, + RowSelectCheckbox, + SelectAllCheckbox, +} from "@/components/BulkActionBar"; import { SkRows } from "@/components/Skeleton"; import { RetryableError } from "@/components/RetryableError"; import { EmptyState } from "@/components/feedback"; -const COLS = 7; +const COLS = 9; // AnalysesListPage lists every analysis recorded across all incidents, -// newest first. Rows open the analysis DETAIL page (that route -// had no inbound link); the Incident column keeps a small secondary link -// to the parent incident. The Post-mortems tab is the explained future -// feature that used to be a dead sidebar item (empty-nav-state rule). +// newest first. The per-row eye opens a peek slide-out (rows themselves do +// not navigate); the peek footer links to the analysis DETAIL page. The +// Incident column keeps a small secondary link to the parent incident. The +// Post-mortems tab is the explained future feature that used to be a dead +// sidebar item (empty-nav-state rule). export function AnalysesListPage() { - const navigate = useNavigate(); const [params, setParams] = useSearchParams(); const tab = params.get("tab") ?? "analyses"; const status = params.get("status") ?? "all"; @@ -79,11 +85,23 @@ export function AnalysesListPage() { resetKey: `${status}|${incidentFilter ?? ""}|${q}`, }); + // Peek + selection. The analyses list is read-only, so the action bar + // carries no actions — it collapses to the selection count + Clear, matching + // the learned-signal tables — and the eye opens a peek without leaving the + // list. Rows do not navigate; the peek footer is the way to the full page. + const [peekId, setPeekId] = useState(null); + const pageKeys = useMemo(() => pg.pageItems.map((r) => r.id), [pg.pageItems]); + const bulk = useBulkSelection( + pageKeys, + `${status}|${incidentFilter ?? ""}|${q}|${pg.page}`, + ); + const peek = peekId ? (data ?? []).find((r) => r.id === peekId) : undefined; + const keys = useTableKeys({ size: pg.pageItems.length, onOpen: (i) => { const rec = pg.pageItems[i]; - if (rec) navigate(`/incidents/${rec.incident_id}/analyses/${rec.id}`); + if (rec) setPeekId(rec.id); }, }); @@ -186,6 +204,14 @@ export function AnalysesListPage() { )}
+ {bulk.count > 0 && ( + {}} + onClear={bulk.clear} + /> + )}
+ + + When Incident Finding @@ -201,6 +233,9 @@ export function AnalysesListPage() { Model Duration Status + + Action + @@ -227,11 +262,14 @@ export function AnalysesListPage() { )} {pg.pageItems.map((rec, i) => ( - + + + bulk.toggle(rec.id)} + label={`Select analysis ${rec.id}`} + /> + - + +
+ +
+ + ))} @@ -285,6 +336,93 @@ export function AnalysesListPage() { )} + + {peek && ( + setPeekId(null)} + title={peek.finding?.Title || `Analysis ${peek.id.slice(0, 8)}`} + footer={ + setPeekId(null)} + > + Open full page ↗ + + } + > + setPeekId(null)} + /> + + )} ); } + +// AnalysisPeekBody — the read-only detail slide-out for one analysis, matching +// the peek shape used across the incident / decision tables. +function AnalysisPeekBody({ + rec, + incidentTitleText, + onOpenIncident, +}: { + rec: AnalysisRecord; + incidentTitleText: string; + onOpenIncident: () => void; +}) { + return ( +
+
+ {rec.status} + +
+ +
+ + + {fmtRel(rec.requested_at)} + + + {rec.model || "—"} + + {rec.duration_ms !== undefined + ? formatDuration(rec.duration_ms) + : "—"} + + + + {incidentTitleText} + + +
+ + {rec.finding?.Summary && ( +
+
+ Summary +
+

+ {rec.finding.Summary} +

+
+ )} + + {rec.error && ( +
+ Error: {rec.error} +
+ )} +
+ ); +} diff --git a/ui/src/pages/DecisionsPage.tsx b/ui/src/pages/DecisionsPage.tsx index 42b4b5d..fcbff95 100644 --- a/ui/src/pages/DecisionsPage.tsx +++ b/ui/src/pages/DecisionsPage.tsx @@ -5,6 +5,7 @@ import { AlertTriangle, Database, Eraser, + Eye, EyeOff, ScrollText, Send, @@ -15,6 +16,7 @@ import { api, type DetectEvent, type ShadowEvent } from "@/lib/api"; import { fmtAbs, fmtRel, truncate } from "@/lib/format"; import { useTableKeys } from "@/lib/hooks"; import { buildSpikeRows, type SpikeRow } from "@/lib/spikeRows"; +import { useBulkSelection } from "@/lib/useBulkSelection"; import { TopBar } from "@/components/TopBar"; import { Pill, VerdictPill } from "@/components/Pill"; import { SeverityBadge } from "@/components/SeverityBadge"; @@ -24,6 +26,12 @@ import { SkRows } from "@/components/Skeleton"; import { RetryableError } from "@/components/RetryableError"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { Pagination } from "@/components/Pagination"; +import { PeekPanel, PeekField } from "@/components/PeekPanel"; +import { + BulkActionBar, + RowSelectCheckbox, + SelectAllCheckbox, +} from "@/components/BulkActionBar"; import { usePagination } from "@/lib/pagination"; import { useToast } from "@/components/toastContext"; import { EmptyState, EmptyValue } from "@/components/feedback"; @@ -228,7 +236,7 @@ const OUTCOME_LABELS: Record = { send_error: "Send error", }; -const DETECT_COLS = 9; +const DETECT_COLS = 11; function DetectTab() { const navigate = useNavigate(); @@ -239,6 +247,7 @@ function DetectTab() { }); const [filter, setFilter] = useState("all"); + const [peekId, setPeekId] = useState(null); const list = useMemo(() => { if (!events.data) return []; @@ -248,6 +257,13 @@ function DetectTab() { const pg = usePagination(list, { resetKey: filter }); + // Selection + bar — the same checkbox model the learned-signal tables use. + // The detect log is a read-only audit trail (no per-row mutation), so the bar + // carries no actions: it collapses to the selection count + Clear. The eye is + // the real affordance here, opening a peek without leaving the audit list. + const pageKeys = useMemo(() => pg.pageItems.map((e) => e.id), [pg.pageItems]); + const bulk = useBulkSelection(pageKeys, `${filter}|${pg.page}`); + const keys = useTableKeys({ size: pg.pageItems.length, onOpen: (i) => { @@ -256,6 +272,8 @@ function DetectTab() { }, }); + const peek = peekId ? events.data?.find((e) => e.id === peekId) : undefined; + return ( <>
+ {bulk.count > 0 && ( + {}} + onClear={bulk.clear} + /> + )}
+ + + Service When Outcome @@ -329,6 +361,9 @@ function DetectTab() { Title / Sample Freq ms + + Action + @@ -360,13 +395,110 @@ function DetectTab() { )} {pg.pageItems.map((e, i) => ( - + bulk.toggle(e.id)} + onPeek={() => setPeekId(e.id)} + /> ))}
+ + setPeekId(null)} + title={peek ? peek.finding?.Title || peek.pattern_id : ""} + footer={ + peek ? ( + setPeekId(null)} + > + Open full page ↗ + + ) : undefined + } + > + {peek && ( +
+
+ + + +
+
+ + {peek.service && peek.service !== "_unknown" + ? peek.service + : "—"} + + + + {fmtRel(peek.timestamp)} + + + + setPeekId(null)} + > + {peek.pattern_id} + + + + {peek.frequency}/s + + + + {peek.baseline.toFixed(1)}/s + {peek.baseline_std !== undefined && ( + + {" "} + ± {peek.baseline_std.toFixed(1)} + + )} + + + + {peek.duration_ms !== undefined + ? `${peek.duration_ms} ms` + : "—"} + +
+ {peek.explanation && ( +
+
+ Why it tripped +
+

+ {peek.explanation} +

+
+ )} +
+
+ Sample +
+
+                {peek.samples?.[0] || peek.template || "—"}
+              
+
+ {peek.error && ( +
+ Error: {peek.error} +
+ )} +
+ )} +
); } @@ -374,9 +506,15 @@ function DetectTab() { function DetectRow({ e, rowProps, + selected, + onToggleSelect, + onPeek, }: { e: DetectEvent; rowProps: React.HTMLAttributes; + selected: boolean; + onToggleSelect: () => void; + onPeek: () => void; }) { const titleOrSample = e.finding?.Title || @@ -387,6 +525,13 @@ function DetectRow({ const href = `/agent/decisions/detect/${encodeURIComponent(e.id)}`; return ( + + + {e.service && e.service !== "_unknown" ? e.service : } @@ -420,6 +565,19 @@ function DetectRow({ {e.duration_ms ?? } + +
+ +
+
); } @@ -486,7 +644,14 @@ export function OutcomePill({ outcome }: { outcome: string }) { const VERDICT_FILTERS = ["all", "spike", "unknown"] as const; type VerdictFilter = (typeof VERDICT_FILTERS)[number]; -const SHADOW_COLS = 9; +const SHADOW_COLS = 11; + +// shadowKey is the stable per-row key: a shadow event is one coalesced pattern, +// keyed on pattern id + first-seen so two windows of the same pattern stay +// distinct. +function shadowKey(e: ShadowEvent): string { + return `${e.pattern_id}-${e.first_seen}`; +} function ShadowEventsTable({ list, @@ -501,6 +666,15 @@ function ShadowEventsTable({ }) { const navigate = useNavigate(); const pg = usePagination(list, { resetKey }); + const [peekKey, setPeekKey] = useState(null); + + // Selection + bar — same model as the other tables; the shadow log is + // read-only so the bar collapses to count + Clear, and the eye opens a peek. + const pageKeys = useMemo( + () => pg.pageItems.map(shadowKey), + [pg.pageItems], + ); + const bulk = useBulkSelection(pageKeys, `${resetKey}|${pg.page}`); const keys = useTableKeys({ size: pg.pageItems.length, @@ -513,8 +687,18 @@ function ShadowEventsTable({ }, }); + const peek = peekKey ? list.find((e) => shadowKey(e) === peekKey) : undefined; + return (
+ {bulk.count > 0 && ( + {}} + onClear={bulk.clear} + /> + )}
+ + + Service Verdict Pattern @@ -532,6 +722,9 @@ function ShadowEventsTable({ Ticks Sample Last seen + + Action + @@ -543,12 +736,16 @@ function ShadowEventsTable({ )} {pg.pageItems.map((e, i) => { const href = `/agent/decisions/shadow/${encodeURIComponent(e.pattern_id)}`; + const key = shadowKey(e); return ( - + + + bulk.toggle(key)} + label={`Select shadow event ${e.pattern_id}`} + /> + {e.service && e.service !== "_unknown" ? ( e.service @@ -588,6 +785,19 @@ function ShadowEventsTable({ > {fmtRel(e.last_seen)} + +
+ +
+
); })} @@ -595,6 +805,66 @@ function ShadowEventsTable({
+ + setPeekKey(null)} + title={peek ? peek.pattern_id : ""} + footer={ + peek ? ( + setPeekKey(null)} + > + Open full page ↗ + + ) : undefined + } + > + {peek && ( +
+
+ + + {peek.rule_name || "no rule"} + +
+
+ + {peek.service && peek.service !== "_unknown" + ? peek.service + : "—"} + + {peek.source || "—"} + + {peek.count} + + + {peek.occurrences} + + + + {fmtRel(peek.first_seen)} + + + + + {fmtRel(peek.last_seen)} + + +
+
+
+ Sample +
+
+                {peek.sample_message || peek.template || "—"}
+              
+
+
+ )} +
); } @@ -723,7 +993,7 @@ function SpikeTab() { ); } -const SPIKE_COLS = 7; +const SPIKE_COLS = 9; // SpikeKindPill labels which decision log a spike came from — AI-detect (the // agent acted) vs Shadow (would have alerted). Text, never color alone. @@ -744,6 +1014,13 @@ function SpikeTable({ }) { const navigate = useNavigate(); const pg = usePagination(rows, { resetKey: "spike" }); + const [peekKey, setPeekKey] = useState(null); + + // Selection + bar — same model as the other tables; the spike view is a + // read-only merge of two logs, so the bar is count + Clear and the eye opens + // a peek. + const pageKeys = useMemo(() => pg.pageItems.map((r) => r.key), [pg.pageItems]); + const bulk = useBulkSelection(pageKeys, `spike|${pg.page}`); const keys = useTableKeys({ size: pg.pageItems.length, @@ -753,8 +1030,18 @@ function SpikeTable({ }, }); + const peek = peekKey ? rows.find((r) => r.key === peekKey) : undefined; + return (
+ {bulk.count > 0 && ( + {}} + onClear={bulk.clear} + /> + )}
+ + + Service Kind Pattern @@ -770,6 +1063,9 @@ function SpikeTable({ Sample Signals When + + Action + @@ -786,6 +1082,13 @@ function SpikeTable({ )} {pg.pageItems.map((r, i) => ( + + bulk.toggle(r.key)} + label={`Select spike ${r.patternId}`} + /> + {r.service && r.service !== "_unknown" ? ( r.service @@ -815,12 +1118,72 @@ function SpikeTable({ {fmtRel(r.when)} + +
+ +
+
))}
+ + setPeekKey(null)} + title={peek ? peek.patternId : ""} + footer={ + peek ? ( + setPeekKey(null)} + > + Open full page ↗ + + ) : undefined + } + > + {peek && ( +
+
+ +
+
+ + {peek.service && peek.service !== "_unknown" + ? peek.service + : "—"} + + {peek.source || "—"} + + {peek.count} + + + {fmtRel(peek.when)} + +
+
+
+ Sample +
+
+                {peek.sample || "—"}
+              
+
+
+ )} +
); } diff --git a/ui/src/pages/DetectDetailPage.tsx b/ui/src/pages/DetectDetailPage.tsx index 722db6b..a86a101 100644 --- a/ui/src/pages/DetectDetailPage.tsx +++ b/ui/src/pages/DetectDetailPage.tsx @@ -84,6 +84,9 @@ export function DetectDetailPage() { label="Baseline" value={event.data.baseline.toFixed(2)} /> + {event.data.explanation && ( + + )} { + const actual = await importActual(); + return { + ...actual, + api: { + ...actual.api, + getIncidentsConfig: vi.fn(), + }, + }; +}); + +afterEach(cleanup); + +function config(): IncidentsConfig { + return { + name: "versus", + host: "0.0.0.0", + port: 3000, + public_host: "", + alert: { + debug_body: false, + channels: [ + { + id: "slack", + name: "Slack", + enable: true, + fields: [ + { label: "Channel ID", value: "C123" }, + { label: "Template", value: "config/slack_message.tmpl" }, + ], + }, + ], + }, + queue: { enable: false, debug_body: false, providers: [] }, + oncall: { + enable: false, + initialized_only: false, + wait_minutes: 3, + provider: "", + aws_incident_manager: { + response_plan_arn: "", + other_response_plan_keys: [], + }, + pagerduty: { routing_key: "", other_routing_keys: [] }, + servicenow: { + instance_url: "", + username: "", + table: "incident", + other_instance_keys: [], + }, + incident_io: { + api_key: "", + alert_source_config_id: "", + other_alert_source_config_keys: [], + }, + }, + storage: { type: "file", file: { max_incidents: 100 } }, + }; +} + +function renderPanel() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + +describe("IncidentsConfigPanel channel card", () => { + beforeEach(() => { + vi.mocked(api.getIncidentsConfig).mockResolvedValue(config()); + }); + + it("does not render the channel template path", async () => { + renderPanel(); + // Wait for the channel to render. + await screen.findByText("Channel ID"); + expect(screen.queryByText("Template")).toBeNull(); + expect( + screen.queryByText("config/slack_message.tmpl"), + ).toBeNull(); + }); + + it("keeps other channel fields visible", async () => { + renderPanel(); + expect(await screen.findByText("Channel ID")).toBeTruthy(); + expect(screen.getByText("C123")).toBeTruthy(); + }); +}); diff --git a/ui/src/pages/IncidentsConfigPage.tsx b/ui/src/pages/IncidentsConfigPage.tsx index 72de676..286628b 100644 --- a/ui/src/pages/IncidentsConfigPage.tsx +++ b/ui/src/pages/IncidentsConfigPage.tsx @@ -218,9 +218,11 @@ function ChannelCard({
- {channel.fields.map((f) => ( - - ))} + {channel.fields + .filter((f) => f.label !== "Template") + .map((f) => ( + + ))}
diff --git a/ui/src/pages/IncidentsPage.test.tsx b/ui/src/pages/IncidentsPage.test.tsx new file mode 100644 index 0000000..8967f72 --- /dev/null +++ b/ui/src/pages/IncidentsPage.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { + render, + screen, + cleanup, + fireEvent, + within, +} from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Routes, Route, useLocation } from "react-router-dom"; +import { ToastProvider } from "@/components/Toast"; +import { IncidentsPage } from "./IncidentsPage"; +import { api, type IncidentIndex, type IncidentSummary } from "@/lib/api"; + +// The Incidents table row exposes ONLY the eye (Assign / Resolve moved to the +// bulk-action bar), and the row itself is no longer a navigation control — +// clicking a row must NOT navigate; only the eye opens the peek. These pin both. +vi.mock("@/lib/api", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + api: { + ...actual.api, + listIncidentsIndex: vi.fn(), + searchIncidentsIndex: vi.fn(), + capabilities: vi.fn().mockResolvedValue({ search: false }), + listTeams: vi.fn().mockResolvedValue([]), + listMembers: vi.fn().mockResolvedValue([]), + }, + }; +}); + +afterEach(cleanup); + +function incident(overrides: Partial = {}): IncidentSummary { + return { + id: "abcdef1234567890", + title: "Checkout latency spike", + source: "ai_detect", + origin: "ai_detect", + service: "checkout", + resolved: false, + created_at: new Date().toISOString(), + ...overrides, + }; +} + +function index(rows: IncidentSummary[]): IncidentIndex { + return { + incidents: rows, + counts: { ai_detect: rows.length, webhook: 0, total: rows.length }, + total: rows.length, + }; +} + +// LocationProbe surfaces the current path so a click can be asserted to NOT +// navigate. +function LocationProbe() { + const loc = useLocation(); + return
{loc.pathname}
; +} + +function renderPage() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + + } /> + incident detail} + /> + + + + , + ); +} + +describe("IncidentsPage row actions", () => { + beforeEach(() => { + vi.mocked(api.listIncidentsIndex).mockResolvedValue(index([incident()])); + }); + + it("shows only the eye action per row — no Assign / Resolve buttons", async () => { + renderPage(); + // The single row's eye is present… + expect(await screen.findByLabelText(/View incident/)).toBeTruthy(); + // …and the per-row Assign / Resolve icon buttons are gone (they live in the + // bulk-action bar now, which only appears on selection). + expect( + screen.queryByRole("button", { name: "Assign team or member" }), + ).toBeNull(); + expect( + screen.queryByRole("button", { name: "Mark incident resolved" }), + ).toBeNull(); + }); + + it("does not navigate when the row is clicked (only the eye acts)", async () => { + renderPage(); + const eye = await screen.findByLabelText(/View incident/); + const row = eye.closest("tr") as HTMLTableRowElement; + // A plain row click is inert — the row carries no navigation affordance. + fireEvent.click(row); + expect(screen.getByTestId("path").textContent).toBe("/incidents"); + // The eye opens the in-place peek, still without a route change. + fireEvent.click(eye); + const panel = screen.getByRole("dialog", { name: "Details panel" }); + expect(within(panel).getByText("checkout")).toBeTruthy(); + expect(screen.getByTestId("path").textContent).toBe("/incidents"); + }); +}); diff --git a/ui/src/pages/IncidentsPage.tsx b/ui/src/pages/IncidentsPage.tsx index 1e9d0cb..be5bfc0 100644 --- a/ui/src/pages/IncidentsPage.tsx +++ b/ui/src/pages/IncidentsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { useMutation, useQuery, @@ -9,6 +9,7 @@ import { CheckCircle2, ChevronDown, ChevronUp, + Eye, Search, UserPlus, } from "lucide-react"; @@ -26,13 +27,19 @@ import { type IncidentStatusFilter, } from "@/lib/incidentList"; import { usePagination } from "@/lib/pagination"; +import { useBulkSelection } from "@/lib/useBulkSelection"; import { TopBar } from "@/components/TopBar"; import { Pill, SourceBadge } from "@/components/Pill"; import { EmptyState, EmptyValue } from "@/components/feedback"; import { AssignDialog } from "@/components/AssignDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { ClickableRow } from "@/components/DataTable"; import { Pagination } from "@/components/Pagination"; +import { PeekPanel, PeekField } from "@/components/PeekPanel"; +import { + BulkActionBar, + RowSelectCheckbox, + SelectAllCheckbox, +} from "@/components/BulkActionBar"; import { SegmentedControl } from "@/components/SegmentedControl"; import { SeverityBadge } from "@/components/SeverityBadge"; import { SkLine, SkRows } from "@/components/Skeleton"; @@ -45,8 +52,8 @@ type StatusFilter = IncidentStatusFilter; const STATUS_VALUES = INCIDENT_STATUS_VALUES; // Column count for colSpan cells (skeleton / empty / notify-error rows): -// service · sev · when · title · channels · assigned · notify · status · id · actions -const COLS = 10; +// select · service · sev · when · title · channels · assigned · notify · status · id · actions +const COLS = 11; // useDebounced returns a value that only updates after `delay` ms of no // change — keeps server-side search from firing on every keystroke. @@ -66,7 +73,6 @@ function useDebounced(value: T, delay = 300): T { // search (Postgres), the query runs on the server; otherwise it falls back // to filtering the already-loaded page client-side. export function IncidentsPage() { - const navigate = useNavigate(); const qc = useQueryClient(); const toast = useToast(); const [q, setQ] = useState(""); @@ -89,6 +95,12 @@ export function IncidentsPage() { const [expandedNotify, setExpandedNotify] = useState>( () => new Set(), ); + // Peek + bulk state. The eye opens a detail slide-out (peekId); the action + // bar's Assign/Resolve capture the current selection into these when picked + // (null = closed), so a single-row action is just a one-row selection. + const [peekId, setPeekId] = useState(null); + const [bulkAssignIds, setBulkAssignIds] = useState(null); + const [bulkResolveIds, setBulkResolveIds] = useState(null); // Probe backend capabilities once; default to no server search until // known. The probe degrades silently — when it fails the page simply @@ -168,6 +180,21 @@ export function IncidentsPage() { resetKey: incidentResetKey(origin, status, trimmed), }); + // ----- selection + action bar ------------------------------------------- + // The SAME checkbox model the learned-signal tables use: a select-all + // checkbox in the header, a checkbox per row, and a bar that APPEARS on + // selection surfacing the per-row actions (Assign / Resolve) as bulk actions. + // Selection resets on origin / status / search / PAGE change. + const pageKeys = useMemo(() => pg.pageItems.map((i) => i.id), [pg.pageItems]); + const bulk = useBulkSelection( + pageKeys, + `${incidentResetKey(origin, status, trimmed)}|${pg.page}`, + ); + const bulkActions = [ + { id: "assign", label: "Assign" }, + { id: "resolve", label: "Resolve" }, + ]; + // Resolve is optimistic (§2.4): cache flips immediately, rollback + // error toast with Retry on failure, invalidate on settle. const resolveMut = useMutation({ @@ -223,14 +250,15 @@ export function IncidentsPage() { }, }); - // j/k + Enter navigation; a/r act on the active row (modals trap focus, - // so these can't fire while a dialog is open). Scoped to the rendered page - // so navigation stays bounded on a multi-thousand-row history. + // j/k move the active row; Enter opens the peek (the eye's action) — the + // row itself is not a navigation control. a/r act on the active row (modals + // trap focus, so these can't fire while a dialog is open). Scoped to the + // rendered page so navigation stays bounded on a multi-thousand-row history. const { containerProps, rowProps } = useTableKeys({ size: pg.pageItems.length, onOpen: (i) => { const row = pg.pageItems[i]; - if (row) navigate(`/incidents/${row.id}`); + if (row) setPeekId(row.id); }, extra: (key, i) => { const row = pg.pageItems[i]; @@ -254,6 +282,31 @@ export function IncidentsPage() { return next; }); + // Map an id back to its row for the selection actions + peek. The whole + // loaded set is the source so the peek survives a page/filter change. + const byId = useMemo(() => { + const m = new Map(); + for (const i of data?.incidents ?? []) m.set(i.id, i); + return m; + }, [data?.incidents]); + + const onBulkAction = (spec: { id: string }) => { + const ids = bulk.selectedKeys; + if (ids.length === 0) return; + if (spec.id === "assign") { + // Keep the selection until the dialog finishes (onDone clears it). + setBulkAssignIds([...ids]); + return; + } + if (spec.id === "resolve") { + // Resolve only the ones not already resolved; a confirm gates the write. + const unresolved = ids.filter((id) => !byId.get(id)?.resolved); + setBulkResolveIds(unresolved); + } + }; + + const peek = peekId ? byId.get(peekId) : undefined; + return ( <> ) : (
+ {bulk.count > 0 && ( + + )}
+ + + Service Severity When @@ -387,14 +455,11 @@ export function IncidentsPage() { teamById={teamById} memberById={memberById} rosterLoading={rosterLoading} + selected={bulk.isSelected(i.id)} + onToggleSelect={() => bulk.toggle(i.id)} + onPeek={() => setPeekId(i.id)} notifyExpanded={expandedNotify.has(i.id)} onToggleNotify={() => toggleNotify(i.id)} - onAssign={() => setAssignFor(i)} - onResolve={() => setResolveFor(i)} - resolvePending={ - resolveMut.isPending && - resolveMut.variables?.id === i.id - } /> ))} @@ -437,32 +502,211 @@ export function IncidentsPage() { }} /> )} + + {/* Bulk assign — one team/member set applied to every selected incident, + via the shared AssignDialog. onDone clears the selection. */} + {bulkAssignIds && ( + setBulkAssignIds(null)} + onDone={() => bulk.clear()} + /> + )} + + {/* Bulk resolve — a single confirm gates resolving the selection; each + not-yet-resolved incident is resolved through the same mutation. */} + {bulkResolveIds && ( + All selected incidents are already resolved. + ) : ( + <> + Mark{" "} + + {bulkResolveIds.length} + {" "} + {bulkResolveIds.length === 1 ? "incident" : "incidents"} as + resolved? This stamps a resolved-at timestamp and cannot be + undone from the UI today. + + ) + } + confirmLabel="Resolve" + busy={resolveMut.isPending} + onConfirm={() => { + for (const id of bulkResolveIds) { + const inc = byId.get(id); + if (inc && !inc.resolved) resolveMut.mutate(inc); + } + setBulkResolveIds(null); + bulk.clear(); + }} + onClose={() => { + if (!resolveMut.isPending) setBulkResolveIds(null); + }} + /> + )} + + {/* Peek — inspect an incident without leaving the list; the footer link + opens the full incident page. */} + {peek && ( + setPeekId(null)} + title={truncate(incidentTitle(peek), 60)} + footer={ + setPeekId(null)} + > + Open full page ↗ + + } + > + { + setPeekId(null); + setAssignFor(peek); + }} + onResolve={() => { + setPeekId(null); + setResolveFor(peek); + }} + /> + + )} ); } +// IncidentPeekBody — the read-only detail shown in the slide-out, plus the same +// Assign / Resolve actions the row offers so the peek is a full stand-in for the +// row. +function IncidentPeekBody({ + i, + teamById, + memberById, + onAssign, + onResolve, +}: { + i: IncidentSummary; + teamById: Map; + memberById: Map; + onAssign: () => void; + onResolve: () => void; +}) { + const statusLabel = i.resolved ? "resolved" : i.acked_at ? "acked" : "open"; + const statusTone = i.resolved ? "good" : i.acked_at ? "accent" : "bad"; + const teamName = i.assigned_team_id + ? teamById.get(i.assigned_team_id) + : undefined; + const memberNames = (i.assigned_member_ids ?? []).map( + (id) => memberById.get(id) ?? id.slice(0, 8), + ); + const hasAssignment = + !!i.assigned_team_id || (i.assigned_member_ids ?? []).length > 0; + + return ( +
+
+ {statusLabel} + +
+ +
+ + {i.service && i.service !== "_unknown" ? i.service : "—"} + + + {fmtRel(i.created_at)} + + + {i.channels_notified && i.channels_notified.length > 0 ? ( + + {i.channels_notified.map((c) => ( + {c} + ))} + + ) : ( + "—" + )} + + + {hasAssignment ? ( + + {teamName && {teamName}} + {memberNames.map((n, idx) => ( + {n} + ))} + + ) : ( + "—" + )} + + {i.notify_status || "—"} + + + {i.id.slice(0, 8)} + + +
+ + {i.notify_status === "failed" && i.notify_error && ( +
+ Notify failed: {i.notify_error} +
+ )} + +
+ + +
+
+ ); +} + function IncidentRow({ i, rowProps, teamById, memberById, rosterLoading, + selected, + onToggleSelect, + onPeek, notifyExpanded, onToggleNotify, - onAssign, - onResolve, - resolvePending, }: { i: IncidentSummary; rowProps: Record; teamById: Map; memberById: Map; rosterLoading: boolean; + selected: boolean; + onToggleSelect: () => void; + onPeek: () => void; notifyExpanded: boolean; onToggleNotify: () => void; - onAssign: () => void; - onResolve: () => void; - resolvePending: boolean; }) { const status = i.resolved ? { label: "resolved", tone: "good" as const } @@ -481,7 +725,14 @@ function IncidentRow({ return ( <> - + + + + {i.service && i.service !== "_unknown" ? ( {i.service} @@ -577,30 +828,15 @@ function IncidentRow({
-
-
+ {notifyFailed && notifyExpanded && ( - } @@ -209,6 +206,22 @@ export function PatternDetailPage() { /> +
+
+ What's normal +
+

+ The agent tracks three views of normal for this pattern — the + spike mode decides which one a surge is measured against. +

+ +
+
Example log line diff --git a/ui/src/pages/PatternsPage.test.tsx b/ui/src/pages/PatternsPage.test.tsx new file mode 100644 index 0000000..43eaa36 --- /dev/null +++ b/ui/src/pages/PatternsPage.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { + render, + screen, + cleanup, + fireEvent, + within, +} from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { ToastProvider } from "@/components/Toast"; +import { PatternsPage } from "./PatternsPage"; +import { + api, + type Pattern, + type SeasonalBucket, +} from "@/lib/api"; + +// The logs LIST endpoint strips `samples` and (on the Postgres backend) can +// carry a leaner baseline set than the full record. These pin that opening the +// peek FETCHES the pattern DETAIL (the same read the full page uses) and +// renders the complete baselines — incl. the hour-of-day grid — and the +// redacted sample example from THAT detail, not the thin list row. +// +// The deployment / license probes answer 403 (community / OSS) so the +// licensed-admin bulk column stays absent and each row shows exactly one +// unambiguous "View details" eye. +vi.mock("@/lib/api", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + api: { + ...actual.api, + listPatterns: vi.fn(), + getPattern: vi.fn(), + listBaselines: vi + .fn() + .mockRejectedValue(new actual.ApiError(403, "community")), + listServiceOverrides: vi.fn().mockResolvedValue([]), + getSSODeployment: vi + .fn() + .mockRejectedValue(new actual.ApiError(403, "community")), + }, + }; +}); + +afterEach(cleanup); + +function seasonalOneWarmedHour(): SeasonalBucket[] { + return Array.from({ length: 24 }, (_, h) => + h === 0 + ? { mean: 7.5, variance: 0.25, count: 4 } + : { mean: 0, variance: 0, count: 0 }, + ); +} + +// listRow is what the LIST endpoint returns: NO samples, and no seasonal / +// cumulative baselines (the leaner Postgres list shape). +function listRow(overrides: Partial = {}): Pattern { + return { + id: "p-checkout-1", + template: "payment <*> failed", + first_seen: new Date().toISOString(), + last_seen: new Date().toISOString(), + count: 1200, + baseline_frequency: 1.3, + verdict: "", + rule_name: "checkout", + source: "logs", + service: "checkout", + readiness: { ready: false, seen: 40, needed: 100, rate_per_min: 2 }, + ...overrides, + }; +} + +// detail is the DETAIL read: full baselines + the redacted sample ring. +function detail(overrides: Partial = {}): Pattern { + return { + ...listRow(), + baseline_variance: 0.25, + baseline_avg: 1.1, + seasonal: seasonalOneWarmedHour(), + samples: ["payment 8471 failed", "payment 22 failed"], + ...overrides, + }; +} + +function renderPage() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + + + , + ); +} + +async function openPeek(): Promise { + const eye = await screen.findByTitle("View details"); + fireEvent.click(eye); + return screen.getByRole("dialog", { name: "Details panel" }); +} + +describe("PatternsPage peek — fetches the pattern DETAIL", () => { + beforeEach(() => { + vi.mocked(api.listServiceOverrides).mockResolvedValue([]); + vi.mocked(api.listPatterns).mockResolvedValue([listRow()]); + vi.mocked(api.getPattern).mockResolvedValue(detail()); + }); + + it("calls getPattern for the opened row (same read as the full page)", async () => { + renderPage(); + await openPeek(); + expect(api.getPattern).toHaveBeenCalledWith("p-checkout-1"); + }); + + it("renders the redacted sample example from the fetched detail", async () => { + renderPage(); + const panel = await openPeek(); + // The list row carries NO samples — the example can only come from detail. + expect(await within(panel).findByText("payment 22 failed")).toBeTruthy(); + expect(within(panel).getByText("Example log line")).toBeTruthy(); + expect( + within(panel).queryByText("No example captured yet"), + ).toBeNull(); + }); + + it("renders the detail's baselines incl. the warmed hour-of-day cell", async () => { + renderPage(); + const panel = await openPeek(); + // seasonal[0] warmed to 7.5/s — only present on the detail read. + expect(await within(panel).findByText("7.5")).toBeTruthy(); + // The cumulative-mean baseline is likewise a detail-only number. + expect(within(panel).getByText(/≈ 1\.1\/s/)).toBeTruthy(); + }); + + it("wraps the pattern template instead of scrolling it sideways", async () => { + renderPage(); + const panel = await openPeek(); + const pre = within(panel).getByText("payment <*> failed"); + expect(pre.tagName).toBe("PRE"); + // The whole template is visible — it wraps rather than scrolling left/right. + expect(pre.className).toContain("whitespace-pre-wrap"); + expect(pre.className).toContain("break-words"); + expect(pre.className).not.toContain("overflow-auto"); + }); +}); diff --git a/ui/src/pages/PatternsPage.tsx b/ui/src/pages/PatternsPage.tsx index e543322..0c60ee9 100644 --- a/ui/src/pages/PatternsPage.tsx +++ b/ui/src/pages/PatternsPage.tsx @@ -14,6 +14,7 @@ import { useAutoRefresh } from "@/lib/useAutoRefresh"; import { EmptyState } from "@/components/feedback"; import { SegmentedControl } from "@/components/SegmentedControl"; import { PeekPanel } from "@/components/PeekPanel"; +import { PatternBaselines } from "@/components/PatternBaselines"; import { SkRows } from "@/components/Skeleton"; import { RetryableError } from "@/components/RetryableError"; import { ConfirmDialog } from "@/components/ConfirmDialog"; @@ -284,6 +285,18 @@ export function PatternsPage() { const peek = peekId ? (data ?? []).find((p) => p.id === peekId) : undefined; + // The list endpoint strips `samples` and (on the Postgres backend) can carry + // a leaner baseline set than the full record. So the peek fetches the SAME + // detail the full page reads — the complete, correct pattern incl. samples, + // every baseline, and the seasonal grid — keyed on the open pattern. The list + // row is the graceful fallback for the header fields while the detail loads. + const peekDetail = useQuery({ + queryKey: ["pattern", peekId], + queryFn: () => api.getPattern(peekId as string), + enabled: !!peekId, + }); + const detail = peekDetail.data; + return ( <> @@ -486,7 +499,7 @@ export function PatternsPage() { {p.count} - ≈ {p.baseline_frequency.toFixed(1)} + ≈ {p.baseline_frequency.toFixed(1)}/s
@@ -569,7 +582,7 @@ export function PatternsPage() { {peek.rule_name || "no rule"}
-
+            
               {peek.template}
             
@@ -577,11 +590,6 @@ export function PatternsPage() { {peek.count} - - - ≈ {peek.baseline_frequency.toFixed(1)} - - @@ -613,6 +621,39 @@ export function PatternsPage() { +
+
+ What's normal +
+ {peekDetail.isLoading ? ( +

Loading baselines…

+ ) : ( + + )} +
+ +
+
+ Example log line +
+ {peekDetail.isLoading ? ( +

Loading example…

+ ) : detail?.samples && detail.samples.length > 0 ? ( +
+                  {detail.samples[detail.samples.length - 1]}
+                
+ ) : ( +

No example captured yet

+ )} +
+
+
+ ))} @@ -744,6 +769,87 @@ export function ServiceDetailPage() {
)} + + setPeekPattern(null)} + title={ + peekPattern ? ( + {peekPattern.id} + ) : ( + "" + ) + } + footer={ + peekPattern ? ( + setPeekPattern(null)} + > + Open full page ↗ + + ) : undefined + } + > + {peekPattern && ( +
+
+ + + {peekPattern.source || "no source"} + +
+ +
+              {peekPattern.template || peekPattern.id}
+            
+ +
+ + {peekPattern.count} + + + + {fmtRel(peekPattern.last_seen)} + + +
+ +
+
+ Sample log lines +
+ {peekPattern.samples && peekPattern.samples.length > 0 ? ( +
+ {[...peekPattern.samples].reverse().map((s, i) => ( +
+                      {s}
+                    
+ ))} +
+ ) : ( +

No samples captured yet

+ )} +
+ +
+
+ What's normal +
+ +
+
+ )} +
); } diff --git a/ui/src/pages/ServicesPage.test.tsx b/ui/src/pages/ServicesPage.test.tsx new file mode 100644 index 0000000..0a74110 --- /dev/null +++ b/ui/src/pages/ServicesPage.test.tsx @@ -0,0 +1,250 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Routes, Route, useLocation } from "react-router-dom"; +import { ToastProvider } from "@/components/Toast"; +import { ServicesPage } from "./ServicesPage"; +import { api, getSsoSession, type ServiceInfo } from "@/lib/api"; + +// The Services table row has a per-row eye that opens a PEEK slide-out (rows +// never navigate, and the service NAME is not a link). The peek fetches the +// service detail for its pattern/incident counts and its footer button opens +// the full service detail page. The deployment / license probes answer 403 +// (community / OSS) so the enterprise Ignore controls stay absent. +vi.mock("@/lib/api", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + // getSsoSession is a module-level export (not on `api`) that useEffectiveRole + // reads directly — default it to "no session" so the enterprise surface stays + // absent unless a test opts in. + getSsoSession: vi + .fn() + .mockRejectedValue(new actual.ApiError(401, "no session")), + api: { + ...actual.api, + listServices: vi.fn(), + getServiceDetail: vi.fn(), + listBaselines: vi + .fn() + .mockRejectedValue(new actual.ApiError(403, "community")), + getSSODeployment: vi + .fn() + .mockRejectedValue(new actual.ApiError(403, "community")), + getLearnExclusions: vi + .fn() + .mockResolvedValue({ services: [], metrics: [], patterns: [] }), + setServiceLearnExclusion: vi + .fn() + .mockResolvedValue({ services: [], metrics: [], patterns: [] }), + }, + }; +}); + +afterEach(cleanup); + +function svc(overrides: Partial = {}): ServiceInfo { + return { + first_seen: new Date().toISOString(), + manual: false, + in_grace: false, + grace_seconds_remaining: 0, + ...overrides, + }; +} + +function LocationProbe() { + const loc = useLocation(); + return
{loc.pathname}
; +} + +function renderPage() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + + } /> + service detail
} + /> + + + + , + ); +} + +describe("ServicesPage row actions", () => { + beforeEach(() => { + vi.mocked(api.listServices).mockResolvedValue({ checkout: svc() }); + vi.mocked(api.getServiceDetail).mockResolvedValue({ + service: "checkout", + first_seen: new Date().toISOString(), + in_grace: false, + grace_seconds_remaining: 0, + patterns: [], + incidents: { + window_days: 30, + count: 0, + severities: {}, + recent: [], + }, + counts: { patterns: 3, incidents: 1 }, + }); + }); + + it("opens a peek from the per-row eye without navigating", async () => { + renderPage(); + const eye = await screen.findByLabelText("View service checkout"); + fireEvent.click(eye); + // The peek opens in place — no navigation happens. + expect(screen.getByTestId("path").textContent).toBe("/agent/services"); + expect(screen.getByRole("dialog")).toBeTruthy(); + // Footer button links to the full service detail page. + expect( + screen.getByRole("link", { name: /Open full page/ }), + ).toBeTruthy(); + }); + + it("navigates to the detail page from the peek footer button", async () => { + renderPage(); + fireEvent.click(await screen.findByLabelText("View service checkout")); + fireEvent.click(screen.getByRole("link", { name: /Open full page/ })); + expect(screen.getByTestId("path").textContent).toBe( + "/agent/services/checkout", + ); + }); + + it("does not navigate on a plain row click (only the eye opens the peek)", async () => { + renderPage(); + const eye = await screen.findByLabelText("View service checkout"); + const row = eye.closest("tr") as HTMLTableRowElement; + // The service name is plain text now — no stray link makes the row navigate. + expect( + screen.queryByRole("link", { name: "checkout" }), + ).toBeNull(); + fireEvent.click(row); + expect(screen.getByTestId("path").textContent).toBe("/agent/services"); + }); +}); + +// The Services page unifies its Active | Ignored presentation with the logs and +// metrics/traces pages: ONE table with a SegmentedControl scope toggle (count +// badges) that filters rows by whether the service is held out of learning — +// the toggle appearing only when the enterprise Disable-Learn exclude surface +// is licensed to an admin, and absent otherwise (scope stays "active"). +describe("ServicesPage Active/Ignored scope", () => { + // Render the enterprise exclude surface: a licensed binary (baselines probe + // succeeds), an admin session (deployment org + admin whoami), and a policy + // that already ignores one of the two services. + function renderScoped(initialEntry = "/agent/services") { + vi.mocked(api.listServices).mockResolvedValue({ + checkout: svc(), + payments: svc(), + }); + vi.mocked(api.getServiceDetail).mockResolvedValue({ + service: "checkout", + first_seen: new Date().toISOString(), + in_grace: false, + grace_seconds_remaining: 0, + patterns: [], + incidents: { window_days: 30, count: 0, severities: {}, recent: [] }, + counts: { patterns: 0, incidents: 0 }, + }); + vi.mocked(api.listBaselines).mockResolvedValue({ + type: "metric", + count: 0, + baselines: [], + }); + vi.mocked(api.getSSODeployment).mockResolvedValue({ org: "acme" }); + vi.mocked(getSsoSession).mockResolvedValue({ + org: "acme", + email: "admin@acme.test", + subject: "admin", + mfa: false, + role: "admin", + issued_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 3_600_000).toISOString(), + }); + // "checkout" is held out of learning; "payments" is active. + vi.mocked(api.getLearnExclusions).mockResolvedValue({ + services: ["checkout"], + metrics: [], + patterns: [], + }); + + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + + } /> + service detail
} + /> + + + + , + ); + } + + it("shows an Active | Ignored scope toggle with count badges when the exclude surface is licensed", async () => { + renderScoped(); + // Active holds the one non-excluded service; Ignored holds the one excluded + // — the counts settle once the exclusion policy resolves. + const active = await screen.findByRole("tab", { name: /Active/ }); + const ignored = screen.getByRole("tab", { name: /Ignored/ }); + await waitFor(() => expect(ignored.textContent).toContain("1")); + expect(active.textContent).toContain("1"); + // Default scope is Active — the active service shows, the ignored one is out. + expect(screen.getByText("payments")).toBeTruthy(); + expect(screen.queryByText("checkout")).toBeNull(); + }); + + it("filters to the ignored service when the Ignored scope is selected", async () => { + renderScoped(); + fireEvent.click(await screen.findByRole("tab", { name: /Ignored/ })); + // The excluded service moves into the single table under the Ignored scope; + // the active one leaves it. + expect(await screen.findByText("checkout")).toBeTruthy(); + expect(screen.queryByText("payments")).toBeNull(); + }); + + it("shows the empty-state when the Ignored scope has no services", async () => { + vi.mocked(api.getLearnExclusions).mockResolvedValue({ + services: [], + metrics: [], + patterns: [], + }); + renderScoped("/agent/services?scope=ignored"); + expect(await screen.findByText("No services are ignored")).toBeTruthy(); + }); + + it("keeps the scope toggle absent on a community / unlicensed binary", async () => { + // Community defaults from the module mock: baselines + deployment 403. + vi.mocked(api.listServices).mockResolvedValue({ checkout: svc() }); + vi.mocked(api.listBaselines).mockRejectedValue( + new (await import("@/lib/api")).ApiError(403, "community"), + ); + vi.mocked(api.getSSODeployment).mockRejectedValue( + new (await import("@/lib/api")).ApiError(403, "community"), + ); + renderPage(); + await screen.findByLabelText("View service checkout"); + expect(screen.queryByRole("tablist", { name: "Learning scope" })).toBeNull(); + }); +}); + diff --git a/ui/src/pages/ServicesPage.tsx b/ui/src/pages/ServicesPage.tsx index def7fc9..9cb341b 100644 --- a/ui/src/pages/ServicesPage.tsx +++ b/ui/src/pages/ServicesPage.tsx @@ -1,12 +1,13 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Link } from "react-router-dom"; -import { Plus, Search, Trash2 } from "lucide-react"; +import { Link, useSearchParams } from "react-router-dom"; +import { Plus, Search, Trash2, Eye } from "lucide-react"; import { api } from "@/lib/api"; import { fmtAbs, fmtRel } from "@/lib/format"; import { TopBar } from "@/components/TopBar"; import { Pill } from "@/components/Pill"; import { InfoHint } from "@/components/InfoHint"; +import { SegmentedControl } from "@/components/SegmentedControl"; import { AutoRefreshControl } from "@/components/AutoRefreshControl"; import { useAutoRefresh } from "@/lib/useAutoRefresh"; import { EmptyState } from "@/components/feedback"; @@ -15,6 +16,7 @@ import { SkRows } from "@/components/Skeleton"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { TextInputModal } from "@/components/TextInputModal"; import { Pagination } from "@/components/Pagination"; +import { PeekPanel, PeekField } from "@/components/PeekPanel"; import { usePagination } from "@/lib/pagination"; import { useToast } from "@/components/toastContext"; import { @@ -28,6 +30,12 @@ import { useIntelLicensed, useLearnExclusions, } from "@/lib/useLearnExclusions"; +import { + countExcluded, + filterByScope, + isExclusionScope, + SCOPE_PARAM, +} from "@/lib/rowActions"; import { GRACE_ACTION_LABEL, graceActionsForSelection, @@ -46,10 +54,25 @@ export function ServicesPage() { }); const [q, setQ] = useState(""); + const [params] = useSearchParams(); + const scope = isExclusionScope(params.get(SCOPE_PARAM)); + + // Eye → peek slide-out. Rows never navigate; the eye opens a read-only peek + // of the key service facts and the peek footer links to the full detail + // page. The list row carries origin/grace/first-seen; the pattern & incident + // counts come from the service-detail read (fetched on open, same shape the + // full page uses), so the peek shows a loading state until they arrive. + const [peekName, setPeekName] = useState(null); + const peekInfo = peekName ? data?.[peekName] : undefined; + const peekDetail = useQuery({ + queryKey: ["service-detail", peekName], + queryFn: () => api.getServiceDetail(peekName as string), + enabled: !!peekName, + }); // Enterprise Disable-Learn ("ignore") controls — gated to a licensed admin, // hidden on community / viewer. Drives the Ignore/Resume bulk actions and the - // "Ignored services" table below the list. + // Active | Ignored scope toggle. const licensed = useIntelLicensed(); const ignore = useLearnExclusions(licensed); @@ -173,7 +196,33 @@ export function ServicesPage() { const filtered = needle ? sorted.filter(([name]) => name.toLowerCase().includes(needle)) : sorted; - const pg = usePagination(filtered, { resetKey: q }); + + // ----- Active | Ignored scope -------------------------------------- + // A service is "ignored" when it is held out of learning (the whole-service + // exclude). The scope control is gated on ignore.visible — absent for + // community / viewers, so scope stays "active" and nothing is partitioned + // out. The server stays authority: this only re-partitions what the loaded + // policy already reports. + const isRowExcluded = ([name]: [string, unknown]) => + ignore.isServiceExcluded(name); + const scopeCounts = useMemo( + () => ({ + active: filtered.length - countExcluded(filtered, isRowExcluded), + ignored: countExcluded(filtered, isRowExcluded), + }), + // isRowExcluded closes over ignore; recompute when the policy or list + // changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + [filtered, ignore], + ); + const scoped = useMemo( + () => + ignore.visible ? filterByScope(filtered, scope, isRowExcluded) : filtered, + // eslint-disable-next-line react-hooks/exhaustive-deps + [filtered, scope, ignore], + ); + + const pg = usePagination(scoped, { resetKey: `${scope}|${q}` }); // ----- selection + grace action bar ------------------------------------- // The SAME checkbox action model the learned-signal pages use: a select-all @@ -187,7 +236,7 @@ export function ServicesPage() { () => pg.pageItems.map(([name]) => name), [pg.pageItems], ); - const bulk = useBulkSelection(pageNames, `${q}|${pg.page}`); + const bulk = useBulkSelection(pageNames, `${scope}|${q}|${pg.page}`); const selectedInGrace = bulk.selectedKeys.map( (name) => data?.[name]?.in_grace ?? false, @@ -266,8 +315,8 @@ export function ServicesPage() { renameService.isPending || deleteService.isPending; - // Columns: checkbox + Service + First seen + Origin + Status + Grace. - const cols = 6; + // Columns: checkbox + Service + First seen + Origin + Status + Grace + Action. + const cols = 7; return ( <> @@ -297,6 +346,21 @@ export function ServicesPage() {
+ {ignore.visible && ( + + )}
- Service + Service First seen Origin @@ -365,11 +429,14 @@ export function ServicesPage() { example="'12m30s' means detection starts in about 12 and a half minutes; '—' means the service is already being detected on." /> + + Action + {isLoading && } - {!isLoading && !isError && filtered.length === 0 && ( + {!isLoading && !isError && scoped.length === 0 && ( {entries.length === 0 ? ( @@ -377,6 +444,11 @@ export function ServicesPage() { title="No services discovered yet." hint="Service detection runs on every signal that matches `agent.service_patterns`." /> + ) : scope === "ignored" ? ( + ) : ( - {isUnknown ? ( - name - ) : ( - - {name} - - )} + {name} {isUnknown && ( fallback @@ -449,6 +512,23 @@ export function ServicesPage() { info.grace_seconds_remaining, )} + +
+ {isUnknown ? ( + + ) : ( + + )} +
+ ); })} @@ -458,64 +538,6 @@ export function ServicesPage() {
)} - - {/* Ignored services — the Enterprise Disable-Learn "services" list. - Only a licensed admin sees it (ignore.visible); absent on - community / viewer. */} - {ignore.visible && ( -
-
-

- Ignored services -

- -
-
- {ignore.excludedServices.length === 0 ? ( -
- -
- ) : ( - - - - - - - - - {[...ignore.excludedServices] - .sort((a, b) => a.localeCompare(b)) - .map((name) => ( - - - - - ))} - -
Service - Actions -
{name} - -
- )} -
-
- )}
{renameTarget && ( @@ -580,6 +602,77 @@ export function ServicesPage() { onClose={() => setConfirmClear(false)} /> )} + + {peekName && peekInfo && ( + setPeekName(null)} + title={{peekName}} + footer={ + setPeekName(null)} + > + Open full page ↗ + + } + > +
+
+ {peekInfo.manual ? ( + Manual + ) : ( + Auto + )} + {peekInfo.in_grace ? ( + in grace + ) : ( + tracked + )} +
+ +
+ + + {fmtRel(peekInfo.first_seen)} + + + + {graceRemainingLabel( + peekInfo.in_grace, + peekInfo.grace_seconds_remaining, + )} + + + {peekDetail.isLoading ? ( + + ) : ( + + {peekDetail.data?.counts.patterns ?? "—"} + + )} + + + {peekDetail.isLoading ? ( + + ) : ( + + {peekDetail.data?.counts.incidents ?? "—"} + + )} + +
+ + {peekDetail.isError && ( +

+ Couldn't load pattern and incident counts — open the full page + for details. +

+ )} +
+
+ )} ); } diff --git a/ui/src/pages/SettingsPage.tsx b/ui/src/pages/SettingsPage.tsx index 1c9c8ca..3824f79 100644 --- a/ui/src/pages/SettingsPage.tsx +++ b/ui/src/pages/SettingsPage.tsx @@ -2,18 +2,25 @@ import { useSearchParams } from "react-router-dom"; import { TopBar } from "@/components/TopBar"; import { SegmentedControl } from "@/components/SegmentedControl"; import { ReportSettingsControl } from "@/components/ReportSettingsControl"; +import { SpikeSettingsControl } from "@/components/SpikeSettingsControl"; import { IncidentsConfigPanel } from "./IncidentsConfigPage"; import { AgentConfigPanel } from "./AgentConfigPage"; -// SettingsPage — the Manage-zone home for the read-only configuration -// views. Incidents and Agent config are URL-synced tabs -// (?tab=incidents|agent) so each view is deep-linkable; the legacy -// /config/incidents and /config/agent routes redirect here. Both panels -// keep their SecretBanner: secrets never reach the browser, only their -// presence is shown. +// SettingsPage — the Manage-zone home for the configuration views, grouped by +// intent into URL-synced tabs (?tab=alerting|agent|tuning) so each view is +// deep-linkable: +// • Alerting — how the agent alerts: the incident delivery / on-call config. +// • Agent — the AI runtime configuration. +// • Detection & reports — the two editable runtime knobs: the spike-detector +// baseline mode and the periodic incident report. +// The legacy /config/incidents route redirects to the default (Alerting) tab +// and /config/agent to ?tab=agent. Every panel keeps its SecretBanner: secrets +// never reach the browser, only their presence is shown. export function SettingsPage() { const [params] = useSearchParams(); - const tab = params.get("tab") === "agent" ? "agent" : "incidents"; + const raw = params.get("tab"); + const tab = + raw === "agent" ? "agent" : raw === "tuning" ? "tuning" : "alerting"; return ( <> @@ -25,21 +32,28 @@ export function SettingsPage() {
- {tab === "incidents" ? ( + {tab === "alerting" ? (
- +
+ ) : tab === "agent" ? ( +
+
) : ( - +
+ + +
)} diff --git a/ui/src/pages/ShadowDetailPage.tsx b/ui/src/pages/ShadowDetailPage.tsx index 7be7a12..a7f7fdd 100644 --- a/ui/src/pages/ShadowDetailPage.tsx +++ b/ui/src/pages/ShadowDetailPage.tsx @@ -151,10 +151,10 @@ export function ShadowDetailPage() { } /> - {pattern.data.baseline_frequency.toFixed(2)} + {pattern.data.baseline_frequency.toFixed(2)}/s } /> diff --git a/ui/tsconfig.app.tsbuildinfo b/ui/tsconfig.app.tsbuildinfo index 36e98be..d355742 100644 --- a/ui/tsconfig.app.tsbuildinfo +++ b/ui/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/adminaccessnotice.tsx","./src/components/adminmemberscontrol.tsx","./src/components/agentaisettingscontrol.tsx","./src/components/agentchannelssettingscontrol.tsx","./src/components/agentmodecontrol.tsx","./src/components/agentssoconnectionscontrol.tsx","./src/components/analysiscard.tsx","./src/components/appshell.tsx","./src/components/assigndialog.tsx","./src/components/autorefreshcontrol.tsx","./src/components/bulkactionbar.tsx","./src/components/channelicon.tsx","./src/components/confirmdialog.tsx","./src/components/datatable.tsx","./src/components/dropdown.tsx","./src/components/enterpriselocked.tsx","./src/components/infohint.tsx","./src/components/kpitile.tsx","./src/components/modal.tsx","./src/components/pageheader.tsx","./src/components/pagination.tsx","./src/components/peekpanel.tsx","./src/components/pill.tsx","./src/components/readinessprogress.tsx","./src/components/reportsettingscontrol.tsx","./src/components/reportsdialog.tsx","./src/components/retryableerror.tsx","./src/components/segmentedcontrol.tsx","./src/components/servicecell.tsx","./src/components/severitybadge.tsx","./src/components/shortcutoverlay.tsx","./src/components/sidebar.tsx","./src/components/skeleton.tsx","./src/components/sparkline.tsx","./src/components/textinputmodal.tsx","./src/components/toast.tsx","./src/components/topbar.tsx","./src/components/feedback.tsx","./src/components/shellcontext.ts","./src/components/toastcontext.ts","./src/lib/agentai.ts","./src/lib/agentchannels.ts","./src/lib/api.ts","./src/lib/auth.tsx","./src/lib/bulkselect.ts","./src/lib/format.ts","./src/lib/hooks.ts","./src/lib/incidentlist.ts","./src/lib/learnexclude.ts","./src/lib/localadmin.ts","./src/lib/pagination.ts","./src/lib/readiness.ts","./src/lib/reportanalytics.ts","./src/lib/role.ts","./src/lib/rowactions.ts","./src/lib/servicegrace.ts","./src/lib/serviceoverride.ts","./src/lib/severity.ts","./src/lib/signinsurface.ts","./src/lib/sloadvisor.ts","./src/lib/spikerows.ts","./src/lib/ssoconfig.ts","./src/lib/systempromptnav.ts","./src/lib/theme.ts","./src/lib/useautorefresh.ts","./src/lib/usebulkselection.ts","./src/lib/usedeploymentorg.ts","./src/lib/useeffectiverole.ts","./src/lib/uselearnexclusions.ts","./src/pages/adminpage.tsx","./src/pages/agentconfigpage.tsx","./src/pages/agentoverviewpage.tsx","./src/pages/analyseslistpage.tsx","./src/pages/analysisdetailpage.tsx","./src/pages/decisionspage.tsx","./src/pages/detectdetailpage.tsx","./src/pages/incidentdetailpage.tsx","./src/pages/incidentsconfigpage.tsx","./src/pages/incidentspage.tsx","./src/pages/learnedsignalsview.tsx","./src/pages/memberspage.tsx","./src/pages/nowpage.tsx","./src/pages/patterndetailpage.tsx","./src/pages/patternspage.tsx","./src/pages/peoplepage.tsx","./src/pages/runbookspage.tsx","./src/pages/slorecommendationspage.tsx","./src/pages/servicedetailpage.tsx","./src/pages/servicespage.tsx","./src/pages/settingspage.tsx","./src/pages/shadowdetailpage.tsx","./src/pages/systempromptpage.tsx","./src/pages/teamspage.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/adminaccessnotice.tsx","./src/components/adminmemberscontrol.tsx","./src/components/agentaisettingscontrol.tsx","./src/components/agentchannelssettingscontrol.tsx","./src/components/agentmodecontrol.tsx","./src/components/agentssoconnectionscontrol.tsx","./src/components/analysiscard.tsx","./src/components/appshell.tsx","./src/components/assigndialog.tsx","./src/components/autorefreshcontrol.tsx","./src/components/bulkactionbar.tsx","./src/components/channelicon.tsx","./src/components/confirmdialog.tsx","./src/components/datatable.tsx","./src/components/dropdown.tsx","./src/components/enterpriselocked.tsx","./src/components/infohint.tsx","./src/components/kpitile.tsx","./src/components/modal.tsx","./src/components/pageheader.tsx","./src/components/pagination.tsx","./src/components/patternbaselines.tsx","./src/components/peekpanel.tsx","./src/components/pill.tsx","./src/components/readinessprogress.tsx","./src/components/reportsettingscontrol.tsx","./src/components/reportsdialog.tsx","./src/components/retryableerror.tsx","./src/components/segmentedcontrol.tsx","./src/components/servicecell.tsx","./src/components/severitybadge.tsx","./src/components/shortcutoverlay.tsx","./src/components/sidebar.tsx","./src/components/skeleton.tsx","./src/components/sparkline.tsx","./src/components/spikesettingscontrol.tsx","./src/components/textinputmodal.tsx","./src/components/toast.tsx","./src/components/topbar.tsx","./src/components/feedback.tsx","./src/components/shellcontext.ts","./src/components/toastcontext.ts","./src/lib/agentai.ts","./src/lib/agentchannels.ts","./src/lib/api.ts","./src/lib/auth.tsx","./src/lib/bulkselect.ts","./src/lib/format.ts","./src/lib/hooks.ts","./src/lib/incidentlist.ts","./src/lib/learnexclude.ts","./src/lib/localadmin.ts","./src/lib/pagination.ts","./src/lib/readiness.ts","./src/lib/reportanalytics.ts","./src/lib/role.ts","./src/lib/rowactions.ts","./src/lib/servicegrace.ts","./src/lib/serviceoverride.ts","./src/lib/severity.ts","./src/lib/signinsurface.ts","./src/lib/sloadvisor.ts","./src/lib/spikerows.ts","./src/lib/ssoconfig.ts","./src/lib/systempromptnav.ts","./src/lib/theme.ts","./src/lib/useautorefresh.ts","./src/lib/usebulkselection.ts","./src/lib/usedeploymentorg.ts","./src/lib/useeffectiverole.ts","./src/lib/uselearnexclusions.ts","./src/pages/adminpage.tsx","./src/pages/agentconfigpage.tsx","./src/pages/agentoverviewpage.tsx","./src/pages/analyseslistpage.tsx","./src/pages/analysisdetailpage.tsx","./src/pages/decisionspage.tsx","./src/pages/detectdetailpage.tsx","./src/pages/incidentdetailpage.tsx","./src/pages/incidentsconfigpage.tsx","./src/pages/incidentspage.tsx","./src/pages/learnedsignalsview.tsx","./src/pages/memberspage.tsx","./src/pages/nowpage.tsx","./src/pages/patterndetailpage.tsx","./src/pages/patternspage.tsx","./src/pages/peoplepage.tsx","./src/pages/runbookspage.tsx","./src/pages/slorecommendationspage.tsx","./src/pages/servicedetailpage.tsx","./src/pages/servicespage.tsx","./src/pages/settingspage.tsx","./src/pages/shadowdetailpage.tsx","./src/pages/systempromptpage.tsx","./src/pages/teamspage.tsx"],"version":"5.9.3"} \ No newline at end of file From 1759ce7632641018a09c92ea9404cf99967e31aa Mon Sep 17 00:00:00 2001 From: Quan Huynh Date: Tue, 7 Jul 2026 15:33:07 +0700 Subject: [PATCH 2/2] deps: fix dependabot[bot] --- Dockerfile | 4 ++-- go.mod | 6 +++--- go.sum | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2f5a892..1c8a90c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # --- UI build stage: static JS/CSS is platform-independent --- -FROM --platform=$BUILDPLATFORM node:20-alpine AS ui-build +FROM --platform=$BUILDPLATFORM node:26-alpine AS ui-build WORKDIR /ui COPY ui/package.json ui/package-lock.json* ./ RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi @@ -27,7 +27,7 @@ COPY --from=ui-build /ui/dist ./ui/dist RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -installsuffix cgo -o /build/run cmd/main.go # --- Final image --- -FROM alpine:3.21 +FROM alpine:3.24 RUN apk add --no-cache ca-certificates tzdata WORKDIR /app COPY --from=build /build/config config diff --git a/go.mod b/go.mod index d176875..505857a 100644 --- a/go.mod +++ b/go.mod @@ -17,13 +17,13 @@ require ( github.com/cloudwego/eino-ext/components/model/qwen v0.1.9 github.com/go-git/go-git/v5 v5.19.1 github.com/go-redis/redis/v8 v8.11.5 - github.com/gofiber/fiber/v2 v2.52.13 + github.com/gofiber/fiber/v2 v2.52.14 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 github.com/slack-go/slack v0.27.0 github.com/spf13/viper v1.21.0 - golang.org/x/image v0.39.0 - golang.org/x/time v0.6.0 + golang.org/x/image v0.43.0 + golang.org/x/time v0.15.0 google.golang.org/genai v1.62.0 ) diff --git a/go.sum b/go.sum index 0373997..7da62f1 100644 --- a/go.sum +++ b/go.sum @@ -166,8 +166,8 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk= -github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gofiber/fiber/v2 v2.52.14 h1:Of3L+9qVFaQNwPlcmEdl5IIodHz8BSE0j37R7rWu4pE= +github.com/gofiber/fiber/v2 v2.52.14/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -386,8 +386,8 @@ golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGb golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= -golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= -golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -433,8 +433,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= -golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=