Skip to content
Merged
14 changes: 13 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@
"overrides": [
// The dashboard's design rules (docs/design-rules.md), on the apps and their kit.
{
"files": ["apps/web/src/**", "apps/local-ui/src/**", "packages/views/src/**", "packages/ui/src/**"],
"files": [
"apps/web/src/**",
"apps/local-ui/src/**",
"packages/views/src/**",
"packages/ui/src/**"
],
"rules": {
"maple/couldnt-copy": "error",
"maple/hover-needs-active": "error",
Expand Down Expand Up @@ -194,6 +199,13 @@
"typescript/no-non-null-assertion": "off"
}
},
// Electric row mappers run inside live-query callbacks: a throwing decode there takes the
// page down when a newer server syncs a value this build does not know.
{
"files": ["packages/web-data/src/collections/**/*.ts"],
"excludeFiles": ["packages/web-data/src/collections/**/*.test.ts"],
"rules": { "maple/no-throwing-live-row-decode": "error" }
},
// `Date.parse` burndown: the server side is clean and held to the rule. These still parse
// with it and move to `parseWarehouseDateTime` / `DateTime` in their own audit; the
// implementation of `parseWarehouseDateTime` itself is the one permanent entry.
Expand Down
10 changes: 6 additions & 4 deletions apps/web/src/page/alerts/editor/derive-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import type { AlertRuleRow } from "@maple/web-data/collections/alerts"
import {
buildRuleStatesByRuleId,
rowsToAlertDestinationDocuments,
rowToAlertIncidentDocument,
rowToAlertRuleDocument,
rowsToAlertIncidentDocuments,
rowsToAlertRuleDocuments,
} from "@maple/web-data/collections/alerts"
import { normalizeTimestampInput } from "@maple/web-data/lib/timezone-format"
import { AsyncData } from "foldkit"
Expand All @@ -28,7 +28,9 @@ export const tagSuggestionsOf = (
rows: ReadonlyArray<AlertRuleRow>,
previous: ReadonlyArray<string>,
): ReadonlyArray<string> => {
const tags = [...new Set(Arr.flatMap(rows, (row) => rowToAlertRuleDocument(row, NO_STATES).tags))].sort()
const tags = [
...new Set(Arr.flatMap(rowsToAlertRuleDocuments(rows, NO_STATES), (rule) => rule.tags)),
].sort()
return tags.length === previous.length && Arr.every(tags, (tag, index) => tag === previous[index])
? previous
: tags
Expand All @@ -41,7 +43,7 @@ export const openIncidentsOf = (
model.editingRuleId === null
? []
: Arr.filter(
Arr.map(model.incidents ?? [], rowToAlertIncidentDocument),
rowsToAlertIncidentDocuments(model.incidents ?? []),
(incident) => incident.ruleId === model.editingRuleId && incident.status === "open",
)

Expand Down
21 changes: 12 additions & 9 deletions apps/web/src/page/alerts/editor/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { Model, Notice } from "./model"
export type Initialization =
| { readonly _tag: "Loading" }
| { readonly _tag: "Failed" }
| { readonly _tag: "Unreadable" }
| {
readonly _tag: "Ready"
readonly key: string
Expand Down Expand Up @@ -57,24 +58,26 @@ const fromBase64Url = (input: string): Option.Option<string> =>
Result.getSuccess(Base64Url.decodeString(input))
export const decodeChartParam = (raw: string) => Option.flatMap(fromBase64Url(raw), decodeChart)

/** The rule being edited, from the live rows. */
export const editingRuleOf = (model: Model, ruleId: string): AlertRuleDocument | null => {
return Option.match(
/** The rule being edited, from the live rows: `None` when no row has its id. */
export const editingRuleOf = (
model: Model,
ruleId: string,
): Option.Option<Option.Option<AlertRuleDocument>> =>
Option.map(
Arr.findFirst(model.rules ?? [], (candidate) => candidate.id === ruleId),
{
onNone: () => null,
onSome: (row) => rowToAlertRuleDocument(row, buildRuleStatesByRuleId(model.states ?? [])),
},
(row) => rowToAlertRuleDocument(row, buildRuleStatesByRuleId(model.states ?? [])),
)
}

export const initialization = (model: Model, draftId: string): Initialization => {
const search = model.search
const base = baseForm(search.serviceName, draftId)
if (search.ruleId !== undefined) {
// Terminal: an edit must not silently turn into a create, so the page stops here.
if (model.rules === null) return model.isRulesLoadFailed ? { _tag: "Failed" } : { _tag: "Loading" }
const rule = editingRuleOf(model, search.ruleId)
const found = editingRuleOf(model, search.ruleId)
// Terminal too: a rule a newer build saved must not open as a blank create that duplicates it.
if (Option.exists(found, Option.isNone)) return { _tag: "Unreadable" }
const rule = Option.getOrNull(Option.flatten(found))
if (rule !== null) {
const form = fromFormState(ruleToFormState(rule))
return ready(
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/page/alerts/editor/editor.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,34 @@ describe("alert rule editor", () => {
expect(answered.editingRuleId).toBe(ruleId(2))
})

test("editing a rule a newer build saved stops with a reload instead of a blank create", () => {
const model = run(entered({ ruleId: ruleId(2) }), [
Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }),
Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2, { signal_type: "a_future_signal" })] }),
])
expect(model.form).toBeNull()
expect(model.status).toBe("Unreadable")
scene(
{ update: pageUpdate, view },
given(model),
themeResolved(),
expectView(text("This rule needs a newer version of Maple")).toExist(),
click(role("button", { name: "Try again" })),
Command.expectHas(ReloadPage()),
Command.resolve(ReloadPage, Message.ReloadedPage()),
)
})

test("a rule a newer build rewrites while it is open stops the editor instead of saving over it", () => {
const open = withLists(entered({ ruleId: ruleId(2) }))
expect(open.form).not.toBeNull()
const rewritten = run(open, [
Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2, { signal_type: "a_future_signal" })] }),
])
expect(rewritten.form).toBeNull()
expect(rewritten.status).toBe("Unreadable")
})

test("a widget link without its snapshot prefills from the dashboard, or says it could not load", () => {
const search = { dashboardId: "dash-1", widgetId: "w-1" }
expect(withLists(entered(search)).form).toBeNull()
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/page/alerts/editor/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ export const Notice = Schema.Struct({
})
export type Notice = typeof Notice.Type

/** `RuleInitialization`: the draft exists only once ready; `Failed` is the rules sync that never answered. */
export const Status = Schema.Literals(["Loading", "Ready", "Failed"])
/**
* `RuleInitialization`: the draft exists only once ready; `Failed` is the rules sync that never
* answered, `Unreadable` a rule this build cannot read (a newer build saved it).
*/
export const Status = Schema.Literals(["Loading", "Ready", "Failed", "Unreadable"])
export type Status = typeof Status.Type

/** A dry run's verdict, tagged with the rule it was produced from (`previewIdentityKey`). */
Expand Down
7 changes: 2 additions & 5 deletions apps/web/src/page/alerts/editor/plan.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { pickDefaultDestination } from "@maple/web-data/alerts/form-utils"
import { buildRuleStatesByRuleId, rowToAlertRuleDocument } from "@maple/web-data/collections/alerts"
import { buildRuleStatesByRuleId, rowsToAlertRuleDocuments } from "@maple/web-data/collections/alerts"
import { Option, Array as Arr } from "effect"
import type { Command } from "foldkit"
import * as Keyed from "@maple/views/data/keyed"
Expand Down Expand Up @@ -136,10 +136,7 @@ export const applyDefaultDestination = (model: Model): Model => {
)
return model
const states = buildRuleStatesByRuleId(model.states ?? [])
const fallback = pickDefaultDestination(
destinations,
Arr.map(model.rules, (row) => rowToAlertRuleDocument(row, states)),
)
const fallback = pickDefaultDestination(destinations, rowsToAlertRuleDocuments(model.rules, states))
const form = model.form
// A tier with a chain is routed already; it gets no default destination on top.
const isApplied = fallback !== null && form.destinationIds.length === 0 && form.escalationChainId === null
Expand Down
20 changes: 15 additions & 5 deletions apps/web/src/page/alerts/editor/steps.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Command } from "foldkit"
import * as Dialog from "@maple/views/ui/dialog"
import type { PageContext, PageReturn } from "../../contract"
import { initialization } from "./draft"
import { editingRuleOf, initialization } from "./draft"
import type { RuleForm } from "./form"
import { Message } from "./message"
import type { Model } from "./model"
import { applyDefaultDestination, planMetrics, planPreviews } from "./plan"
import { String as Str } from "effect"
import { Option, String as Str } from "effect"

export type Return = PageReturn<Model, Message>

Expand All @@ -27,7 +27,8 @@ export const initialize = (model: Model, context: PageContext): Return => {
if (model.form !== null || model.draftId === null) return { model }
Comment thread
maple-review-bot[bot] marked this conversation as resolved.
Resolved
const draft = initialization(model, model.draftId)
if (draft._tag === "Loading") return { model }
if (draft._tag === "Failed") return { model: { ...model, status: "Failed" } }
if (draft._tag === "Failed" || draft._tag === "Unreadable")
return { model: { ...model, status: draft._tag } }
const form = draft.form
const ready: Model = {
...model,
Expand All @@ -53,5 +54,14 @@ export const editForm = (model: Model, context: PageContext, edit: (form: RuleFo
model.form === null ? { model } : planned({ ...model, form: edit(model.form) }, context)

/** A list answered: the draft may now be known, and the default destination picked. */
export const followRows = (model: Model, context: PageContext): Return =>
model.form === null ? initialize(model, context) : { model: applyDefaultDestination(model) }
export const followRows = (model: Model, context: PageContext): Return => {
if (model.form === null) return initialize(model, context)
// A newer build rewrote the open rule into something this one cannot read: saving the stale
// form would write the old values back over it, so the editor stops as on open.
if (
model.editingRuleId !== null &&
Option.exists(editingRuleOf(model, model.editingRuleId), Option.isNone)
)
return { model: { ...model, form: null, status: "Unreadable" } }
return { model: applyDefaultDestination(model) }
}
23 changes: 22 additions & 1 deletion apps/web/src/page/alerts/editor/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ const loadError = (h: H): Html =>
],
)

/** A rule this build cannot read: editing it here would save over fields it does not know. */
const unreadableRule = (h: H): Html =>
h.div(
[h.Class(cn("mx-auto w-full", RULE_FORM_MAX_WIDTH))],
[
errorState(h, {
failure: toLoadFailure(null),
title: "This rule needs a newer version of Maple",
message: "It was saved by a newer version than this tab runs. Reload to edit it.",
onRetry: Message.ClickedReloadPage(),
className: "py-12",
}),
],
)

/** `WidgetPrefillNoticeBanner`. */
const noticeBanner = (h: H, notices: ReadonlyArray<Notice>): ReadonlyArray<Html> =>
notices.length === 0
Expand Down Expand Up @@ -220,7 +235,13 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model,
{},
form !== null
? surface(h, model, form, inputs.context.timeZone)
: [model.status === "Failed" ? loadError(h) : formSkeleton(h)],
: [
model.status === "Failed"
? loadError(h)
: model.status === "Unreadable"
? unreadableRule(h)
: formSkeleton(h),
],
),
dialogSlot(destinationDialog, [h, model.destinationDialog]),
],
Expand Down
17 changes: 7 additions & 10 deletions apps/web/src/page/alerts/hub/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { type AlertsOverviewReady, deriveOverview } from "@maple/web-data/alerts
import {
buildRuleStatesByRuleId,
rowsToAlertDestinationDocuments,
rowToAlertIncidentDocument,
rowToAlertRuleDocument,
rowsToAlertIncidentDocuments,
rowsToAlertRuleDocuments,
} from "@maple/web-data/collections/alerts"
import { Option, Array as Arr, String as Str } from "effect"
import { AsyncData } from "foldkit"
Expand All @@ -27,8 +27,8 @@ const overviewFor = lastBy(
const statesByRule = buildRuleStatesByRuleId(stateRows)
return Option.some(
deriveOverview({
rules: Arr.map(rules, (row) => rowToAlertRuleDocument(row, statesByRule)),
incidents: Arr.map(incidents, rowToAlertIncidentDocument),
rules: rowsToAlertRuleDocuments(rules, statesByRule),
incidents: rowsToAlertIncidentDocuments(incidents),
states: stateRows,
deliveryEvents: Option.getOrElse(AsyncData.getData(deliveries), () => []),
now: nowMs,
Expand Down Expand Up @@ -66,17 +66,14 @@ const anomalyOverviewFor = lastBy(
const stateRows = states ?? []
const statesByRule = buildRuleStatesByRuleId(stateRows)
const anomalyIncidents = Arr.filter(
Arr.map(
Arr.filter(incidents, (row) => row.signal_type === "anomaly"),
rowToAlertIncidentDocument,
),
rowsToAlertIncidentDocuments(Arr.filter(incidents, (row) => row.signal_type === "anomaly")),
(incident) => serviceName === undefined || isAboutService(incident, serviceName),
)
return Option.some(
deriveOverview({
rules: Arr.map(
rules: rowsToAlertRuleDocuments(
Arr.filter(rules, (row) => row.signal_type === "anomaly"),
(row) => rowToAlertRuleDocument(row, statesByRule),
statesByRule,
),
incidents: anomalyIncidents,
states: stateRows,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/page/alerts/hub/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -822,7 +822,7 @@ const pickedFromMenu = (model: Model, context: PageContext, menuId: string, valu
if (kind === "schedule") return pickedScheduleAction(model, target, value)
if (kind === "rule") {
const id = Str.split("@")(target)[0]
const rule = Option.map(
const rule = Option.flatMap(
Array.findFirst(model.rules ?? [], (row) => row.id === id),
(row) => rowToAlertRuleDocument(row, new Map()),
)
Expand Down
30 changes: 22 additions & 8 deletions apps/web/src/page/alerts/incident/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import type { V2AlertRulePreviewParams } from "@maple/domain/http/v2"
import { formatWarehouseDateTime } from "@maple/query-engine"
import { ruleToFormState } from "@maple/web-data/alerts/form-utils"
import {
type AlertIncidentRow,
type AlertRuleStateRow,
buildRuleStatesByRuleId,
rowsToAlertIncidentDocuments,
rowToAlertIncidentDocument,
rowToAlertRuleDocument,
} from "@maple/web-data/collections/alerts"
Expand Down Expand Up @@ -62,30 +64,42 @@ const deriveIncidentView = (model: ViewKey): Option.Option<IncidentView> => {
const foundIncident = Arr.findFirst(model.incidents, (row) => row.id === model.incidentId)
if (Option.isNone(foundIncident)) return Option.none()
const incidentRow = foundIncident.value
const now = model.nowMs
// Only this incident's rule is decoded: the org's other rules and incidents are never shown here.
const incident = rowToAlertIncidentDocument(incidentRow)
// An incident this build cannot read renders as not found rather than crashing the page.
return Option.flatMap(rowToAlertIncidentDocument(incidentRow), (incident) =>
incidentView(model, incidentRow, incident, now),
)
}

const incidentView = (
model: ViewKey,
incidentRow: AlertIncidentRow,
incident: AlertIncidentDocument,
now: number,
): Option.Option<IncidentView> => {
const rule = Option.match(
Arr.findFirst(model.rules ?? [], (row) => row.id === incidentRow.rule_id),
{
onNone: () => null,
onSome: (ruleRow) =>
rowToAlertRuleDocument(
ruleRow,
buildRuleStatesByRuleId(ruleStatesOf(model, incidentRow.rule_id)),
Option.getOrNull(
rowToAlertRuleDocument(
ruleRow,
buildRuleStatesByRuleId(ruleStatesOf(model, incidentRow.rule_id)),
),
),
},
)
const now = model.nowMs
const start = isoMs(incident.firstTriggeredAt)
const pad = Math.max(30, (rule?.windowMinutes ?? 5) * 4) * MINUTE_MS
const end = incident.resolvedAt != null ? Math.min(isoMs(incident.resolvedAt) + pad, now) : now
const range = { start: start - pad, end: Math.max(end, start + MINUTE_MS) }
return Option.some({
incident,
rule,
ruleIncidents: Arr.map(
Arr.filter(model.incidents, (row) => row.rule_id === incidentRow.rule_id),
rowToAlertIncidentDocument,
ruleIncidents: rowsToAlertIncidentDocuments(
Arr.filter(model.incidents ?? [], (row) => row.rule_id === incidentRow.rule_id),
),
range,
// An anomaly rule records only severity codes: the detector's own series is the evidence.
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/page/alerts/incident/incident.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { OrgId } from "@maple/domain"
import * as Policy from "@maple/domain/policy"
import { AlertRuleId, AlertRulePreviewResponse, AlertSilenceId } from "@maple/domain/http"
import { sloRuleName } from "@maple/domain/slo"
import { Result, Schema, Array as Arr } from "effect"
import { Option, Result, Schema, Array as Arr } from "effect"
import {
click,
Command,
Expand Down Expand Up @@ -518,7 +518,7 @@ describe("alert incident", () => {
created_at: at,
})

const synced = rowToAlertIncidentDocument(incidentRow())
const synced = Option.getOrThrow(rowToAlertIncidentDocument(incidentRow()))
const activityKey = (acknowledgedAt: string | null) =>
requestKey(ORG, "alertIncidents.listEvents", {
id: INCIDENT_ID,
Expand Down
Loading
Loading