Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
4272aa6
feat(alerts): anomalies page through alert rules
Makisuo Nov 6, 2026
816b9ce
Merge remote-tracking branch 'origin/main' into feat/alerts-unified-i…
Makisuo Nov 6, 2026
b518097
feat(alerts): silences match an anomaly incident's service and enviro…
Makisuo Nov 6, 2026
dd25362
feat(web): anomalies live in the alerts hub
Makisuo Nov 6, 2026
d32578e
fix(alerts): review fixes for anomaly rules
Makisuo Nov 6, 2026
ba6f119
style(alerts): format review fixes
Makisuo Nov 6, 2026
67a2f17
Merge branch 'feat/alerts-unified-incidents' into feat/alerts-inciden…
Makisuo Nov 6, 2026
014359f
Merge branch 'feat/alerts-incident-labels' into feat/alerts-unified-ui
Makisuo Nov 6, 2026
5cd792c
fix(alerts): only Maple creates the default anomaly rule
Makisuo Nov 6, 2026
806ed4a
Merge branch 'feat/alerts-incident-labels' into feat/alerts-unified-ui
Makisuo Nov 6, 2026
8085b14
Merge branch 'feat/alerts-unified-incidents' into feat/alerts-inciden…
Makisuo Nov 6, 2026
30aab29
fix(web): anomaly editor fixes from the preview test
Makisuo Nov 6, 2026
1351130
Merge remote-tracking branch 'origin/main' into feat/alerts-unified-i…
Makisuo Nov 6, 2026
ce36af7
Merge branch 'feat/alerts-unified-incidents' into feat/alerts-inciden…
Makisuo Nov 6, 2026
2b42ce3
Merge branch 'feat/alerts-incident-labels' into feat/alerts-unified-ui
Makisuo Nov 6, 2026
d4fd896
test(alerts): scope the anomaly silences test rule now that only Mapl…
Makisuo Nov 6, 2026
a1f94c9
Merge branch 'feat/alerts-incident-labels' into feat/alerts-unified-ui
Makisuo Nov 6, 2026
0901897
docs(domain): keep the hold reason comment on its schema
Makisuo Nov 6, 2026
cf004c9
Merge branch 'feat/alerts-incident-labels' into feat/alerts-unified-ui
Makisuo Nov 6, 2026
720e8cf
fix(alerts): templates show an anomaly's severity as a word
Makisuo Nov 6, 2026
1d1e149
Merge branch 'feat/alerts-unified-incidents' into feat/alerts-inciden…
Makisuo Nov 6, 2026
0ec32a1
Merge branch 'feat/alerts-incident-labels' into feat/alerts-unified-ui
Makisuo Nov 6, 2026
62a23d0
fix(web): the anomalies open view reads open incidents only
Makisuo Nov 6, 2026
c62cebf
fix(alerts): the default anomaly rule message names actions that exist
Makisuo Nov 6, 2026
e6381ad
Merge branch 'feat/alerts-unified-incidents' into feat/alerts-inciden…
Makisuo Nov 6, 2026
7534186
Merge branch 'feat/alerts-incident-labels' into feat/alerts-unified-ui
Makisuo Nov 6, 2026
68df751
fix(alerts): anomaly rule test runs and MCP text
Makisuo Nov 6, 2026
6b79565
fix(alerts): default rule message says scoped rules still page
Makisuo Nov 6, 2026
54e709a
Merge branch 'feat/alerts-unified-incidents' into feat/alerts-inciden…
Makisuo Nov 6, 2026
1acf1cb
Merge branch 'feat/alerts-incident-labels' into feat/alerts-unified-ui
Makisuo Nov 6, 2026
395c4c4
fix(web): anomaly view counts and copy follow the service filter; no …
Makisuo Nov 6, 2026
adb588e
Merge remote-tracking branch 'origin/main' into feat/alerts-unified-i…
Makisuo Nov 6, 2026
48582b6
chore(db): merge the anomaly rules and escalation lease migrations
Makisuo Nov 6, 2026
b362bef
Merge branch 'feat/alerts-unified-incidents' into feat/alerts-inciden…
Makisuo Nov 6, 2026
e40ff81
chore(db): merge the incident labels migration with main
Makisuo Nov 6, 2026
93c6655
Merge branch 'feat/alerts-incident-labels' into feat/alerts-unified-ui
Makisuo Nov 6, 2026
6ada386
fix(web): the default anomaly rule's empty routing is not a warning
Makisuo Nov 6, 2026
bd86ddc
fix(alerts): anomaly rules record that they ran
Makisuo Nov 6, 2026
6204940
fix(alerts): unrouted anomaly tiers never push; disabling detection r…
Makisuo Nov 6, 2026
97db19f
Merge remote-tracking branch 'origin/feat/alerts-unified-incidents' i…
Makisuo Nov 6, 2026
e4f9529
Merge branch 'feat/alerts-incident-labels' into feat/alerts-unified-ui
Makisuo Nov 6, 2026
9ae96ab
fix(web): anomaly rule page and editor fixes from the preview test
Makisuo Nov 6, 2026
8a1707e
test(web): the unrouted anomaly test removes a destination the rule had
Makisuo Nov 6, 2026
de432af
Merge remote-tracking branch 'origin/main' into feat/alerts-unified-ui
Makisuo Nov 6, 2026
1325efa
chore(web-data): keep the shared default anomaly rule helper after th…
Makisuo Nov 6, 2026
6ff79c0
chore: rerun CI against main after the base change
Makisuo Nov 6, 2026
19a6286
fix(web): anomaly UI fixes from review
Makisuo Nov 6, 2026
1fb0219
fix(web): anomaly investigations get the detector's readings, not sev…
Makisuo Nov 6, 2026
108c84e
Merge remote-tracking branch 'origin/main' into feat/alerts-unified-ui
Makisuo Nov 6, 2026
2de40ce
fix(web): resolved anomalies read "just now"; test both tiers' destin…
Makisuo Nov 6, 2026
5301042
fix(web): an anomaly investigation never sends readings from evidence…
Makisuo Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
450 changes: 450 additions & 0 deletions apps/web/src/lab/anomaly-alerts-lab.ts

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions apps/web/src/lab/entries/anomaly-alerts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineShellLab } from "../shell-lab"
import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../anomaly-alerts-lab"

// `/lab/anomaly-alerts`: anomaly rules in the editor, an anomaly rule's incident and the Inbox's
// Anomalies view over fixtures (`?state=`).

export const lab = defineShellLab("Anomaly alerts", {
Model,
Message,
Flags,
flags,
init,
update,
view,
currentPath: "/lab/anomaly-alerts",
breadcrumbs,
})
34 changes: 1 addition & 33 deletions apps/web/src/lab/entries/empty-states.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { OrgId } from "@maple/domain"
import { Clock, Effect, Result, Schema, Array as Arr } from "effect"
import { Clock, Effect, Result, Schema } from "effect"
import { AsyncData, Submodel } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
import { defineMessageUnion } from "foldkit/message"
Expand All @@ -8,10 +8,6 @@ import * as AgentSessionMessage from "../../page/agent-sessions/session/message"
import { dividerBlock } from "../../page/agent-sessions/session/transcript-lanes"
import { init as initSession } from "../../page/agent-sessions/session/update"
import { loadIndicator } from "../../page/agent-sessions/session/view-load"
import * as AnomaliesMessage from "../../page/anomalies/list/message"
import * as Anomalies from "../../page/anomalies/list/model"
import { init as initAnomalies, update as updateAnomalies } from "../../page/anomalies/list/update"
import { view as anomaliesView } from "../../page/anomalies/list/view"
import { notFoundContent } from "../../page/chrome"
import type { PageContext } from "../../page/contract"
import * as ErrorsMessage from "../../page/errors/hub/message"
Expand Down Expand Up @@ -40,8 +36,6 @@ export const ITEMS = [
"releases-first-run",
"releases-quiet",
"releases-filtered",
"anomalies-first-run",
"anomalies-loading",
"session-rest-failed",
"recommendations-loading",
"not-found",
Expand Down Expand Up @@ -124,23 +118,6 @@ const releasesModel = (
return { ...loaded, presence: presenceOf(presence) }
}

const anomaliesModel = (nowMs: number, presence: Presence | null): Anomalies.Model => {
const entered = initAnomalies({ search: {} }, context).model
if (presence === null) return entered
const loaded = Arr.reduce(
[
AnomaliesMessage.Message.ReadClock({ nowMs }),
AnomaliesMessage.Message.CompletedFetchFirstPage({
key: entered.firstPage.key ?? "",
result: Result.succeed({ data: [], nextCursor: null }),
}),
],
entered,
(model, message) => updateAnomalies(model, message, context).model,
)
return { ...loaded, presence: presenceOf(presence) }
}

const FAILED_PROGRESS = {
phase: "failed",
agentSpansComplete: false,
Expand Down Expand Up @@ -199,15 +176,6 @@ const itemView = (model: Model, h: HtmlBuilder<Message>): Html => {
toParentMessage: ignore,
viewInputs: { context },
})
case "anomalies-first-run":
case "anomalies-loading":
return h.submodel({
slotId: "lab-anomalies",
model: anomaliesModel(nowMs, model.item === "anomalies-first-run" ? "absent" : null),
view: anomaliesView,
toParentMessage: ignore,
viewInputs: { context },
})
case "session-rest-failed":
return h.submodel({
slotId: "lab-session",
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/lab/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ export const LAB_ENTRIES: ReadonlyArray<LabEntry> = [
kind: "lab",
session: "none",
},
{
path: "/lab/anomaly-alerts",
title: "Anomaly alerts",
description:
"Anomaly rules in the rule editor (scoped and the protected default), an anomaly rule's incident with the detector's evidence, and the Inbox's Anomalies view (`?state=`).",
kind: "lab",
session: "none",
},
{
path: "/lab/billing-orgs",
title: "Billing orgs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe("email member picker", () => {
},
given(model),
expectView(text("Ada Lovelace")).toExist(),
expectView(role("button", { name: "Remove" })).toExist(),
expectView(role("button", { name: /^Remove / })).toExist(),
)
})
})
30 changes: 23 additions & 7 deletions apps/web/src/page/alerts/editor/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Result } from "effect"
import { PageOutMessage } from "../../contract"
import { alertsHref } from "../shared/links"
import { RunRuleTest, SaveRule, TrackRuleCreated } from "./command"
import { previewIdentityKey, saveIssues } from "./derive"
import { isProtectedDefault, previewIdentityKey, saveIssues } from "./derive"
import { draftTimingOf } from "./derive-rows"
import type { Message } from "./message"
import type { Model } from "./model"
Expand All @@ -23,15 +23,22 @@ export const runTest = (model: Model, isSending: boolean): Return => {
const form = model.form
if (form === null || model.isDryRunning || model.isSendingTest) return { model }
if (!isRulePreviewReady(form))
return { model, outMessage: errorToast("Complete the rule name, query, and threshold before testing") }
return {
model,
outMessage: errorToast("Complete the rule name, query, and threshold before testing"),
}
return {
model: isSending ? { ...model, isSendingTest: true } : { ...model, isDryRunning: true },
commands: [RunRuleTest({ key: previewIdentityKey(form), payload: buildRuleTestParamsV2(form, isSending) })],
commands: [
RunRuleTest({ key: previewIdentityKey(form), payload: buildRuleTestParamsV2(form, isSending) }),
],
}
}

export const completeTest = (model: Model, message: Extract<Message, { _tag: "CompletedTest" }>): Return => {
const settled: Model = message.isSending ? { ...model, isSendingTest: false } : { ...model, isDryRunning: false }
const settled: Model = message.isSending
? { ...model, isSendingTest: false }
: { ...model, isDryRunning: false }
return Result.match(message.result, {
onSuccess: (dryRun) => ({
model: { ...settled, dryRun },
Expand All @@ -41,15 +48,22 @@ export const completeTest = (model: Model, message: Extract<Message, { _tag: "Co
description: "",
}),
}),
onFailure: (error) => ({ model: settled, outMessage: errorToast("Couldn't preview rule", error.message) }),
onFailure: (error) => ({
model: settled,
// A failed Send test is a delivery failure, not a preview one: say which.
outMessage: errorToast(
message.isSending ? "Couldn't send test notification" : "Couldn't preview rule",
error.message,
),
}),
})
}

/** `handleSave`: the ready form as create (or update) params; success lands on `/alerts`. */
export const save = (model: Model): Return => {
const form = model.form
if (form === null || model.isSaving) return { model }
if (saveIssues(form, draftTimingOf(model, form)).length > 0) return { model }
if (saveIssues(form, draftTimingOf(model, form), isProtectedDefault(model)).length > 0) return { model }
return {
model: { ...model, isSaving: true },
commands: [SaveRule({ ruleId: model.editingRuleId, payload: buildRuleCreateParamsV2(form) })],
Expand All @@ -61,7 +75,9 @@ export const completeSave = (model: Model, message: Extract<Message, { _tag: "Co
onSuccess: () => ({
model: { ...model, isSaving: false },
commands:
model.editingRuleId === null && model.form !== null ? [TrackRuleCreated({ signal: model.form.signalType })] : [],
model.editingRuleId === null && model.form !== null
? [TrackRuleCreated({ signal: model.form.signalType })]
: [],
outMessage: PageOutMessage.RequestedNavigation({
url: alertsHref({}),
isReplace: false,
Expand Down
42 changes: 38 additions & 4 deletions apps/web/src/page/alerts/editor/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
signalLabels,
thresholdUnitLabel,
} from "@maple/web-data/alerts/form-utils"
import { anomalyScopeSummary, isDefaultAnomalyRule } from "@maple/web-data/alerts/anomaly-rule"
import { destinationProvider } from "../shared/providers"
import type { RuleForm } from "./form"
import { intervalWindowIssue, queryTimingIssue } from "./interval"
Expand All @@ -19,6 +20,12 @@

// The rule editor's pure helpers: form surface, condition bar, live chart hero, notifications.

/** Editing the org's default anomaly rule: its scope, kind and enabled state are the server's. */
export const isProtectedDefault = (
model: Readonly<{ editingRuleId: string | null; savedForm: RuleForm | null }>,
): boolean =>
model.editingRuleId !== null && model.savedForm !== null && isDefaultAnomalyRule(model.savedForm)

export const COMPARATOR_WORDS = new Map<AlertComparator, string>([
["gt", "is above"],
["gte", "is at or above"],
Expand All @@ -33,6 +40,7 @@

/** What the threshold is compared against, in words: the aggregation for a query, the signal otherwise. */
export const conditionSubject = (form: RuleForm): string => {
if (form.signalType === "anomaly") return "anomalies"
if (form.signalType === "raw_query") return "the SQL value"
if (form.signalType === "builder_query") {
const aggregation = Str.trim(Str.replaceAll("_", " ")(form.queryBuilderDraft.aggregation))
Expand All @@ -42,7 +50,13 @@
}

/** The rule in one plain sentence, shown in the action bar. */
export const describeRuleCondition = (form: RuleForm): string => {
export const describeRuleCondition = (form: RuleForm, isDefault = false): string => {
if (form.signalType === "anomaly")
return isDefault
? "Pages on every anomaly no narrower rule handles"
: isDefaultAnomalyRule(form)
? "Anomalies in a service, environment or signal"
: `Pages on anomalies in ${anomalyScopeSummary(form)}`
const unit = thresholdUnitLabel(form) ?? ""
const value = (raw: string) => `${Str.trim(raw) || "?"}${unit === "%" ? "%" : unit ? ` ${unit}` : ""}`
const threshold = isRangeComparator(form.comparator)
Expand Down Expand Up @@ -80,7 +94,8 @@
}

/** `deriveValidationIssues`: what the action bar lists as missing. */
export const validationIssues = (form: RuleForm): ReadonlyArray<string> => {
export const validationIssues = (form: RuleForm, isDefault = false): ReadonlyArray<string> => {
if (form.signalType === "anomaly") return anomalyIssues(form, isDefault)
const issues: Array<string> = []
if (Str.trim(form.name).length === 0) issues.push("Rule name")
if (!isFilledNumber(form.threshold)) issues.push("Threshold")
Expand Down Expand Up @@ -110,10 +125,28 @@
return issues
}

/**
* An anomaly rule has no query or timing to get wrong: a name, a scope and someone to page. Only
* Maple creates the unscoped default rule, which may stay unrouted (anomaly paging is opt-in); the
* server refuses any other unscoped or unrouted enabled anomaly rule.
*/
const anomalyIssues = (form: RuleForm, isDefault: boolean): ReadonlyArray<string> => [
...(Str.trim(form.name).length === 0 ? ["Rule name"] : []),
...(!isDefault && isDefaultAnomalyRule(form) ? ["Scope"] : []),
...(!isDefault && form.destinationIds.length === 0 && (form.escalation?.destinationIds.length ?? 0) === 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

An anomaly rule routed by an escalation chain cannot be saved

F12 · Warning · correctness

anomalyIssues counts only destinationIds and escalation.destinationIds, while isNotifyMissingOf (editor/view.ts:112) also counts escalationChainId and escalation.escalationChainId as routing. A scoped anomaly rule whose tier starts a chain shows "Who gets notified" as done but saveIssues adds "Who gets notified", so the action bar prints "Fix before saving" and Save stays disabled.

Treat a chain as routing here too: add `form.escalationChainId === null` and `(form.escalation?.escalationChainId ?? null) === null` to the destination condition, mirroring `isNotifyMissingOf`.
🤖 Prompt to fix with an AI agent
In `apps/web/src/page/alerts/editor/derive.ts:136-138`: An anomaly rule routed by an escalation chain cannot be saved.

`anomalyIssues` counts only `destinationIds` and `escalation.destinationIds`, while `isNotifyMissingOf` (`editor/view.ts:112`) also counts `escalationChainId` and `escalation.escalationChainId` as routing. A scoped anomaly rule whose tier starts a chain shows "Who gets notified" as `done` but `saveIssues` adds "Who gets notified", so the action bar prints "Fix before saving" and Save stays disabled.

Suggested fix: Treat a chain as routing here too: add `form.escalationChainId === null` and `(form.escalation?.escalationChainId ?? null) === null` to the destination condition, mirroring `isNotifyMissingOf`.

Verify the problem exists at that location before changing it, and keep the fix to those lines.

? ["Who gets notified"]
: []),

Check warning on line 138 in apps/web/src/page/alerts/editor/derive.ts

View check run for this annotation

Maple Review Bot / Maple / review

correctness: An anomaly rule routed by an escalation chain cannot be saved

`anomalyIssues` counts only `destinationIds` and `escalation.destinationIds`, while `isNotifyMissingOf` (`editor/view.ts:112`) also counts `escalationChainId` and `escalation.escalationChainId` as routing. A scoped anomaly rule whose tier starts a chain shows "Who gets notified" as `done` but `saveIssues` adds "Who gets notified", so the action bar prints "Fix before saving" and Save stays disabled.
]

/** What blocks saving: the form's own issues and a query too slow for its interval. */
export const saveIssues = (form: RuleForm, timing: AlertQueryTiming | null): ReadonlyArray<string> => {
export const saveIssues = (
form: RuleForm,
timing: AlertQueryTiming | null,
isDefault = false,
): ReadonlyArray<string> => {
const timingIssue = queryTimingIssue(form, timing)
return timingIssue === null ? validationIssues(form) : [...validationIssues(form), timingIssue]
const issues = validationIssues(form, isDefault)
return timingIssue === null ? issues : [...issues, timingIssue]
}

/** `previewIdentityKey`: the rule as evaluated; a dry-run verdict is only shown while it matches. */
Expand All @@ -138,6 +171,7 @@
/** `makeSuggestedName`: signal and scope, offered while the name is empty. */
export const suggestedName = (form: RuleForm): string | null => {
if (Str.trim(form.name).length > 0) return null
if (form.signalType === "anomaly") return `Anomalies · ${anomalyScopeSummary(form)}`
const subject = conditionSubject(form)
const base = Str.toUpperCase(Str.slice(0, 1)(subject)) + Str.slice(1)(subject)
const queryGroupBy =
Expand Down
32 changes: 25 additions & 7 deletions apps/web/src/page/alerts/editor/dest-steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { PageOutMessage } from "../../contract"
import * as DestinationDialog from "../destination/dialog"
import * as ChatConnect from "../shared/chat-connect"
import { FetchEditorConnectors } from "./command"
import { addId, removeId, updateTierIds } from "./form-steps"
import { assignToTier, removeId, updateTierIds } from "./form-steps"
import { Message, type Tier } from "./message"
import type { Model } from "./model"
import type { Return } from "./steps"
Expand All @@ -31,7 +31,13 @@ export const liftDialog = (model: Model, step: ReturnType<typeof DestinationDial
return DestinationDialog.OutMessage.match(step.outMessage, {
CreatedDestination: ({ id }) => ({
model:
next.form === null ? next : { ...next, form: updateTierIds(next.form, "warning", addId(id)) },
next.form === null
? next
: {
...next,
autoDestinationId: null,
form: assignToTier(next.form, next.destinationTier, id),
},
commands,
outMessage: PageOutMessage.RequestedToast({
type: "success",
Expand All @@ -58,15 +64,15 @@ const picked = (model: Model, menuId: string, value: string): Return => {
model:
model.form === null
? model
: { ...model, form: updateTierIds(model.form, tier, addId(id)) },
: { ...model, autoDestinationId: null, form: assignToTier(model.form, tier, id) },
}),
})
if (kind === "type")
return Option.match(decodeType(Arr.join(rest, ":")), {
onNone: () => ({ model }),
onSome: (type) =>
liftDialog(
model,
{ ...model, destinationTier: tier },
DestinationDialog.openNew(model.destinationDialog, defaultDestinationForm(type)),
),
})
Expand All @@ -81,7 +87,7 @@ const picked = (model: Model, menuId: string, value: string): Return => {
onNone: (): Return => ({ model }),
onSome: (workspace) =>
liftDialog(
model,
{ ...model, destinationTier: tier },
DestinationDialog.openNew(
model.destinationDialog,
chatDestinationForm(connector, workspace.id),
Expand Down Expand Up @@ -127,7 +133,13 @@ export const connectorsAnswered = (model: Model, connectors: ReadonlyArray<V2Cha
onNone: () => ({ model: next }),
onSome: (linked) =>
liftDialog(
{ ...next, chatConnect: ChatConnect.finish(next.chatConnect), chatConnectMenuId: null },
{
...next,
chatConnect: ChatConnect.finish(next.chatConnect),
chatConnectMenuId: null,
destinationTier:
model.chatConnectMenuId === null ? "warning" : tierOfMenu(model.chatConnectMenuId),
},
DestinationDialog.openNew(
next.destinationDialog,
chatDestinationForm(linked.connector, linked.id),
Expand All @@ -151,4 +163,10 @@ export const menuStep = (model: Model, menuId: string, message: Menu.Message): R
export const removeDestination = (model: Model, tier: Tier, id: AlertDestinationId): Return =>
model.form === null
? { model }
: { model: { ...model, form: updateTierIds(model.form, tier, removeId(id)) } }
: {
model: {
...model,
autoDestinationId: null,
form: updateTierIds(model.form, tier, removeId(id)),
},
}
3 changes: 3 additions & 0 deletions apps/web/src/page/alerts/editor/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ export const initialization = (model: Model, draftId: string): Initialization =>
{ severity: "warning", message: "Couldn't load dashboards. Starting from a blank alert." },
])
}
// "New rule → Anomaly rule" in the hub: the editor opens on the anomaly kind.
if (search.template === "anomaly")
return ready("new:anomaly", fromFormState(switchSignalKind(base, "anomaly").form))
const template = Arr.findFirst(ALERT_TEMPLATES, (candidate) => candidate.id === search.template)
if (Option.isSome(template))
return ready(`new:template:${template.value.id}`, fromFormState(template.value.apply(base)))
Expand Down
Loading
Loading