From bae2b513826707e0f33e1aa6691fc28d38dbda27 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 12:25:23 +0100 Subject: [PATCH 1/2] fix(alerts): metric search, few-sample blind spots, headroom and silences on main - Alert editor (Metrics): load the metric list and a picked metric's keys through the dashboard's loaders, now shared in the query-builder kit; drop root_only when leaving traces (dashboard widget too); show the metric name, not name::type. - Hub: a rule skipped for too few samples is a "Few samples" blind spot, not "no data"; skipped values stay out of Headroom; Inbox and Rules share one near-firing test. - Silences: a cancelled silence is badged "Cancelled"; the duration control wraps on mobile; Headroom rows drop the bar below sm. - /lab/alerts-hub over fixtures for every hub state. --- apps/web/src/lab/alerts-hub-lab.ts | 211 ++++++++++++++++++ apps/web/src/lab/entries/alerts-hub.ts | 16 ++ apps/web/src/lab/registry.ts | 8 + apps/web/src/page/alerts/editor/command.ts | 26 +++ .../editor/editor-metrics.scene.test.ts | 185 +++++++++++++++ apps/web/src/page/alerts/editor/init.ts | 5 + apps/web/src/page/alerts/editor/message.ts | 10 + apps/web/src/page/alerts/editor/model.ts | 10 + apps/web/src/page/alerts/editor/plan.ts | 28 ++- apps/web/src/page/alerts/editor/steps.ts | 7 +- apps/web/src/page/alerts/editor/sub-steps.ts | 17 +- apps/web/src/page/alerts/editor/update.ts | 20 ++ apps/web/src/page/alerts/editor/view-query.ts | 20 +- .../src/page/alerts/editor/where-inputs.ts | 37 ++- .../alerts/hub/hub-silences.scene.test.ts | 3 + apps/web/src/page/alerts/hub/update.ts | 4 +- apps/web/src/page/alerts/hub/view-inbox.ts | 141 ++++++------ apps/web/src/page/alerts/hub/view-rules.ts | 17 +- apps/web/src/page/alerts/hub/view-silences.ts | 12 +- apps/web/src/page/alerts/shared/rule-chart.ts | 21 +- apps/web/src/page/alerts/shared/silences.ts | 4 + .../src/page/alerts/silence-dialog/view.ts | 2 +- apps/web/src/page/dashboards/widget/edits.ts | 6 +- .../web/src/page/dashboards/widget/message.ts | 2 +- .../page/dashboards/widget/metric-option.ts | 15 -- .../page/dashboards/widget/metric-options.ts | 103 ++------- apps/web/src/page/dashboards/widget/model.ts | 2 +- apps/web/src/page/dashboards/widget/step.ts | 3 +- .../src/page/kit/query-builder/change.test.ts | 18 ++ apps/web/src/page/kit/query-builder/change.ts | 18 +- .../query-builder}/metric-options.test.ts | 2 +- .../page/kit/query-builder/metric-options.ts | 114 ++++++++++ .../page/kit/query-builder/metrics-body.ts | 37 ++- .../page/kit/query-builder/panel-message.ts | 2 +- apps/web/src/page/kit/query-builder/view.ts | 3 +- packages/views/src/ui/INDEX.md | 1 + packages/web-data/src/alerts/headroom.test.ts | 9 + packages/web-data/src/alerts/headroom.ts | 22 +- .../src/alerts/overview-derive.test.ts | 13 ++ .../web-data/src/alerts/overview-derive.ts | 21 +- .../web-data/src/alerts/rule-status.test.ts | 36 +++ packages/web-data/src/alerts/rule-status.ts | 36 ++- 42 files changed, 1029 insertions(+), 238 deletions(-) create mode 100644 apps/web/src/lab/alerts-hub-lab.ts create mode 100644 apps/web/src/lab/entries/alerts-hub.ts create mode 100644 apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts delete mode 100644 apps/web/src/page/dashboards/widget/metric-option.ts create mode 100644 apps/web/src/page/kit/query-builder/change.test.ts rename apps/web/src/page/{dashboards/widget => kit/query-builder}/metric-options.test.ts (97%) create mode 100644 apps/web/src/page/kit/query-builder/metric-options.ts diff --git a/apps/web/src/lab/alerts-hub-lab.ts b/apps/web/src/lab/alerts-hub-lab.ts new file mode 100644 index 0000000000..b8c92d6d45 --- /dev/null +++ b/apps/web/src/lab/alerts-hub-lab.ts @@ -0,0 +1,211 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { AlertSilenceId } from "@maple/domain/http" +import type { V2AlertSilence } from "@maple/domain/http/v2" +import { Effect, Option, Result, Schema, Array as Arr } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import * as HubMessage from "../page/alerts/hub/message" +import * as HubModel from "../page/alerts/hub/model" +import * as HubUpdate from "../page/alerts/hub/update" +import { view as hubView } from "../page/alerts/hub/view" +import * as SilenceDialog from "../page/alerts/silence-dialog/dialog" +import { destinationRow, NOW, ORG, ruleRow, stateRow } from "../page/alerts/shared/test-rows" +import type { PageContext } from "../page/contract" +import type { Resources } from "../platform/resources" + +// `/lab/alerts-hub`: the hub's Inbox, Rules and Silences tabs and the silence dialog over +// fixtures, including a rule skipped for too few samples and a cancelled silence. `?state=` picks +// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom. + +export const LabState = Schema.Literals([ + "inbox", + "rules", + "loading", + "error", + "silences", + "silence-dialog", + "silence-dialog-custom", +]) +export type LabState = typeof LabState.Type + +export const Flags = Schema.Struct({ state: LabState }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.sync(() => ({ + state: Option.getOrElse( + Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), + (): LabState => "inbox", + ), +})) + +export const Model = Schema.Struct({ state: LabState, hub: HubModel.Model }) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ GotHubMessage: { message: HubMessage.Message } }) +export type Message = typeof Message.Type + +type Return = Update.Return + +const CONTEXT: PageContext = { + orgId: Schema.decodeSync(OrgId)(ORG), + pinnedNamespace: null, + timeZone: "UTC", +} +const GRANTS = Policy.grantsOf(["org:engineer"]) + +/** The hub's reads and writes are dropped: the fixture is all the data there is. */ +const keepLocal = (commands: ReadonlyArray> | undefined) => + Arr.filter(commands ?? [], (command) => !/^(Fetch|Toggle|Delete|Save|Read|End)/.test(command.name)) + +const MINUTE = 60_000 +const iso = (ms: number) => new Date(ms).toISOString() + +const RULES = [ + // Over its threshold on 4 of the 20 samples it needs: every window is skipped and can't fire. + ruleRow(1, { + name: "Checkout error rate", + signal_type: "error_rate", + threshold: 0.05, + minimum_sample_count: 20, + service_names_json: ["checkout"], + }), + ruleRow(2, { name: "Payments p95 latency", threshold: 800 }), + ruleRow(3, { + name: "Search apdex", + signal_type: "apdex", + comparator: "lt", + threshold: 0.9, + apdex_threshold_ms: 500, + service_names_json: ["search"], + }), + ruleRow(4, { name: "Batch export throughput", signal_type: "throughput", threshold: 5000 }), + // A floor with a quarter of its threshold to spare: healthy, so not drawn as near firing. + ruleRow(5, { + name: "Orders throughput floor", + signal_type: "throughput", + comparator: "lt", + threshold: 500, + service_names_json: ["orders"], + }), +] + +const STATES = [ + stateRow(1, { last_status: "skipped", last_value: 0.063, last_sample_count: 4, consecutive_healthy: 0 }), + stateRow(2, { last_value: 650 }), + stateRow(3, { last_value: 0.97 }), + stateRow(4, { last_status: "skipped", last_value: null, last_sample_count: 0, consecutive_healthy: 0 }), + stateRow(5, { last_value: 630 }), +] + +const silence = ( + id: string, + reason: string, + startMin: number, + endMin: number, + matchers: V2AlertSilence["matchers"], +): V2AlertSilence => ({ + id: Schema.decodeSync(AlertSilenceId)(id), + object: "alert_silence", + reason, + matchers, + status: endMin <= 0 ? "ended" : startMin > 0 ? "scheduled" : "active", + starts_at: iso(NOW + startMin * MINUTE), + ends_at: iso(NOW + endMin * MINUTE), + maintenance_window_id: null, + created_by: "user_ada", + created_at: iso(NOW - 3 * 60 * MINUTE), + updated_at: iso(NOW), +}) + +const SILENCES = [ + silence("11111111-1111-4111-8111-111111111111", "Deploying checkout v2.3", -20, 40, { + services: ["checkout"], + }), + silence("22222222-2222-4222-8222-222222222222", "Database migration", 180, 240, { tags: ["db"] }), + // Cancelled before it started: the API collapses its window to one instant. + silence("33333333-3333-4333-8333-333333333333", "Load test on staging", -45, -45, { + environments: ["staging"], + }), + silence("44444444-4444-4444-8444-444444444444", "Payments provider outage", -300, -120, { + services: ["payments"], + }), +] + +const tabOf = (state: LabState) => + state === "rules" + ? "rules" + : state === "inbox" || state === "loading" || state === "error" + ? "inbox" + : "silences" + +const step = (model: HubModel.Model, message: HubMessage.Message) => HubUpdate.update(model, message, CONTEXT) + +const hubFor = (state: LabState): HubModel.Model => { + const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: tabOf(state) } }, CONTEXT).model + const base = [ + HubMessage.Message.ReadClock({ nowMs: NOW }), + HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), + ] + const data = + state === "loading" + ? [] + : state === "error" + ? [HubMessage.Message.FailedLoadAlerts({ orgId: ORG })] + : [ + HubMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }), + HubMessage.Message.UpdatedRuleStates({ orgId: ORG, rows: STATES }), + HubMessage.Message.UpdatedIncidents({ orgId: ORG, rows: [] }), + HubMessage.Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), + HubMessage.Message.CompletedFetchSilences({ + key: entered.silences.key ?? "", + result: Result.succeed(SILENCES), + }), + ] + return Arr.reduce([...base, ...data], entered, (model, message) => step(model, message).model) +} + +const toLab = ( + next: Readonly<{ commands?: ReadonlyArray> }>, +) => Command.mapMessages(keepLocal(next.commands), (message) => Message.GotHubMessage({ message })) + +export const init = (flags: Flags): Return => { + const hub = hubFor(flags.state) + if (flags.state !== "silence-dialog" && flags.state !== "silence-dialog-custom") + return { model: { state: flags.state, hub } } + // The dialog opens through its own commands, so they run here rather than in the fold. + const opened = step(hub, HubMessage.Message.ClickedNewSilence()) + const picked = + flags.state === "silence-dialog-custom" + ? step( + opened.model, + HubMessage.Message.GotSilenceDialogMessage({ + message: SilenceDialog.Message.ChangedDuration({ duration: "custom" }), + }), + ) + : { model: opened.model, commands: [] } + return { + model: { state: flags.state, hub: picked.model }, + commands: [...toLab(opened), ...toLab(picked)], + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotHubMessage: ({ message: child }) => { + const next = step(model.hub, child) + return { model: { ...model, hub: next.model }, commands: toLab(next) } + }, + }) + +export const breadcrumbs = () => [{ label: "Alerts" }] + +export const view = (model: Model, h: HtmlBuilder): Html => + h.submodel({ + slotId: "lab-alerts-hub", + model: model.hub, + view: hubView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: HubMessage.Message) => Message.GotHubMessage({ message }), + }) diff --git a/apps/web/src/lab/entries/alerts-hub.ts b/apps/web/src/lab/entries/alerts-hub.ts new file mode 100644 index 0000000000..de2a9f57ed --- /dev/null +++ b/apps/web/src/lab/entries/alerts-hub.ts @@ -0,0 +1,16 @@ +import { defineShellLab } from "../shell-lab" +import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../alerts-hub-lab" + +// `/lab/alerts-hub`: the hub tabs and silence dialog over fixtures (`?state=`). + +export const lab = defineShellLab("Alerts hub", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/alerts-hub", + breadcrumbs, +}) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 3e6933419a..6f6cdb62b0 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -53,6 +53,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/alerts-hub", + title: "Alerts hub", + description: + "The alerts Inbox, Rules and Silences tabs loading, failing and populated, with a rule skipped for too few samples and a cancelled silence, and the silence dialog (`?state=`).", + kind: "lab", + session: "none", + }, { path: "/lab/slos", title: "SLOs", diff --git a/apps/web/src/page/alerts/editor/command.ts b/apps/web/src/page/alerts/editor/command.ts index 7fdc661fc5..11c2a79d0a 100644 --- a/apps/web/src/page/alerts/editor/command.ts +++ b/apps/web/src/page/alerts/editor/command.ts @@ -1,4 +1,5 @@ import { AlertRuleId } from "@maple/domain/http" +import { QUERY_BUILDER_METRIC_TYPES } from "@maple/query-model" import { V2AlertRuleCreateParams, V2AlertRulePreviewParams, @@ -14,6 +15,7 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import { TimeWindow } from "../../../data/time-range" import { MapleApiV2Client } from "../../../platform/api-clients" import { AutocompleteLookup, autocompleteLookup } from "../../kit/autocomplete-lookups" +import { listMetricOptions, metricValueOf, readMetricKeys } from "../../kit/query-builder/metric-options" import { trackProduct } from "../../../data/analytics" import { readGrants } from "../../../data/session-tenant" import { fetchPreview } from "../shared/preview" @@ -192,3 +194,27 @@ export const SaveRule = Command.define("SaveAlertRule", { Effect.map((result) => Message.CompletedSave({ result })), ), }) + +/** The metric picker's page for what was searched (`listMetrics`). */ +export const FetchEditorMetricOptions = Command.define("FetchAlertEditorMetricOptions", { + args: { search: Schema.String }, + messages: [Message.CompletedFetchMetricOptions], + execute: ({ search }) => + listMetricOptions(search).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchMetricOptions({ search, result })), + ), +}) + +/** The picked metric's own keys, the group-by picker's choices. */ +export const FetchEditorMetricKeys = Command.define("FetchAlertEditorMetricKeys", { + args: { metricName: Schema.String, metricType: Schema.Literals(QUERY_BUILDER_METRIC_TYPES) }, + messages: [Message.CompletedFetchMetricKeys], + execute: (metric) => + readMetricKeys(metric).pipe( + Effect.result, + Effect.map((result) => + Message.CompletedFetchMetricKeys({ metric: metricValueOf(metric), result }), + ), + ), +}) diff --git a/apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts b/apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts new file mode 100644 index 0000000000..1e2a948987 --- /dev/null +++ b/apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts @@ -0,0 +1,185 @@ +import { OrgId } from "@maple/domain" +import { Result, Schema, Array as Arr } from "effect" +import { expect as expectView, given, Mount, placeholder, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { toLoadFailure } from "@maple/views/data/load-failure" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import * as Combobox from "@maple/views/ui/combobox" +import * as Dialog from "@maple/views/ui/dialog" +import { CaretMessage } from "../../kit/where-autocomplete" +import { WhereFieldMessage } from "../../kit/where-field" +import { MountWhereFieldCaret } from "../../kit/where-field-command" +import type { PageContext } from "../../contract" +import * as QueryPanel from "../../kit/query-builder/panel" +import { NOW, ORG } from "../shared/test-rows" +import { init } from "./init" +import { Message } from "./message" +import type { Model } from "./model" +import { update } from "./update" +import { view as pageView } from "./view" + +// The alert query builder's metrics source: the picker's list, the group-by keys, the +// picked metric's display, and the traces-only `root_only` filter on a source switch. + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const view = withViewInputs(pageView, { context })() +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const DRAFT_ID = "6f1c2e3a-0000-4000-8000-000000000001" +/** The page's mounts: the theme, the closed templates dialog and the where editor's caret. */ +const mounted = () => [ + Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), + Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()), + // Resolved with the query panel's message: the page's submodel wraps it. + Mount.resolve( + MountWhereFieldCaret, + QueryPanel.Message.GotWhereField({ + message: WhereFieldMessage.GotCaret({ fieldId: DRAFT_ID, message: CaretMessage.BlurredEditor() }), + }), + ), +] +const comboMounted = () => Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()) + +type Step = Readonly<{ model: Model; names: ReadonlyArray }> +const step = (model: Model, message: Message): Step => { + const next = update(model, message, context) + return { model: next.model, names: Arr.map(next.commands ?? [], ({ name }) => name) } +} +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => step(current, message).model) + +const panel = (message: QueryPanel.Message) => Message.GotQueryPanelMessage({ message }) +const pickSelect = (field: string, value: string) => + panel( + QueryPanel.Message.GotSelectMessage({ + id: `${field}:${DRAFT_ID}`, + message: { _tag: "SelectedItem", index: 0, value }, + }), + ) +const metricPicker = (message: Combobox.Message) => + panel(QueryPanel.Message.GotMetricMessage({ queryId: DRAFT_ID, value: "", message })) + +/** A blank new rule in the query builder, on its default traces draft. */ +const blank = () => + run(init({ _tag: "AlertsCreate", search: {} }, context).model, [ + Message.GeneratedDraftId({ id: DRAFT_ID }), + Message.CompletedReadClock({ nowMs: NOW }), + Message.ClickedStartBlank(), + ]) + +const PAGE = { + options: [ + { value: "shop.orders.created::sum", label: "shop.orders.created (sum)", isMonotonic: true }, + { + value: "process.cpu.utilization::gauge", + label: "process.cpu.utilization (gauge)", + isMonotonic: false, + }, + ], + services: ["checkout"], +} + +const onMetrics = () => { + const switched = step(blank(), pickSelect("source", "metrics")) + return { + ...switched, + model: run(switched.model, [ + Message.CompletedFetchMetricOptions({ search: "", result: Result.succeed(PAGE) }), + ]), + } +} + +const picked = () => { + const chosen = step( + onMetrics().model, + metricPicker({ _tag: "ClickedItem", value: "shop.orders.created::sum" }), + ) + return { + ...chosen, + model: run(chosen.model, [ + Message.CompletedFetchMetricKeys({ + metric: "shop.orders.created::sum", + result: Result.succeed({ attributes: ["http.route"], resources: ["host.name"] }), + }), + ]), + } +} + +describe("alert editor metrics query", () => { + test("switching to Metrics lists the org's metrics, and typing searches them", () => { + const switched = step(blank(), pickSelect("source", "metrics")) + expect(switched.names).toContain("FetchAlertEditorMetricOptions") + const searched = step(onMetrics().model, metricPicker({ _tag: "ChangedInput", value: "cpu" })) + expect(searched.names).toContain("FetchAlertEditorMetricOptions") + expect(searched.model.metricSearch).toBe("cpu") + const opened = run(onMetrics().model, [metricPicker({ _tag: "ClickedInput" })]) + scene( + { update: pageUpdate, view }, + given(opened), + ...mounted(), + comboMounted(), + expectView(text("shop.orders.created (sum)")).toExist(), + expectView(text("process.cpu.utilization (gauge)")).toExist(), + ) + }) + + test("a failed metric list is asked for again on the next edit", () => { + const failed = run(step(blank(), pickSelect("source", "metrics")).model, [ + Message.CompletedFetchMetricOptions({ search: "", result: Result.fail(toLoadFailure("down")) }), + ]) + const edited = step(failed, Message.ChangedField({ field: "name", value: "Orders" })) + expect(edited.names).toContain("FetchAlertEditorMetricOptions") + }) + + test("root spans only is a traces filter: switching to Metrics drops it and keeps the rest", () => { + const rooted = run(blank(), [ + panel( + QueryPanel.Message.ChangedText({ + queryId: DRAFT_ID, + field: "whereClause", + value: 'service.name = "api"', + }), + ), + pickSelect("spans", "root"), + ]) + expect(rooted.form?.queryBuilderDraft.whereClause).toBe('service.name = "api" AND root_only = true') + const switched = run(rooted, [pickSelect("source", "metrics")]) + expect(switched.form?.queryBuilderDraft.whereClause).toBe('service.name = "api"') + }) + + test("a picked metric sets the draft and reads its keys, which the group-by picker offers", () => { + const chosen = picked() + expect(chosen.names).toContain("FetchAlertEditorMetricKeys") + const draft = chosen.model.form?.queryBuilderDraft + expect(draft?.dataSource === "metrics" ? [draft.metricName, draft.metricType] : null).toEqual([ + "shop.orders.created", + "sum", + ]) + const opened = run(chosen.model, [ + panel( + QueryPanel.Message.GotGroupByMessage({ + queryId: DRAFT_ID, + message: { _tag: "ClickedInput" }, + }), + ), + ]) + scene( + { update: pageUpdate, view }, + given(opened), + ...mounted(), + comboMounted(), + expectView(text("attr.http.route")).toExist(), + expectView(text("resource.host.name")).toExist(), + ) + }) + + test("the picked metric reads as its name, with its type beside it, never name::type", () => { + scene( + { update: pageUpdate, view }, + given(picked().model), + ...mounted(), + expectView(placeholder("Search metrics…")).toHaveValue("shop.orders.created"), + expectView(text("sum")).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/editor/init.ts b/apps/web/src/page/alerts/editor/init.ts index 2b03c68552..c1f6d82704 100644 --- a/apps/web/src/page/alerts/editor/init.ts +++ b/apps/web/src/page/alerts/editor/init.ts @@ -54,6 +54,11 @@ export const init = (route: CreateRoute, _context: PageContext): PageReturn ) } +/** While the draft reads metrics: the picker's page once per search, and the picked metric's keys once. */ +export const planMetrics = (model: Model): Planned => { + const draft = model.form?.queryBuilderDraft + if (draft === undefined || draft.dataSource !== "metrics") return none(model) + const list = + model.metricRequested === model.metricSearch + ? [] + : [FetchEditorMetricOptions({ search: model.metricSearch })] + const missing = unaskedMetrics([draft], model.metricKeysRequested) + return { + model: { + ...model, + metricRequested: model.metricSearch, + metricKeysRequested: [...model.metricKeysRequested, ...Arr.map(missing, metricValueOf)], + }, + commands: [...list, ...Arr.map(missing, (metric) => FetchEditorMetricKeys(metric))], + } +} + /** Whether the saved rule evaluates differently from the draft (`evaluationChanged`). */ export const isEvaluationChanged = (model: Pick): boolean => model.savedForm !== null && diff --git a/apps/web/src/page/alerts/editor/steps.ts b/apps/web/src/page/alerts/editor/steps.ts index 2705a16793..fee14a8651 100644 --- a/apps/web/src/page/alerts/editor/steps.ts +++ b/apps/web/src/page/alerts/editor/steps.ts @@ -5,15 +5,16 @@ import { initialization } from "./draft" import type { RuleForm } from "./form" import { Message } from "./message" import type { Model } from "./model" -import { applyDefaultDestination, planPreviews } from "./plan" +import { applyDefaultDestination, planMetrics, planPreviews } from "./plan" import { String as Str } from "effect" export type Return = PageReturn -/** The planned reads of a step, as a page return. */ +/** The planned reads of a step, as a page return: the previews, and the metric picker's lists. */ const planned = (model: Model, context: PageContext): Return => { const step = planPreviews(model, context) - return { model: step.model, commands: step.commands } + const metrics = planMetrics(step.model) + return { model: metrics.model, commands: [...step.commands, ...metrics.commands] } } export const liftTemplates = (model: Model, step: ReturnType): Return => ({ diff --git a/apps/web/src/page/alerts/editor/sub-steps.ts b/apps/web/src/page/alerts/editor/sub-steps.ts index bd19e02c0e..75eba25e79 100644 --- a/apps/web/src/page/alerts/editor/sub-steps.ts +++ b/apps/web/src/page/alerts/editor/sub-steps.ts @@ -8,6 +8,7 @@ import * as RadioGroup from "@maple/views/ui/radio-group" import * as Select from "@maple/views/ui/select" import type { PageContext } from "../../contract" import { applyQueryChange } from "../../kit/query-builder/change" +import { pickedMetric } from "../../kit/query-builder/metric-options" import * as QueryPanel from "../../kit/query-builder/panel" import * as TimeRangePicker from "../../kit/time-range-picker" import { isProtectedDefault } from "./derive" @@ -24,6 +25,7 @@ import { Message } from "./message" import { panelInputsOf } from "./where-inputs" import type { Model } from "./model" import { ReadEditorClock } from "./command" +import { planMetrics } from "./plan" import { editForm, type Return } from "./steps" // The child Submodels the form is built from: selects, chip pickers, the query panel, the chart, @@ -142,10 +144,21 @@ export const queryPanelStep = (model: Model, context: PageContext, message: Quer Message.GotQueryPanelMessage({ message: inner }), ) const out = next.outMessage - if (out === undefined || out._tag !== "ChangedQuery") return { model: lifted, commands } + if (out === undefined) return { model: lifted, commands } + if (out._tag === "ChangedMetricSearch") { + const asked = planMetrics({ ...lifted, metricSearch: out.search }) + return { model: asked.model, commands: [...commands, ...asked.commands] } + } + const change = + out._tag === "ChangedQuery" + ? out.change + : out._tag === "PickedMetric" + ? pickedMetric(out.value, lifted.metricOptions?.options ?? []) + : null + if (change === null) return { model: lifted, commands } const edited = editForm(lifted, context, (form) => ({ ...form, - queryBuilderDraft: applyQueryChange(form.queryBuilderDraft, out.change), + queryBuilderDraft: applyQueryChange(form.queryBuilderDraft, change), })) return withCommands(edited, commands) } diff --git a/apps/web/src/page/alerts/editor/update.ts b/apps/web/src/page/alerts/editor/update.ts index 39ecdf346e..206340027a 100644 --- a/apps/web/src/page/alerts/editor/update.ts +++ b/apps/web/src/page/alerts/editor/update.ts @@ -3,6 +3,7 @@ import { resolveWindow } from "../../../data/time-range" import * as Dialog from "@maple/views/ui/dialog" import * as Tooltip from "@maple/views/ui/tooltip" import { Command } from "foldkit" +import { Array as Arr, Result } from "effect" import type { PageContext } from "../../contract" import * as DestinationDialog from "../destination/dialog" import { completeSave, completeTest, runTest, save } from "./actions" @@ -104,6 +105,25 @@ export const update = (model: Model, message: Message, context: PageContext): Re }, } }, + // A failed read is forgotten, so the next edit or search asks again. + CompletedFetchMetricOptions: ({ search, result }) => + search !== model.metricSearch + ? { model } + : Result.isSuccess(result) + ? { model: { ...model, metricOptions: { search, ...result.success } } } + : { model: { ...model, metricRequested: null } }, + CompletedFetchMetricKeys: ({ metric, result }) => + Result.isSuccess(result) + ? { model: { ...model, metricKeys: { ...model.metricKeys, [metric]: result.success } } } + : { + model: { + ...model, + metricKeysRequested: Arr.filter( + model.metricKeysRequested, + (key) => key !== metric, + ), + }, + }, // Both slots take an answer for their key: the replay can share the draft's first request. CompletedFetchPreview: ({ key, result }) => ({ model: { diff --git a/apps/web/src/page/alerts/editor/view-query.ts b/apps/web/src/page/alerts/editor/view-query.ts index 692ed209ed..e890400d63 100644 --- a/apps/web/src/page/alerts/editor/view-query.ts +++ b/apps/web/src/page/alerts/editor/view-query.ts @@ -16,7 +16,7 @@ import { stableSlice } from "../../kit/stable-slice" import { queryPanelView } from "../../kit/query-builder/view" import { isProtectedDefault } from "./derive" import { spanAttributeKeysOf, traceFacetsOf } from "./derive-rows" -import { baseValuesOf } from "./where-inputs" +import { baseValuesOf, metricKeysOf } from "./where-inputs" import type { RuleForm } from "./form" import type { AlertQueryTiming } from "@maple/domain/http" import { draftTimingOf } from "./derive-rows" @@ -35,7 +35,17 @@ type H = HtmlBuilder /** What the section reads off the Model. */ type QueryView = SqlView & - Pick & { + Pick< + Model, + | "kindNotice" + | "queryPanels" + | "combos" + | "traceFacets" + | "autocomplete" + | "metricSearch" + | "metricOptions" + | "metricKeys" + > & { /** Editing the org's default anomaly rule: its kind and scope are fixed. */ readonly isDefaultAnomaly: boolean } @@ -426,6 +436,9 @@ const builderPanel = (h: H, model: QueryView, form: RuleForm): Html => intervalPlaceholder: "Auto", hasHeaderActions: false, hasVisibilityToggle: false, + metricOptions: model.metricOptions?.options ?? [], + metricSearch: model.metricSearch, + metricBreakdownKeys: metricKeysOf(model, form.queryBuilderDraft), autocompleteValues: baseValuesOf(model, form.queryBuilderDraft), }, toParentMessage: (message) => Message.GotQueryPanelMessage({ message }), @@ -478,6 +491,9 @@ const viewOf = stableSlice((model: Model): QueryView => ({ combos: model.combos, traceFacets: model.traceFacets, autocomplete: model.autocomplete, + metricSearch: model.metricSearch, + metricOptions: model.metricOptions, + metricKeys: model.metricKeys, isSqlCollapsed: model.isSqlCollapsed, reducerSelect: model.selects[REDUCER_SELECT_ID], isDefaultAnomaly: isProtectedDefault(model), diff --git a/apps/web/src/page/alerts/editor/where-inputs.ts b/apps/web/src/page/alerts/editor/where-inputs.ts index 2544b6025b..2bcd549aa9 100644 --- a/apps/web/src/page/alerts/editor/where-inputs.ts +++ b/apps/web/src/page/alerts/editor/where-inputs.ts @@ -5,6 +5,8 @@ import { AsyncData } from "foldkit" import type { PageContext } from "../../contract" import { type AutocompleteLookup, lookupField } from "../../kit/autocomplete-lookups" import type { QueryDraft } from "../../kit/query-builder/draft" +import { metricValueOf } from "../../kit/query-builder/metric-options" +import type { MetricKeys } from "../../kit/query-builder/metrics-body" import type { PanelInputs } from "../../kit/query-builder/panel" import { traceFacetsOf } from "./derive-rows" import type { Model } from "./model" @@ -12,11 +14,27 @@ import type { Model } from "./model" // `AlertQueryPanel`'s `autocompleteValues` (the provider is not lazy here): the where editor's // suggestions per source, over the editor's 24h window. -const field = (model: Pick, lookup: AutocompleteLookup, name: string): Array => [ - ...lookupField(Option.getOrUndefined(AsyncData.getData(model.autocomplete[lookup]?.data ?? AsyncData.Idle())), name), +const field = ( + model: Pick, + lookup: AutocompleteLookup, + name: string, +): Array => [ + ...lookupField( + Option.getOrUndefined(AsyncData.getData(model.autocomplete[lookup]?.data ?? AsyncData.Idle())), + name, + ), ] -export const baseValuesOf = (model: Pick, query: QueryDraft): WhereClauseAutocompleteValues => { +type ValuesModel = Pick + +/** The picked metric's own keys, once read; none for another source or before a metric is picked. */ +export const metricKeysOf = (model: Pick, query: QueryDraft): MetricKeys | undefined => + query.dataSource === "metrics" && query.metricName !== "" + ? model.metricKeys[metricValueOf(query)] + : undefined + +/** `autocompleteValues[query.dataSource]`, with a picked metric's own keys (`useMetricScopedAutocomplete`). */ +export const baseValuesOf = (model: ValuesModel, query: QueryDraft): WhereClauseAutocompleteValues => { const facets = traceFacetsOf(model) const spanKeys = field(model, "spanAttributeKeys", "keys") const resourceKeys = field(model, "resourceAttributeKeys", "keys") @@ -39,7 +57,15 @@ export const baseValuesOf = (model: Pick, resourceAttributeKeys: resourceKeys, } case "metrics": - return { metricTypes: [...QUERY_BUILDER_METRIC_TYPES], attributeKeys: field(model, "metricAttributeKeys", "keys") } + return { + metricTypes: [...QUERY_BUILDER_METRIC_TYPES], + // The org-wide keys stand in until a picked metric's own keys arrive. + attributeKeys: [ + ...(metricKeysOf(model, query)?.attributes ?? + field(model, "metricAttributeKeys", "keys")), + ], + services: [...(model.metricOptions?.services ?? [])], + } case "product_events": return { eventNames: field(model, "productEventNames", "keys"), @@ -49,7 +75,8 @@ export const baseValuesOf = (model: Pick, } export const panelInputsOf = (model: Model, context: PageContext): PanelInputs => ({ - queryOf: (queryId) => (model.form?.queryBuilderDraft.id === queryId ? model.form.queryBuilderDraft : undefined), + queryOf: (queryId) => + model.form?.queryBuilderDraft.id === queryId ? model.form.queryBuilderDraft : undefined, baseValuesOf: (query) => baseValuesOf(model, query), orgId: context.orgId, window: model.autocompleteWindow, diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index 856e643737..6c853b3408 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -189,6 +189,9 @@ describe("alerts hub, Silences tab", () => { ]), ), expectView(text(/^Cancelled /u)).toExist(), + // The row's badge agrees with its window line: a silence that never ran did not end. + expectView(text("Cancelled")).toExist(), + expectView(text("Ended")).not.toExist(), expectView(text(/· 1 rule$/u)).toExist(), ) }) diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 4bc9ba85eb..b6219dc8e4 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -24,7 +24,7 @@ import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" import * as ChainDialog from "../escalation-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" -import { endsAtMs, startsAtMs } from "../shared/silences" +import { isCancelledSilence } from "../shared/silences" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { alertsHref, createHref, ruleHref } from "../shared/links" import { CHAIN_IN_USE_CODE, CHAIN_LIST_LIMIT } from "../shared/escalation-chains" @@ -1136,7 +1136,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re ...refreshSilences(settled, context), outMessage: toast( "success", - startsAtMs(ended) === endsAtMs(ended) ? "Silence cancelled" : "Silence ended", + isCancelledSilence(ended) ? "Silence cancelled" : "Silence ended", ), }), onFailure: (failure): Return => ({ diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index 16d36e87bd..a4e89cbb23 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -47,7 +47,9 @@ const HOUR_MS = 60 * 60 * 1000 const INCIDENT_GRID = "grid grid-cols-[minmax(0,1fr)_150px_90px_120px] items-center gap-4" const MAX_LANES = 8 const MAX_HEADROOM_ROWS = 8 -const HEADROOM_GRID = "grid grid-cols-[minmax(0,220px)_minmax(0,1fr)_130px_44px] items-center gap-4" +// Below `sm` the bar has no room: the row keeps the name, the value and when it was checked. +const HEADROOM_GRID = + "grid grid-cols-[minmax(0,1fr)_auto_44px] items-center gap-4 sm:grid-cols-[minmax(0,220px)_minmax(0,1fr)_130px_44px]" const SECTIONS_GRID = "grid gap-8 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]" type Range = Readonly<{ min: number; max: number }> @@ -208,76 +210,72 @@ const openIncidentRow = ( const isHeld = incident.holdReason !== null const acked = acknowledgedLine(incident) const tone = incident.severity === "critical" ? "text-severity-error" : "text-severity-warn" - return gridListRow(h, { key: incident.id, grid: INCIDENT_GRID }, - [ - h.span([ - h.AriaHidden(true), - h.Class( - cn( - "absolute inset-y-2 left-0 w-0.5 rounded-full", - isHeld || acked !== null - ? "bg-foreground-subtle" - : incident.severity === "critical" - ? "bg-severity-error" - : "bg-severity-warn", - ), + return gridListRow(h, { key: incident.id, grid: INCIDENT_GRID }, [ + h.span([ + h.AriaHidden(true), + h.Class( + cn( + "absolute inset-y-2 left-0 w-0.5 rounded-full", + isHeld || acked !== null + ? "bg-foreground-subtle" + : incident.severity === "critical" + ? "bg-severity-error" + : "bg-severity-warn", ), - ]), - h.div( - [h.Class("min-w-0 space-y-1")], - [ - hstack(h, { gap: "2", shrink: true }, [ - isHeld - ? badge(h, { variant: "outline", className: "text-muted-foreground" }, ["Held"]) - : severityBadge(h, incident.severity), - rowLink( - h, - { - href: incidentHref(incident.id), - className: "min-w-0 truncate font-medium", - attributes: [h.Title(incidentTitle(incident))], - }, - [isHeld ? `${incident.ruleName}: waiting on data` : incidentTitle(incident)], + ), + ]), + h.div( + [h.Class("min-w-0 space-y-1")], + [ + hstack(h, { gap: "2", shrink: true }, [ + isHeld + ? badge(h, { variant: "outline", className: "text-muted-foreground" }, ["Held"]) + : severityBadge(h, incident.severity), + rowLink( + h, + { + href: incidentHref(incident.id), + className: "min-w-0 truncate font-medium", + attributes: [h.Title(incidentTitle(incident))], + }, + [isHeld ? `${incident.ruleName}: waiting on data` : incidentTitle(incident)], + ), + ]), + typography(h, { size: "xs", tone: "muted", truncate: true, as: "p" }, [ + acked === null ? incidentSubline(incident) : `${acked} · ${incidentSubline(incident)}`, + ]), + ], + ), + typography(h, { size: "xs", mono: true, numeric: true }, [ + isHeld + ? h.span([h.Class("text-muted-foreground")], ["no data"]) + : h.span([h.Class(tone)], [incidentValues(incident).now]), + ...(incidentValues(incident).threshold === null + ? [] + : [h.span([h.Class("text-muted-foreground")], [" / ", incidentValues(incident).threshold])]), + ]), + typography(h, { size: "xs", mono: true, numeric: true }, [ + formatAlertDuration(incident.firstTriggeredAt, msIso(now)), + ]), + // Lifted above the row's stretched link, so the click acknowledges instead of navigating. + hstack(h, { justify: "end", className: ROW_LINK_LIFT }, [ + button( + h, + { + variant: acked === null ? "outline" : "ghost", + size: "xs", + isLoading: acknowledge.pendingIds.includes(incident.id), + attributes: [ + h.OnClick(acknowledge.onAcknowledge(incident.id, acked === null)), + h.AriaLabel( + `${acked === null ? "Acknowledge" : "Unacknowledge"} ${incidentTitle(incident)}`, ), - ]), - typography(h, { size: "xs", tone: "muted", truncate: true, as: "p" }, [ - acked === null - ? incidentSubline(incident) - : `${acked} · ${incidentSubline(incident)}`, - ]), - ], + ], + }, + acked === null ? [icon(h, "CheckIcon"), "Acknowledge"] : ["Unacknowledge"], ), - typography(h, { size: "xs", mono: true, numeric: true }, [ - isHeld - ? h.span([h.Class("text-muted-foreground")], ["no data"]) - : h.span([h.Class(tone)], [incidentValues(incident).now]), - ...(incidentValues(incident).threshold === null - ? [] - : [h.span([h.Class("text-muted-foreground")], [" / ", incidentValues(incident).threshold])]), - ]), - typography(h, { size: "xs", mono: true, numeric: true }, [ - formatAlertDuration(incident.firstTriggeredAt, msIso(now)), - ]), - // Lifted above the row's stretched link, so the click acknowledges instead of navigating. - hstack(h, { justify: "end", className: ROW_LINK_LIFT }, [ - button( - h, - { - variant: acked === null ? "outline" : "ghost", - size: "xs", - isLoading: acknowledge.pendingIds.includes(incident.id), - attributes: [ - h.OnClick(acknowledge.onAcknowledge(incident.id, acked === null)), - h.AriaLabel( - `${acked === null ? "Acknowledge" : "Unacknowledge"} ${incidentTitle(incident)}`, - ), - ], - }, - acked === null ? [icon(h, "CheckIcon"), "Acknowledge"] : ["Unacknowledge"], - ), - ]), - ], - ) + ]), + ]) } // LAST 24 HOURS @@ -407,6 +405,7 @@ const fireLaneRow = (h: HtmlBuilder, lane: FireLane): Html => const BLIND_SPOT_META = new Map([ ["error", { label: "Error", action: "Fix", className: "text-severity-error" }], ["stale", { label: "Stale", action: "Check", className: "text-severity-warn" }], + ["few-samples", { label: "Few samples", action: "Tune", className: "text-severity-warn" }], ["no-data", { label: "No data", action: "Tune", className: "text-severity-warn" }], ["slowed", { label: "Slowed down", action: "Fix query", className: "text-severity-warn" }], ["unrouted", { label: "Unrouted", action: "Route", className: "text-severity-warn" }], @@ -445,7 +444,7 @@ const blindSpotRow = (h: HtmlBuilder, spot: BlindSpot): Html => { [ h.Key(spot.rule.id), h.Class( - "group relative grid grid-cols-[72px_minmax(0,1fr)_auto] items-center gap-4 py-2.5 hover:bg-surface-sunken active:bg-surface-sunken", + "group relative grid grid-cols-[88px_minmax(0,1fr)_auto] items-center gap-4 py-2.5 hover:bg-surface-sunken active:bg-surface-sunken", ), ], [ @@ -534,7 +533,7 @@ const headroomList = (h: HtmlBuilder, rows: ReadonlyArray, n const headroomRow = (h: HtmlBuilder, row: RuleHeadroom, now: number): Html => { const { rule, closeness } = row - const isNear = closeness !== null && closeness >= 0.75 + const { isNear } = row const isFloor = rule.comparator === "lt" || rule.comparator === "lte" return h.li( [ @@ -566,7 +565,7 @@ const headroomRow = (h: HtmlBuilder, row: RuleHeadroom, now: number): Html ], ), h.div( - [h.Class("relative h-2 rounded-xs bg-surface-strong"), h.AriaHidden(true)], + [h.Class("relative hidden h-2 rounded-xs bg-surface-strong sm:block"), h.AriaHidden(true)], [ ...(closeness !== null ? [ diff --git a/apps/web/src/page/alerts/hub/view-rules.ts b/apps/web/src/page/alerts/hub/view-rules.ts index 076400dc95..41ecc4685b 100644 --- a/apps/web/src/page/alerts/hub/view-rules.ts +++ b/apps/web/src/page/alerts/hub/view-rules.ts @@ -122,7 +122,14 @@ export const rulesTab = (h: H, model: Model, inputs: RulesTabInputs): Html => { rule, derived: derivedByRuleId.get(rule.id), bucket: bucketByRuleId.get(rule.id) ?? "healthy", - lastValue: worstState(data.statesByRule.get(rule.id) ?? [])?.last_value ?? null, + // A skipped window can keep a value (too few samples) but can't fire: not headroom. + lastValue: + worstState( + Arr.filter( + data.statesByRule.get(rule.id) ?? [], + (state) => state.last_status !== "skipped", + ), + )?.last_value ?? null, incidents: incidentsByRule.get(rule.id) ?? [], ribbonRange, destinations: Arr.flatMap(Arr.dedupe(rule.destinationIds), (id) => { @@ -390,13 +397,11 @@ const headroomMeter = ( : typography(h, { size: "xs", tone: value >= 2 ? "error" : "warning" }, [ `${anomalySeverityLabel(value)} anomaly open`, ]) + if (derived?.status === "few-samples") + return typography(h, { size: "xs", tone: "muted" }, ["Too few samples to check"]) if (value === null) return typography(h, { size: "xs", tone: "muted" }, ["Waiting on data"]) const meter = ruleHeadroom(rule, value) - const tone = meter.breached - ? "bg-severity-error" - : meter.headroom !== null && meter.headroom < 0.2 - ? "bg-severity-warn" - : "bg-severity-info" + const tone = meter.breached ? "bg-severity-error" : meter.isNear ? "bg-severity-warn" : "bg-severity-info" const label = meter.breached ? "over threshold" : meter.headroom !== null diff --git a/apps/web/src/page/alerts/hub/view-silences.ts b/apps/web/src/page/alerts/hub/view-silences.ts index 8f22345a79..7e2dce8743 100644 --- a/apps/web/src/page/alerts/hub/view-silences.ts +++ b/apps/web/src/page/alerts/hub/view-silences.ts @@ -16,6 +16,7 @@ import { sectionHeading } from "../../kit/section-heading" import { endsAtMs, formatSilenceTime, + isCancelledSilence, silenceScopeParts, silenceStatusAt, startsAtMs, @@ -49,12 +50,11 @@ const windowText = (silence: V2AlertSilence, nowMs: number, timeZone: string): s const status = silenceStatusAt(silence, nowMs) const start = formatSilenceTime(startsAtMs(silence), nowMs, timeZone) const end = formatSilenceTime(endsAtMs(silence), nowMs, timeZone) - // A cancelled silence comes back with its window collapsed to one instant: it never ran. return status === "active" ? `Until ${end}` : status === "scheduled" ? `${start} to ${end}` - : startsAtMs(silence) === endsAtMs(silence) + : isCancelledSilence(silence) ? `Cancelled ${end}` : `Ended ${end}` } @@ -115,7 +115,11 @@ const silenceRow = ( ), ] : status === "ended" - ? [badge(h, { variant: "outline" }, ["Ended"])] + ? [ + badge(h, { variant: "outline" }, [ + isCancelledSilence(silence) ? "Cancelled" : "Ended", + ]), + ] : [], }) } @@ -174,7 +178,7 @@ const loaded = ( : []), ...section(h, model, "Active", active, options), ...section(h, model, "Scheduled", scheduled, options), - ...section(h, model, "Ended in the last 7 days", ended, options), + ...section(h, model, "Ended or cancelled in the last 7 days", ended, options), ]) } diff --git a/apps/web/src/page/alerts/shared/rule-chart.ts b/apps/web/src/page/alerts/shared/rule-chart.ts index 3363087d8b..233bd084d9 100644 --- a/apps/web/src/page/alerts/shared/rule-chart.ts +++ b/apps/web/src/page/alerts/shared/rule-chart.ts @@ -475,11 +475,24 @@ type RailBucket = { healthy: number skipped: number noData: number + fewSamples: number errored: number opened: boolean errorMessage: string | null } +/** " (2 no data, 1 too few samples)", or nothing when no skip carries a reason. */ +const skipDetail = (bucket: RailBucket): string => { + const parts = Arr.filter( + [ + bucket.noData > 0 ? `${bucket.noData} no data` : null, + bucket.fewSamples > 0 ? `${bucket.fewSamples} too few samples` : null, + ], + (part) => part !== null, + ) + return parts.length === 0 ? "" : ` (${Arr.join(parts, ", ")})` +} + const rail = ( h: HtmlBuilder, inputs: RuleChartInputs, @@ -494,6 +507,7 @@ const rail = ( healthy: 0, skipped: 0, noData: 0, + fewSamples: 0, errored: 0, opened: false, errorMessage: null, @@ -513,6 +527,7 @@ const rail = ( } else { bucket.skipped += 1 if (check.skipReason === "no_data") bucket.noData += 1 + else if (check.skipReason === "below_min_samples") bucket.fewSamples += 1 } if (check.incidentTransition === "opened") bucket.opened = true return acc @@ -537,11 +552,7 @@ const rail = ( bucket.errored > 0 ? `${bucket.errored} failed` : null, bucket.breached > 0 ? `${bucket.breached} breached` : null, bucket.healthy > 0 ? `${bucket.healthy} healthy` : null, - bucket.skipped > 0 - ? bucket.noData > 0 - ? `${bucket.skipped} skipped (${bucket.noData} no data)` - : `${bucket.skipped} skipped` - : null, + bucket.skipped > 0 ? `${bucket.skipped} skipped${skipDetail(bucket)}` : null, ], (part) => part !== null, ), diff --git a/apps/web/src/page/alerts/shared/silences.ts b/apps/web/src/page/alerts/shared/silences.ts index 4cd5360ee8..8db968ee93 100644 --- a/apps/web/src/page/alerts/shared/silences.ts +++ b/apps/web/src/page/alerts/shared/silences.ts @@ -66,6 +66,10 @@ export const endSilence = (id: AlertSilenceId) => export const startsAtMs = (silence: V2AlertSilence): number => isoMs(silence.starts_at) export const endsAtMs = (silence: V2AlertSilence): number => isoMs(silence.ends_at) +/** Cancelled before it started: the API collapses its window to one instant. */ +export const isCancelledSilence = (silence: V2AlertSilence): boolean => + startsAtMs(silence) === endsAtMs(silence) + /** "15:00" today, "Nov 7, 09:00" on another day, in the viewer's zone. */ export const formatSilenceTime = (ms: number, nowMs: number, timeZone: string): string => { const time = formatTimeInTimezone(ms, { timeZone, withSeconds: false }) diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 9e748ea70e..bb301807e5 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -144,7 +144,7 @@ const durationSection = (h: H, model: Model): Html => value: model.duration, onChange: (duration) => Message.ChangedDuration({ duration }), size: "sm", - className: "max-w-full overflow-x-auto", + className: "w-fit flex-wrap", }), ...(model.duration === "custom" ? [ diff --git a/apps/web/src/page/dashboards/widget/edits.ts b/apps/web/src/page/dashboards/widget/edits.ts index 4e925fbed0..725e00bdec 100644 --- a/apps/web/src/page/dashboards/widget/edits.ts +++ b/apps/web/src/page/dashboards/widget/edits.ts @@ -4,10 +4,10 @@ import { createQueryDraft, formulaLabel, queryLabel, - resetQueryForDataSource, type QueryBuilderDataSource, } from "@maple/query-engine/query-builder" import { fromPanelType } from "../kit/chart-registry" +import { switchDataSource } from "../../kit/query-builder/change" import { inferDefaultUnitForQueries } from "./drafts" import type { EditorState, QueryDraft } from "./editor-state" import { Array as Arr, Option } from "effect" @@ -38,7 +38,7 @@ export const setDataSource = ( dataSource: QueryBuilderDataSource, ): EditorState => reconcileFunnelSource( - withInferredUnit(updateQuery(state, queryId, (query) => resetQueryForDataSource(query, dataSource))), + withInferredUnit(updateQuery(state, queryId, (query) => switchDataSource(query, dataSource))), ) /** `addQuery`: a default draft under the next letter. */ @@ -123,7 +123,7 @@ export const reconcileFunnelSource = (state: EditorState): EditorState => { funnel: { ...state.funnel, source: "query_set" }, queries: Arr.map(state.queries, (query, index) => index === 0 && query.dataSource !== "product_events" - ? resetQueryForDataSource(query, "product_events") + ? switchDataSource(query, "product_events") : query, ), } diff --git a/apps/web/src/page/dashboards/widget/message.ts b/apps/web/src/page/dashboards/widget/message.ts index 30f4477c01..1d9763cd8e 100644 --- a/apps/web/src/page/dashboards/widget/message.ts +++ b/apps/web/src/page/dashboards/widget/message.ts @@ -16,7 +16,7 @@ import { WhereFieldMessage } from "../../kit/where-field" import { WidgetFailure } from "../kit/widget-data" import { LegendPosition, PointsMode } from "./editor-state" import { MetricKeysSchema } from "../../kit/query-builder/metrics-body" -import { MetricPageSchema } from "./metric-option" +import { MetricPageSchema } from "../../kit/query-builder/metric-options" import { ProductEdit } from "./product-edit" import { Suggestion, SuggestionLookup } from "./suggestions" diff --git a/apps/web/src/page/dashboards/widget/metric-option.ts b/apps/web/src/page/dashboards/widget/metric-option.ts deleted file mode 100644 index 511442d4fe..0000000000 --- a/apps/web/src/page/dashboards/widget/metric-option.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Schema } from "effect" - -/** One metric picker entry: `name::type`, its label, and whether a sum is monotonic. */ -export const MetricOptionSchema = Schema.Struct({ - value: Schema.String, - label: Schema.String, - isMonotonic: Schema.Boolean, -}) -export type MetricOption = typeof MetricOptionSchema.Type - -/** One `listMetrics` page: the picker's options and every row's service (`metricServices`). */ -export const MetricPageSchema = Schema.Struct({ - options: Schema.Array(MetricOptionSchema), - services: Schema.Array(Schema.String), -}) diff --git a/apps/web/src/page/dashboards/widget/metric-options.ts b/apps/web/src/page/dashboards/widget/metric-options.ts index 1f32f7dbc7..c6e6e6009c 100644 --- a/apps/web/src/page/dashboards/widget/metric-options.ts +++ b/apps/web/src/page/dashboards/widget/metric-options.ts @@ -1,10 +1,15 @@ -import { QUERY_BUILDER_METRIC_TYPES, toQueryBuilderMetricType } from "@maple/query-model" -import { getMetricAttributeKeys, listMetrics } from "@maple/web-data/warehouse/metrics" -import { Effect, Schema, Array as Arr, String as Str, Option } from "effect" +import { QUERY_BUILDER_METRIC_TYPES } from "@maple/query-model" +import { Effect, Schema, Array as Arr } from "effect" import { Command } from "foldkit" -import { toLoadFailure } from "@maple/views/data/load-failure" -import { applyQueryChange, QueryChange } from "../../kit/query-builder/change" -import type { MetricOption } from "./metric-option" +import { applyQueryChange } from "../../kit/query-builder/change" +import { + listMetricOptions, + type MetricOption, + metricValueOf, + pickedMetric, + readMetricKeys, + unaskedMetrics, +} from "../../kit/query-builder/metric-options" import type { EditorState } from "./editor-state" import { Message } from "./message" import type { Model } from "./model" @@ -12,43 +17,11 @@ import type { Model } from "./model" // `useWidgetBuilderData`'s metric list: one page of the org's metrics for what the picker searched, // each name and type once, fetched while a query reads metrics. -export type { MetricOption } from "./metric-option" - -type MetricKey = Readonly<{ metricName: string; metricType: string }> - -/** `listMetricsResultAtom({ data: { limit: 100, search } })`, reduced to picker options. */ export const FetchMetricOptions = Command.define("FetchWidgetEditorMetricOptions", { args: { search: Schema.String }, messages: [Message.CompletedFetchMetricOptions], execute: ({ search }) => - listMetrics({ data: { limit: 100, ...(search === "" ? undefined : { search }) } }).pipe( - Effect.mapBoth({ - onSuccess: (response) => ({ - options: Arr.dedupeWith( - Arr.map( - Arr.filter( - response.data, - (row) => toQueryBuilderMetricType(row.metricType) !== undefined, - ), - (row) => ({ - value: `${row.metricName}::${row.metricType}`, - label: `${row.metricName} (${row.metricType})`, - isMonotonic: row.isMonotonic, - }), - ), - (option: MetricOption, other: MetricOption) => option.value === other.value, - ), - // `toNames`: trimmed, non-empty, each once. - services: Arr.dedupeWith( - Arr.filter( - Arr.map(response.data, (row) => Str.trim(row.serviceName)), - (name) => name !== "", - ), - (name, other) => name === other, - ), - }), - onFailure: toLoadFailure, - }), + listMetricOptions(search).pipe( Effect.result, Effect.map((result) => Message.CompletedFetchMetricOptions({ search, result })), ), @@ -58,27 +31,11 @@ export const FetchMetricOptions = Command.define("FetchWidgetEditorMetricOptions export const FetchMetricKeys = Command.define("FetchWidgetEditorMetricKeys", { args: { metricName: Schema.String, metricType: Schema.Literals(QUERY_BUILDER_METRIC_TYPES) }, messages: [Message.CompletedFetchMetricKeys], - execute: ({ metricName, metricType }) => - Effect.all( - { - attributes: getMetricAttributeKeys({ data: { metricName, metricType } }), - // Resource keys only add to the picker: their failure must not empty the label keys too. - resources: getMetricAttributeKeys({ - data: { metricName, metricType, scope: "resource" }, - }).pipe(Effect.orElseSucceed(() => ({ data: [] }))), - }, - { concurrency: 2 }, - ).pipe( - Effect.mapBoth({ - onSuccess: ({ attributes, resources }) => ({ - attributes: Arr.map(attributes.data, (row) => row.attributeKey), - resources: Arr.map(resources.data, (row) => row.attributeKey), - }), - onFailure: toLoadFailure, - }), + execute: (metric) => + readMetricKeys(metric).pipe( Effect.result, Effect.map((result) => - Message.CompletedFetchMetricKeys({ metric: `${metricName}::${metricType}`, result }), + Message.CompletedFetchMetricKeys({ metric: metricValueOf(metric), result }), ), ), }) @@ -99,43 +56,17 @@ export const askMetricOptions = (model: Model): Asked => { model.metricRequested === model.metricSearch ? [] : [FetchMetricOptions({ search: model.metricSearch })] - const missing = Arr.filter( - Arr.dedupeWith( - Arr.map( - Arr.filter(queries, (query) => query.metricName !== ""), - (query) => ({ metricName: query.metricName, metricType: query.metricType }), - ), - (metric: MetricKey, other: MetricKey) => - other.metricName === metric.metricName && other.metricType === metric.metricType, - ), - (metric) => !model.metricKeysRequested.includes(`${metric.metricName}::${metric.metricType}`), - ) + const missing = unaskedMetrics(queries, model.metricKeysRequested) return { model: { ...model, metricRequested: model.metricSearch, - metricKeysRequested: [ - ...model.metricKeysRequested, - ...Arr.map(missing, (metric) => `${metric.metricName}::${metric.metricType}`), - ], + metricKeysRequested: [...model.metricKeysRequested, ...Arr.map(missing, metricValueOf)], }, commands: [...list, ...Arr.map(missing, (metric) => FetchMetricKeys(metric))], } } -/** `parseMetricSelection`: `name::type` back into the metric it names. */ -export const pickedMetric = (value: string, options: ReadonlyArray): QueryChange | null => { - const [metricName, rawType = ""] = Str.split("::")(value) - const foundType = Arr.findFirst(QUERY_BUILDER_METRIC_TYPES, (type) => type === rawType) - if (metricName === "" || Option.isNone(foundType)) return null - const metricType = foundType.value - const isMonotonic = Arr.findFirst(options, (option) => option.value === value).pipe( - Option.flatMapNullishOr((found) => found.isMonotonic), - Option.getOrElse(() => metricType === "sum"), - ) - return QueryChange.SetMetric({ metricName, metricType, isMonotonic }) -} - export const optionsOf = (model: Model): ReadonlyArray => model.metricOptions?.options ?? [] /** The first page's first metric fills any metrics query that has none yet (once). */ diff --git a/apps/web/src/page/dashboards/widget/model.ts b/apps/web/src/page/dashboards/widget/model.ts index 15a2a36a8e..7cb271cb0d 100644 --- a/apps/web/src/page/dashboards/widget/model.ts +++ b/apps/web/src/page/dashboards/widget/model.ts @@ -20,7 +20,7 @@ import { WidgetFailure, WidgetGate } from "../kit/widget-data" import { EditorState } from "./editor-state" import { RawSqlState } from "./raw-sql" import { MetricKeysSchema } from "../../kit/query-builder/metrics-body" -import { MetricPageSchema } from "./metric-option" +import { MetricPageSchema } from "../../kit/query-builder/metric-options" import { Suggestion } from "./suggestions" // The widget editor. diff --git a/apps/web/src/page/dashboards/widget/step.ts b/apps/web/src/page/dashboards/widget/step.ts index ffc4e18134..fa6afde505 100644 --- a/apps/web/src/page/dashboards/widget/step.ts +++ b/apps/web/src/page/dashboards/widget/step.ts @@ -10,7 +10,8 @@ import type { PageContext } from "../../contract" import { applyQueryChange } from "../../kit/query-builder/change" import * as QueryPanel from "../../kit/query-builder/panel" import { GenerateId } from "./command" -import { askMetricOptions, optionsOf, pickedMetric } from "./metric-options" +import { pickedMetric } from "../../kit/query-builder/metric-options" +import { askMetricOptions, optionsOf } from "./metric-options" import { removeQuery, setAggregation, setDataSource, updateQuery, withInferredUnit } from "./edits" import type { EditorState } from "./editor-state" import { applyProductEdit } from "./product-apply" diff --git a/apps/web/src/page/kit/query-builder/change.test.ts b/apps/web/src/page/kit/query-builder/change.test.ts new file mode 100644 index 0000000000..8ea510fe7b --- /dev/null +++ b/apps/web/src/page/kit/query-builder/change.test.ts @@ -0,0 +1,18 @@ +import { createQueryDraft } from "@maple/query-engine/query-builder" +import { describe, expect, test } from "vitest" +import { switchDataSource } from "./change" + +describe("switchDataSource", () => { + const traces = { ...createQueryDraft(1), whereClause: "service.name = checkout AND root_only = true" } + + test("leaving traces drops root spans only and keeps the rest of the filter", () => { + expect(switchDataSource(traces, "metrics").whereClause).toBe("service.name = checkout") + expect(switchDataSource(traces, "logs").whereClause).toBe("service.name = checkout") + }) + + test("staying on traces keeps root spans only", () => { + expect(switchDataSource(traces, "traces").whereClause).toBe( + "service.name = checkout AND root_only = true", + ) + }) +}) diff --git a/apps/web/src/page/kit/query-builder/change.ts b/apps/web/src/page/kit/query-builder/change.ts index 2f556170d0..8b05d7f52f 100644 --- a/apps/web/src/page/kit/query-builder/change.ts +++ b/apps/web/src/page/kit/query-builder/change.ts @@ -1,4 +1,9 @@ -import { resetAggregationForMetricType, resetQueryForDataSource } from "@maple/query-engine/query-builder" +import { + type QueryBuilderDataSource, + type QueryBuilderQueryDraft, + resetAggregationForMetricType, + resetQueryForDataSource, +} from "@maple/query-engine/query-builder" import { QUERY_BUILDER_DATA_SOURCES, QUERY_BUILDER_METRIC_TYPES, @@ -61,10 +66,19 @@ export const withRootOnly = (whereClause: string, isRootOnly: boolean): string = return stripped === "" ? "root_only = true" : `${stripped} AND root_only = true` } +/** A draft moved to another source; `root_only` is a traces filter, so other sources drop it. */ +export const switchDataSource = ( + query: QueryBuilderQueryDraft, + source: QueryBuilderDataSource, +): QueryBuilderQueryDraft => { + const reset = resetQueryForDataSource(query, source) + return source === "traces" ? reset : { ...reset, whereClause: withRootOnly(reset.whereClause, false) } +} + /** The change applied to one draft, as `QueryPanel`'s callbacks do. */ export const applyQueryChange = (query: QueryDraft, change: QueryChange): QueryDraft => QueryChange.match(change, { - SetSource: ({ source }) => resetQueryForDataSource(query, source), + SetSource: ({ source }) => switchDataSource(query, source), SetRootOnly: ({ isRootOnly }) => ({ ...query, whereClause: withRootOnly(query.whereClause, isRootOnly), diff --git a/apps/web/src/page/dashboards/widget/metric-options.test.ts b/apps/web/src/page/kit/query-builder/metric-options.test.ts similarity index 97% rename from apps/web/src/page/dashboards/widget/metric-options.test.ts rename to apps/web/src/page/kit/query-builder/metric-options.test.ts index 25e7fb1c0a..6d83702c96 100644 --- a/apps/web/src/page/dashboards/widget/metric-options.test.ts +++ b/apps/web/src/page/kit/query-builder/metric-options.test.ts @@ -1,6 +1,6 @@ import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" import { describe, expect, test } from "vitest" -import { applyQueryChange } from "../../kit/query-builder/change" +import { applyQueryChange } from "./change" import { pickedMetric } from "./metric-options" const OPTIONS = [ diff --git a/apps/web/src/page/kit/query-builder/metric-options.ts b/apps/web/src/page/kit/query-builder/metric-options.ts new file mode 100644 index 0000000000..f25a20aa02 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/metric-options.ts @@ -0,0 +1,114 @@ +import { + QUERY_BUILDER_METRIC_TYPES, + type QueryBuilderMetricType, + toQueryBuilderMetricType, +} from "@maple/query-model" +import { getMetricAttributeKeys, listMetrics } from "@maple/web-data/warehouse/metrics" +import { Effect, Schema, Array as Arr, String as Str, Option } from "effect" +import { toLoadFailure } from "@maple/views/data/load-failure" +import { QueryChange } from "./change" +import type { QueryDraft } from "./draft" +import type { MetricKeys } from "./metrics-body" + +// The metric picker's reads, shared by every host of a metrics `QueryPanel`: one page of the +// org's metrics for what was searched, and each picked metric's own keys. + +/** One metric picker entry: `name::type`, its label, and whether a sum is monotonic. */ +export const MetricOptionSchema = Schema.Struct({ + value: Schema.String, + label: Schema.String, + isMonotonic: Schema.Boolean, +}) +export type MetricOption = typeof MetricOptionSchema.Type + +/** One `listMetrics` page: the picker's options and every row's service (`metricServices`). */ +export const MetricPageSchema = Schema.Struct({ + options: Schema.Array(MetricOptionSchema), + services: Schema.Array(Schema.String), +}) +export type MetricPage = typeof MetricPageSchema.Type + +export type MetricRef = Readonly<{ metricName: string; metricType: QueryBuilderMetricType }> + +/** A metric's picker value and the key its fetched keys are kept under. */ +export const metricValueOf = (metric: MetricRef): string => `${metric.metricName}::${metric.metricType}` + +/** `listMetricsResultAtom({ data: { limit: 100, search } })`, reduced to picker options. */ +export const listMetricOptions = (search: string) => + listMetrics({ data: { limit: 100, ...(search === "" ? undefined : { search }) } }).pipe( + Effect.mapBoth({ + onSuccess: (response): MetricPage => ({ + options: Arr.dedupeWith( + Arr.map( + Arr.filter(response.data, (row) => toQueryBuilderMetricType(row.metricType) !== null), + (row) => ({ + value: `${row.metricName}::${row.metricType}`, + label: `${row.metricName} (${row.metricType})`, + isMonotonic: row.isMonotonic, + }), + ), + (option: MetricOption, other: MetricOption) => option.value === other.value, + ), + // `toNames`: trimmed, non-empty, each once. + services: Arr.dedupeWith( + Arr.filter( + Arr.map(response.data, (row) => Str.trim(row.serviceName)), + (name) => name !== "", + ), + (name, other) => name === other, + ), + }), + onFailure: toLoadFailure, + }), + ) + +/** The metric's data-point and resource keys over the last 24 h, read together. */ +export const readMetricKeys = ({ metricName, metricType }: MetricRef) => + Effect.all( + { + attributes: getMetricAttributeKeys({ data: { metricName, metricType } }), + // Resource keys only add to the picker: their failure must not empty the label keys too. + resources: getMetricAttributeKeys({ data: { metricName, metricType, scope: "resource" } }).pipe( + Effect.orElseSucceed(() => ({ data: [] })), + ), + }, + { concurrency: 2 }, + ).pipe( + Effect.mapBoth({ + onSuccess: ({ attributes, resources }): MetricKeys => ({ + attributes: Arr.map(attributes.data, (row) => row.attributeKey), + resources: Arr.map(resources.data, (row) => row.attributeKey), + }), + onFailure: toLoadFailure, + }), + ) + +/** The metrics the queries picked whose keys were not asked for yet, each once. */ +export const unaskedMetrics = ( + queries: ReadonlyArray, + requested: ReadonlyArray, +): ReadonlyArray => + Arr.filter( + Arr.dedupeWith( + Arr.flatMap(queries, (query): ReadonlyArray => + query.dataSource === "metrics" && query.metricName !== "" + ? [{ metricName: query.metricName, metricType: query.metricType }] + : [], + ), + (metric: MetricRef, other: MetricRef) => metricValueOf(metric) === metricValueOf(other), + ), + (metric) => !requested.includes(metricValueOf(metric)), + ) + +/** `parseMetricSelection`: `name::type` back into the metric it names. */ +export const pickedMetric = (value: string, options: ReadonlyArray): QueryChange | null => { + const [metricName, rawType = ""] = Str.split("::")(value) + const foundType = Arr.findFirst(QUERY_BUILDER_METRIC_TYPES, (type) => type === rawType) + if (metricName === "" || Option.isNone(foundType)) return null + const metricType = foundType.value + const isMonotonic = Arr.findFirst(options, (option) => option.value === value).pipe( + Option.flatMapNullishOr((found) => found.isMonotonic), + Option.getOrElse(() => metricType === "sum"), + ) + return QueryChange.SetMetric({ metricName, metricType, isMonotonic }) +} diff --git a/apps/web/src/page/kit/query-builder/metrics-body.ts b/apps/web/src/page/kit/query-builder/metrics-body.ts index a1b2eb588c..eeab255aa9 100644 --- a/apps/web/src/page/kit/query-builder/metrics-body.ts +++ b/apps/web/src/page/kit/query-builder/metrics-body.ts @@ -13,6 +13,7 @@ import { import { QUERY_BUILDER_SPACE_AGGREGATIONS } from "@maple/query-model" import { groupBySelectionOf, type QueryDraft } from "./draft" import { Message, metricPickerIdOf, type Model } from "./panel" +import { type MetricOption, metricValueOf } from "./metric-options" import { panelSelect, textInput, whereClauseEditor } from "./parts" import { Array as Arr, Schema, String as Str, Option } from "effect" @@ -30,15 +31,18 @@ export type MetricKeys = typeof MetricKeysSchema.Type type H = HtmlBuilder type MetricsQuery = Extract -export type MetricOption = Readonly<{ value: string; label: string; isMonotonic: boolean }> - -/** A combobox kept by query id; closed, its input reads the draft's value as Base UI's does. */ -const comboOf = (stored: Combobox.Model | undefined, id: string, value: string): Combobox.Model => { +/** A combobox kept by query id; closed, its input reads the metric's name, never `name::type`. */ +const comboOf = ( + stored: Combobox.Model | undefined, + id: string, + value: string, + text: string, +): Combobox.Model => { const current = stored ?? Combobox.init(id, "single") return { ...current, selected: value === "" ? [] : [value], - inputValue: current.isOpen ? current.inputValue : value, + inputValue: current.isOpen ? current.inputValue : text, } } @@ -58,14 +62,15 @@ const picker = ( options: ReadonlyArray, searchTerm: string, ): Html => { - const value = query.metricName === "" ? "" : `${query.metricName}::${query.metricType}` + const value = query.metricName === "" ? "" : metricValueOf(query) const listed = optionsWith(query, value, options) const term = Str.trim(searchTerm) return h.submodel({ slotId: `${model.slotPrefix}-metric-${query.id}`, - model: comboOf(model.metricPickers[query.id], metricPickerIdOf(query.id), value), + model: comboOf(model.metricPickers[query.id], metricPickerIdOf(query.id), value, query.metricName), view: ComboboxView.view, - toParentMessage: (message) => Message.GotMetricMessage({ queryId: query.id, value, message }), + toParentMessage: (message) => + Message.GotMetricMessage({ queryId: query.id, value: query.metricName, message }), viewInputs: { items: Arr.map(listed, (option) => option.value), placeholder: "Search metrics…", @@ -93,7 +98,21 @@ const picker = ( toView: ({ field, rest }) => h.div( [h.Class("flex items-center gap-2")], - [h.span([h.Class("text-xs text-muted-foreground shrink-0")], ["Metric"]), field, ...rest], + [ + h.span([h.Class("text-xs text-muted-foreground shrink-0")], ["Metric"]), + field, + // The type is shown beside the name: the input reads the name alone. + ...(query.metricName === "" + ? [] + : [ + typography( + h, + { size: "xs", tone: "muted", mono: true, className: "shrink-0" }, + [query.metricType], + ), + ]), + ...rest, + ], ), }, }) diff --git a/apps/web/src/page/kit/query-builder/panel-message.ts b/apps/web/src/page/kit/query-builder/panel-message.ts index f773e23d77..521b1a73fe 100644 --- a/apps/web/src/page/kit/query-builder/panel-message.ts +++ b/apps/web/src/page/kit/query-builder/panel-message.ts @@ -34,7 +34,7 @@ export const init = (slotPrefix: string): Model => ({ export const Message = defineMessageUnion({ GotSelectMessage: { id: Schema.String, message: Select.Message }, GotGroupByMessage: { queryId: Schema.String, message: Combobox.Message }, - /** `value`: the draft's metric, which a closed picker's input reads (and keeps on opening). */ + /** `value`: the draft's metric name, which a closed picker's input reads (and keeps on opening). */ GotMetricMessage: { queryId: Schema.String, value: Schema.String, message: Combobox.Message }, ToggledCollapsed: { queryId: Schema.String }, ToggledVisible: { queryId: Schema.String }, diff --git a/apps/web/src/page/kit/query-builder/view.ts b/apps/web/src/page/kit/query-builder/view.ts index 05d0a8dee8..e308c979a4 100644 --- a/apps/web/src/page/kit/query-builder/view.ts +++ b/apps/web/src/page/kit/query-builder/view.ts @@ -19,7 +19,8 @@ import { hstack } from "@maple/views/ui/stack" import type { SelectOption } from "@maple/views/ui/select-view" import type { QueryDraft } from "./draft" import { Message, type Model } from "./panel" -import { type MetricKeys, type MetricOption, metricsBody } from "./metrics-body" +import type { MetricOption } from "./metric-options" +import { type MetricKeys, metricsBody } from "./metrics-body" import { addOnSections, ADD_ON_KEYS, addOnToggles, panelSelect, textInput, whereClauseEditor } from "./parts" import { Array as Arr, String as Str } from "effect" diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 490ce2e509..e0cbd8de00 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -206,6 +206,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/pinned-namespace-notice.ts` `pinnedNamespaceNotice`: `PinnedNamespaceNotice`: replaces a rail's Namespace section while the org-global pin is on. - `apps/web/src/page/kit/query-builder/change.ts` `withRootOnly`, `applyQueryChange`: One edit a `QueryPanel` makes to its draft (its `onUpdate`, `onAggregationChange` and `onDataSourceChange` callbacks), as data the host applies. - `apps/web/src/page/kit/query-builder/draft.ts` `groupBySelectionOf`: `QueryBuilderQueryDraft` (@maple/query-engine/query-builder) as a Schema: the query one `QueryPanel` edits, shared by the widget editor and the alert rule editor. +- `apps/web/src/page/kit/query-builder/metric-options.ts` `metricValueOf`, `listMetricOptions`, `readMetricKeys`, `unaskedMetrics`, `pickedMetric`: The metric picker's reads, shared by every host of a metrics `QueryPanel`: one page of the org's metrics for what was searched, and each picked metric's own keys. - `apps/web/src/page/kit/query-builder/metrics-body.ts` `groupByChoicesOf`, `metricsBody`: `MetricsBody`: the metric picker, the where clause, and the two aggregation stages: each series reduced within the interval, then the series combined by any number of fields. - `apps/web/src/page/kit/query-builder/panel.ts` `metricPickerIdOf` (stateful: Model, init, update): Where-editor Messages need `inputs` (the drafts and autocomplete values); without them they are ignored. - `apps/web/src/page/kit/query-builder/parts.ts` `panelSelect`, `whereClauseEditor`, `textInput`, `addOnToggles`, `addOnSections`: The pieces of a `QueryPanel` body: the where clause, the add-on toggles and their sections. diff --git a/packages/web-data/src/alerts/headroom.test.ts b/packages/web-data/src/alerts/headroom.test.ts index 835d25dcd8..d9bf57f1f6 100644 --- a/packages/web-data/src/alerts/headroom.test.ts +++ b/packages/web-data/src/alerts/headroom.test.ts @@ -103,6 +103,15 @@ describe("ruleHeadroom", () => { ) expect(rows).toEqual([]) }) + + it("leaves out a value from a window under the minimum sample count, which can't fire", () => { + const fewSamples = { ...state(RULE_A, "api", 0.063), last_status: "skipped", last_sample_count: 4 } + const rows = ruleHeadroom( + [rule(RULE_A, { minimumSampleCount: 20 })], + new Map([[RULE_A, [fewSamples, state(RULE_A, "web", 0.01)]]]), + ) + expect(Arr.map(rows, (row) => [row.groupKey, row.value])).toEqual([["web", 0.01]]) + }) }) describe("quietForMs", () => { diff --git a/packages/web-data/src/alerts/headroom.ts b/packages/web-data/src/alerts/headroom.ts index f26ff401df..facbe10d18 100644 --- a/packages/web-data/src/alerts/headroom.ts +++ b/packages/web-data/src/alerts/headroom.ts @@ -1,7 +1,8 @@ import { UNGROUPED_GROUP_KEY, type AlertIncidentDocument, type AlertRuleDocument } from "@maple/domain/http" import type { AlertRuleStateRow } from "../collections/alerts" -import { Array as Arr, Order } from "effect" +import { ruleHeadroom as thresholdHeadroom } from "./overview-derive" +import { Array as Arr, Order, Struct } from "effect" /** How close one enabled rule's nearest group sits to firing, from its last recorded check. */ export interface RuleHeadroom { @@ -16,6 +17,10 @@ export interface RuleHeadroom { * single direction (equality and ranges), which still list their value. */ readonly closeness: number | null + /** Share of the threshold left before breaching; null where `closeness` is. Sorts the list. */ + readonly headroom: number | null + /** Past the threshold or close to it, by the same test as the Rules tab's meter. */ + readonly isNear: boolean readonly checkedAt: number | null } @@ -37,7 +42,8 @@ function closenessOf(rule: AlertRuleDocument, value: number): number | null { /** * Every enabled rule's latest value against its threshold, nearest to firing * first. Rules that have never produced a value are left out: there is nothing - * to measure yet, and the blind-spots list already names them. + * to measure yet, and the blind-spots list already names them. So are values + * from skipped windows (too few samples keeps its value), which can never fire. */ export function ruleHeadroom( rules: ReadonlyArray, @@ -56,14 +62,16 @@ export function ruleHeadroom( groupKey === null ? null : (labels.get(`${rule.id}:${groupKey}`) ?? (rule.signalType === "anomaly" ? null : groupKey)) - const nearestFirst = Order.flip( - Order.mapInput(Order.Number, (entry: { readonly closeness: number | null }) => entry.closeness ?? -1), + // Least headroom first, by the measure that also colours a row near firing. + const nearestFirst = Order.mapInput( + Order.Number, + (entry: { readonly headroom: number | null }) => entry.headroom ?? Number.POSITIVE_INFINITY, ) const enabled = Arr.filter(rules, (rule) => rule.enabled) return Arr.sort( Arr.flatMap(enabled, (rule) => { const measured = Arr.flatMap(statesByRule.get(rule.id) ?? [], (state) => - state.last_value === null + state.last_value === null || state.last_status === "skipped" ? [] : [ { @@ -74,6 +82,10 @@ export function ruleHeadroom( ), value: state.last_value, closeness: closenessOf(rule, state.last_value), + ...Struct.pick(thresholdHeadroom(rule, state.last_value), [ + "headroom", + "isNear", + ]), checkedAt: state.last_evaluated_at === null ? null diff --git a/packages/web-data/src/alerts/overview-derive.test.ts b/packages/web-data/src/alerts/overview-derive.test.ts index 8ccb5cf779..8049aa5d5f 100644 --- a/packages/web-data/src/alerts/overview-derive.test.ts +++ b/packages/web-data/src/alerts/overview-derive.test.ts @@ -271,6 +271,19 @@ describe("ruleRibbon", () => { }) }) +describe("ruleHeadroom near firing", () => { + it("calls a value near when under a fifth of the threshold is left, for floors and ceilings alike", () => { + const ceiling = makeRule({ comparator: "gt", threshold: 0.05 }) + const floor = makeRule({ comparator: "lt", threshold: 0.05 }) + expect(ruleHeadroom(ceiling, 0.042).isNear).toBe(true) + expect(ruleHeadroom(ceiling, 0.03).isNear).toBe(false) + expect(ruleHeadroom(ceiling, 0.063).isNear).toBe(true) + // 6.3% against a 5% floor leaves 26%: healthy, not near. + expect(ruleHeadroom(floor, 0.063).isNear).toBe(false) + expect(ruleHeadroom(floor, 0.055).isNear).toBe(true) + }) +}) + describe("blindSpots", () => { it("lists errors before unrouted rules and skips disabled ones", () => { const erroring = makeRule({ id: "00000000-0000-4000-8000-000000000001" }) diff --git a/packages/web-data/src/alerts/overview-derive.ts b/packages/web-data/src/alerts/overview-derive.ts index 21de7895ef..21dbcbcb26 100644 --- a/packages/web-data/src/alerts/overview-derive.ts +++ b/packages/web-data/src/alerts/overview-derive.ts @@ -122,8 +122,13 @@ export interface Headroom { readonly breached: boolean /** Fraction of the threshold left before breaching; null for range/equality comparators. */ readonly headroom: number | null + /** Past the threshold, or within `NEAR_HEADROOM` of it: what the hub draws in warning colour. */ + readonly isNear: boolean } +/** Under a fifth of the threshold left counts as near firing, on every surface that says so. */ +export const NEAR_HEADROOM = 0.2 + const breaches = (rule: AlertRuleDocument, value: number): boolean => { const upper = rule.thresholdUpper ?? rule.threshold switch (rule.comparator) { @@ -160,11 +165,13 @@ export function ruleHeadroom(rule: AlertRuleDocument, value: number): Headroom { : below ? (value - rule.threshold) / rule.threshold : null + const breached = breaches(rule, value) return { fill: Math.min(1, Math.max(0, value / max)), ticks: Arr.map(thresholds, (t) => Math.min(1, Math.max(0, t / max))), - breached: breaches(rule, value), + breached, headroom, + isNear: breached || (headroom !== null && headroom < NEAR_HEADROOM), } } @@ -250,7 +257,7 @@ export function ruleRibbon( return out } -export type BlindSpotKind = "error" | "stale" | "no-data" | "unrouted" | "delivery" | "slowed" +export type BlindSpotKind = "error" | "stale" | "few-samples" | "no-data" | "unrouted" | "delivery" | "slowed" export interface BlindSpot { readonly rule: AlertRuleDocument @@ -261,10 +268,11 @@ export interface BlindSpot { const BLIND_SPOT_ORDER: Record = { error: 0, stale: 1, - "no-data": 2, - unrouted: 3, - delivery: 4, - slowed: 5, + "few-samples": 2, + "no-data": 3, + unrouted: 4, + delivery: 5, + slowed: 6, } satisfies Record /** Enabled rules that cannot fire, reach nobody, or were slowed to keep up. One entry per rule, worst first. */ @@ -279,6 +287,7 @@ export function blindSpots( const reason = derived.reason ?? "" if (derived.status === "error") spots.push({ rule, kind: "error", detail: reason }) else if (derived.status === "stale") spots.push({ rule, kind: "stale", detail: reason }) + else if (derived.status === "few-samples") spots.push({ rule, kind: "few-samples", detail: reason }) else if (derived.status === "no-data") spots.push({ rule, kind: "no-data", detail: reason }) else if (derived.attention.noDestinations) spots.push({ rule, kind: "unrouted", detail: "No destinations, so nobody is notified" }) diff --git a/packages/web-data/src/alerts/rule-status.test.ts b/packages/web-data/src/alerts/rule-status.test.ts index af7db636ee..dea0822f95 100644 --- a/packages/web-data/src/alerts/rule-status.test.ts +++ b/packages/web-data/src/alerts/rule-status.test.ts @@ -236,6 +236,42 @@ describe("deriveRuleStatus", () => { ).toBe("healthy") }) + it("names too few samples as the reason, not no data, when every skip saw some", () => { + const rule = makeRule({ minimumSampleCount: 20 }) + const fewSamples = derive({ + rule, + states: [makeState({ last_status: "skipped", last_value: 0.063, last_sample_count: 4 })], + }) + expect(fewSamples.status).toBe("few-samples") + expect(fewSamples.reason).toBe("Every recent check had under 20 samples") + expect(needsAttention(fewSamples)).toBe(true) + + // An empty window counts zero samples: that is no data, whatever the minimum. + const empty = derive({ + rule, + states: [makeState({ last_status: "skipped", last_value: null, last_sample_count: 0 })], + }) + expect(empty.status).toBe("no-data") + expect(empty.reason).toBe("All recent checks were skipped (no data)") + + const mixed = derive({ + rule, + states: [ + makeState({ last_status: "skipped", last_sample_count: 4 }), + makeState({ + group_key: "svc-b", + last_status: "skipped", + last_value: null, + last_sample_count: 0, + }), + ], + }) + expect(mixed.status).toBe("no-data") + expect(mixed.reason).toBe( + "All recent checks were skipped: 1 group under 20 samples, the rest with no data", + ) + }) + it("flags missing destinations and delivery failures as attention", () => { const result = derive({ rule: makeRule({ destinationIds: [] }), diff --git a/packages/web-data/src/alerts/rule-status.ts b/packages/web-data/src/alerts/rule-status.ts index 7b7113da0a..010df6370c 100644 --- a/packages/web-data/src/alerts/rule-status.ts +++ b/packages/web-data/src/alerts/rule-status.ts @@ -7,9 +7,9 @@ import { Array as Arr, Option } from "effect" /** * Derived health of an alert rule, in display-priority order: - * disabled → firing → error → stale → no-data → healthy. + * disabled → firing → error → stale → few-samples → no-data → healthy. */ -export type RuleStatus = "disabled" | "firing" | "error" | "stale" | "no-data" | "healthy" +export type RuleStatus = "disabled" | "firing" | "error" | "stale" | "few-samples" | "no-data" | "healthy" export interface RuleAttention { /** Enabled but routed nowhere — it can fire but nobody gets told. */ @@ -76,6 +76,19 @@ export function worstState(states: ReadonlyArray): AlertRuleS return best } +/** + * A skipped state that saw data, just less than the rule's minimum. The state row keeps no skip + * reason, but a no-data window counts zero samples and a below-minimum one counts at least one. + */ +export const isBelowMinSamples = ( + rule: Pick, + state: Pick, +): boolean => + state.last_status === "skipped" && + state.last_sample_count !== null && + state.last_sample_count > 0 && + state.last_sample_count < rule.minimumSampleCount + /** * Derive the display status for a rule from everything the client knows about * it: the rule document, its live-synced per-group states (may be empty on @@ -132,10 +145,24 @@ export function deriveRuleStatus(input: { } } - // No data only when we have state rows and ALL of them last skipped — + // Skipped only when we have state rows and ALL of them last skipped: // a single skipped group among healthy ones is normal for grouped rules. if (states.length > 0 && Arr.every(states, (s) => s.last_status === "skipped")) { - return { status: "no-data", attention, reason: "All recent checks were skipped (no data)" } + const fewSamples = Arr.filter(states, (s) => isBelowMinSamples(rule, s)).length + if (fewSamples === states.length) + return { + status: "few-samples", + attention, + reason: `Every recent check had under ${rule.minimumSampleCount} samples`, + } + return { + status: "no-data", + attention, + reason: + fewSamples === 0 + ? "All recent checks were skipped (no data)" + : `All recent checks were skipped: ${countLabel(fewSamples, "group")} under ${rule.minimumSampleCount} samples, the rest with no data`, + } } return { status: "healthy", attention, reason: null } @@ -146,6 +173,7 @@ export function needsAttention(derived: DerivedRuleStatus): boolean { return ( derived.status === "error" || derived.status === "stale" || + derived.status === "few-samples" || derived.status === "no-data" || derived.attention.noDestinations || derived.attention.recentDeliveryFailure || From 145043e249ca2ab2b4a0fca6a257bdeed9d9302f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 12:36:49 +0100 Subject: [PATCH 2/2] chore(views): regenerate the kit index for switchDataSource --- packages/views/src/ui/INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index e0cbd8de00..fbb81c5399 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -204,7 +204,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/peek-keys.ts` `peekStepFor`: The list peeks' step keys: down/J forward, up/K back, except in a text field or with a modifier. - `apps/web/src/page/kit/pick-list.ts` `pickList`: `PickList`: what a form has picked (destinations, people, schedules), each removable and, in an ordered list, movable, then an "Add" menu of the rest. - `apps/web/src/page/kit/pinned-namespace-notice.ts` `pinnedNamespaceNotice`: `PinnedNamespaceNotice`: replaces a rail's Namespace section while the org-global pin is on. -- `apps/web/src/page/kit/query-builder/change.ts` `withRootOnly`, `applyQueryChange`: One edit a `QueryPanel` makes to its draft (its `onUpdate`, `onAggregationChange` and `onDataSourceChange` callbacks), as data the host applies. +- `apps/web/src/page/kit/query-builder/change.ts` `withRootOnly`, `switchDataSource`, `applyQueryChange`: One edit a `QueryPanel` makes to its draft (its `onUpdate`, `onAggregationChange` and `onDataSourceChange` callbacks), as data the host applies. - `apps/web/src/page/kit/query-builder/draft.ts` `groupBySelectionOf`: `QueryBuilderQueryDraft` (@maple/query-engine/query-builder) as a Schema: the query one `QueryPanel` edits, shared by the widget editor and the alert rule editor. - `apps/web/src/page/kit/query-builder/metric-options.ts` `metricValueOf`, `listMetricOptions`, `readMetricKeys`, `unaskedMetrics`, `pickedMetric`: The metric picker's reads, shared by every host of a metrics `QueryPanel`: one page of the org's metrics for what was searched, and each picked metric's own keys. - `apps/web/src/page/kit/query-builder/metrics-body.ts` `groupByChoicesOf`, `metricsBody`: `MetricsBody`: the metric picker, the where clause, and the two aggregation stages: each series reduced within the interval, then the series combined by any number of fields.