Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
41 changes: 41 additions & 0 deletions Darling/PerformanceMonitor.Darling.Service/wwwroot/css/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -586,3 +586,44 @@ button.template-item:focus-visible { outline: 2px solid var(--muted); outline-of
.views-hero .hero-chip.clickable:hover { color: var(--fg); border-color: var(--accent); }
.views-hero .hero-chip.clickable:focus-visible { outline: 2px solid var(--muted); outline-offset: 1px; }
.views-hero .starter-server { justify-content: center; padding: 0; }

/* ─────────────────────────── custom alert rules (#3285) ─────────────────────────── */

/* The "Paused" badge on a disabled rule's list card — the muted twin of the notebook badge. */
.paused-badge {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.3px;
text-transform: uppercase;
background: var(--lighter);
color: var(--muted);
border: 1px solid var(--border);
flex: 0 0 auto;
}
.view-card .vc-name .paused-badge { flex: 0 0 auto; }

/* The alert editor's card sections (Condition / Hysteresis / Scope) — the same inset the panel editor uses. */
.alert-section { padding: 0.9rem 1rem; }

/* The Enabled checkbox field sits left-aligned under its label rather than stretching. */
.editor-field.field-enabled { align-items: flex-start; }
.editor-field.field-enabled .editor-check { margin-top: 0.2rem; }

/* The "Create alert from this metric" action row inside a composed panel editor — set off from the config above. */
.composed-panel-actions { margin-top: 0.85rem; border-top: 1px solid var(--border); padding-top: 0.7rem; }

/* The alert editor's advanced (cadence) disclosure — the twin of the composer's Advanced. */
.alert-advanced { margin: 0.85rem 0 1.25rem; }
.alert-advanced > summary { cursor: pointer; font-size: 0.8rem; font-weight: 600; color: var(--muted); }
.alert-advanced > summary:hover { color: var(--fg); }
.alert-advanced > summary::-webkit-details-marker { display: none; }
.alert-advanced > summary::before { content: "▸ "; color: var(--muted); }
.alert-advanced[open] > summary::before { content: "▾ "; }
.alert-advanced > .cfg { margin-top: 0.6rem; }

/* The would-fire preview area. */
.alert-preview { margin-bottom: 1rem; }
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ <h1>Darling Web</h1>
regardless, so a deep link is never broken by the entry being hidden. -->
<a data-route="ag" href="#/ag" hidden>Availability Groups</a>
<a data-route="alerts" href="#/alerts">Alert History</a>
<a data-route="alert-rules" href="#/alert-rules">Alert Rules</a>
<a data-route="views" href="#/views">Custom Views</a>
</nav>
<div class="server-list-header">Servers</div>
Expand Down
740 changes: 740 additions & 0 deletions Darling/PerformanceMonitor.Darling.Service/wwwroot/js/alert-editor.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

/*
* The "Create alert from this panel" handoff (#3285, Component 7) — a LEAF module so BOTH the dashboard composer
* (editor.js) and the notebook composer (notebook.js) can offer the action, and the alert editor (alert-editor.js)
* can pick it up, WITHOUT an import cycle. This module imports only util.js: editor.js and notebook.js import the
* action builder here (they pass their own composedPanelToDesc in, so this module never imports editor.js), and
* alert-editor.js imports the pending-seed getter here — a clean DAG, no editor.js <-> alert-editor.js cycle.
*
* The seed is a COPY, never a live binding (the plan's requirement): the button snapshots the panel's metric spec
* into module state and navigates to #/alert-rule/new, where the editor consumes it once and clears it. Editing
* the source panel afterwards does not touch the drafted rule, and editing the rule does not touch the panel.
*/

import { el } from "./util.js";

/* The one-shot handoff slot: a panel's metric spec waiting for the next #/alert-rule/new render to consume. */
let _pendingSeed = null;

/** Stash a metric seed ({metric, name}) for the next new-alert-rule editor to pre-fill from. */
export function setPendingAlertSeed(seed) {
_pendingSeed = seed;
}

/** Take (and clear) the pending metric seed, or null when none is waiting. Consumed once by the editor. */
export function takePendingAlertSeed() {
const seed = _pendingSeed;
_pendingSeed = null;
return seed;
}

/**
* Extract the alert-metric keys from a stored composed-panel descriptor — ONLY the keys the compose authority
* (ComposeSpec.TryParsePanel) reads, so nothing frontend-only (title / span / viz) rides along into a rule
* definition (the backend rejects unknown keys, and supplies its own scalar viz). Deliberately omits the window:
* a dashboard panel's chart window is not an alert window, so the target editor supplies `hours` itself. Shared
* by the create-from-panel handoff and the alert editor's own serialize.
*/
export function metricSpecFromDesc(desc) {
const d = desc || {};
const metric = { source: d.source };
if (d.ratio) metric.ratio = d.ratio;
else if (d.measure) metric.measure = d.measure;
if (d.aggregate) metric.aggregate = d.aggregate;
if (d.unit) metric.unit = d.unit;
if (Array.isArray(d.filters) && d.filters.length) metric.filters = d.filters;
return metric;
}

/**
* Whether a composed-panel model is a legal alert metric source: a SCALAR panel (no timeBucket/topN) that names a
* measure or ratio. An alert metric is a single value, so only a scalar panel can seed one — a timeseries/ranked
* panel would carry a shape the alert editor can't use. Mirrors the CustomAlertRuleDefinition Scalar rule.
*/
function isScalarMetricPanel(p) {
return !!p && p.shape === "scalar" && !!(p.measure || p.ratio);
}

/**
* The "Create alert from this panel" action row for a composed-panel editor — a button that copies THIS panel's
* metric into a new alert-rule draft and opens the editor. Returns null unless the panel is a scalar metric (so a
* timeseries/ranked panel shows nothing), which is why it is rebuilt through buildComposedPanelBody's panelExtraAction
* hook: flipping the panel to/from "Single value" reveals/hides it live. `toDesc` is the caller's composedPanelToDesc
* (passed in to keep this module cycle-free). The copy is a snapshot — a later edit to the panel never touches the draft.
*/
export function buildCreateAlertAction(p, toDesc) {
if (!isScalarMetricPanel(p)) {
return null;
}
const btn = el("button", {
class: "btn small",
type: "button",
text: "Create alert from this metric",
title: "Copy this metric into a new alert rule (a copy — later edits here don't change the rule)",
});
btn.addEventListener("click", () => {
const metric = metricSpecFromDesc(toDesc(p));
setPendingAlertSeed({ metric, name: (p.title || "").trim() });
location.hash = "#/alert-rule/new";
});
return el("div", { class: "composed-panel-actions" }, [btn]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

/*
* The #3285 custom-alert-rule API client (Component 7) — the twin of views-api.js, thin wrappers over util.js's
* apiGet/apiSend for the seven /api/alerts endpoints. The rule CRUD mirrors the view CRUD EXACTLY: a bare-array
* list of summaries, a full single rule with its embedded definition, create (POST -> 201/400/409), a full-replace
* update (PUT with `version` for optimistic concurrency -> 200/400/404/409), and delete (204/404). Two alert-only
* POSTs sit beside them: /validate dry-runs a definition ({valid,error}) and /test evaluates it now (per-server
* current value + would-fire). The backend re-validates every write and the /validate + /test verdicts as the
* authority — this client only shapes the requests and forwards the classified response.
*
* The session ({can_edit}) and read catalog are the SAME per-page-load resources the composer uses, so they are
* re-exported from views-api.js rather than re-fetched: one cached promise backs the views composer, the notebook
* composer, and this editor alike (see the "maximize shared base code" rule). can_edit is the server's answer for
* this seat; the UI hides every edit affordance when it is false, and the server's method-based write gate refuses
* a viewer-seat mutation regardless (this is the affordance layer, never the enforcement).
*/

import { apiGet, apiSend } from "./util.js";

/* Shared session + catalog — one cache for every composer surface (re-exported, not re-implemented). */
export { getSession, getCatalog } from "./views-api.js";

/** GET the bare-array list of rule summaries (no definition), each carrying `enabled`. */
export function listAlertRules() {
return apiGet("/api/alerts");
}

/** GET one rule in full (with its embedded definition and `enabled`). */
export function getAlertRule(id) {
return apiGet("/api/alerts/" + encodeURIComponent(id));
}

/** POST a new rule {name, description?, definition, enabled?} — 201 + the full rule, or 400/409(dup)/403. */
export function createAlertRule(body) {
return apiSend("POST", "/api/alerts", body);
}

/** PUT an existing rule {name, description?, definition, enabled?, version} — 200 + the full rule, or 400/404/409/403. */
export function updateAlertRule(id, body) {
return apiSend("PUT", "/api/alerts/" + encodeURIComponent(id), body);
}

/** DELETE a rule — 204, or 404/403. (An open incident is force-resolved server-side before the delete, #3305.) */
export function deleteAlertRule(id) {
return apiSend("DELETE", "/api/alerts/" + encodeURIComponent(id));
}

/** GET the built-in starter templates — {templates:[{key,name,description,definition}]}; server-authored (#3325). */
export function listAlertTemplates() {
return apiGet("/api/alert-templates");
}

/** POST a dry-run validation of a definition — {valid, error}; the SAME authority the write path applies. */
export function validateAlertRule(definition) {
return apiSend("POST", "/api/alerts/validate", { definition });
}

/**
* POST an evaluate-now test — read the metric's CURRENT value on each in-scope server and report whether it WOULD
* breach right now (delivering/persisting nothing). Pass exactly one of {rule_id} (a saved rule) or {definition}
* (an unsaved draft). Returns {rule_id,name,note,results:[{server,current_value,breaching,severity,no_data}]} as
* data; an invalid/not-found draft comes back as the {status,message} envelope (classified "empty"); a valid draft
* with no matching servers as data carrying status "no_in_scope_servers".
*/
export function testAlertRule(body) {
return apiSend("POST", "/api/alerts/test", body);
}
30 changes: 24 additions & 6 deletions Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
* #/server/{name} — one server's detail (Overview)
* #/server/{name}/{tab} — one server's detail, opened on a named sub-tab (pages/server-tabs.js)
* #/alerts — fleet-wide Alert History
* #/alert-rules — Custom Alert Rules list (#3285)
* #/alert-rule/new — the alert-rule editor creating a new rule (optionally /new/{template})
* #/alert-rule/{id} — the alert-rule editor editing a saved rule (#3285)
* #/views — Custom Views list (#1563)
* #/view/{id} — a saved custom view, rendered (#1563)
* #/view/{id}/edit — the composer editing a saved view (#1563)
Expand All @@ -33,10 +36,12 @@ import { renderFleet } from "./pages/fleet.js";
import { renderAg } from "./pages/ag.js";
import { renderServer } from "./pages/server.js";
import { renderAlerts } from "./pages/alerts.js";
import { renderAlertRuleList } from "./pages/alert-rules.js";
import { renderViewList, renderView } from "./pages/views.js";
import { renderTriage } from "./pages/triage.js";
import { renderEditor } from "./editor.js";
import { renderNotebookEditor } from "./notebook.js";
import { renderAlertEditor } from "./alert-editor.js";
import { getSession, listViews } from "./views-api.js";

const POLL_MS = 60000;
Expand All @@ -60,6 +65,14 @@ function currentRoute() {
return { name: "triage", query: q >= 0 ? h.slice(q + 1) : "" };
}
if (h.startsWith("#/alerts")) return { name: "alerts" };
/* Alert-rule routes (#3285): the list, the new form (optionally /new/{template}), then the bare /{id} edit form,
tested most-specific first. Distinct from #/alerts above (which cannot collide: #/alert- never starts #/alerts). */
if (h === "#/alert-rules" || h === "#/alert-rules/") return { name: "alertRules" };
if (h === "#/alert-rule/new") return { name: "alertEditor", id: "new" };
if (h.startsWith("#/alert-rule/new/")) {
return { name: "alertEditor", id: "new", template: decodeURIComponent(h.slice("#/alert-rule/new/".length)) };
}
if (h.startsWith("#/alert-rule/")) return { name: "alertEditor", id: decodeURIComponent(h.slice("#/alert-rule/".length)) };
/* #/views (list) is checked before the #/view/ forms; and the /edit form is tested before the bare /view/. */
if (h === "#/views" || h === "#/views/") return { name: "views" };
if (h === "#/view/new") return { name: "editor", id: "new" };
Expand Down Expand Up @@ -100,6 +113,8 @@ function route() {
if (r.name === "server") renderServer(main, r.param, r.tab);
else if (r.name === "ag") renderAg(main);
else if (r.name === "alerts") renderAlerts(main);
else if (r.name === "alertRules") renderAlertRuleList(main);
else if (r.name === "alertEditor") renderAlertEditor(main, r.id, r.template);
else if (r.name === "triage") renderTriage(main, r.query);
else if (r.name === "views") renderViewList(main);
else if (r.name === "view") renderView(main, r.id);
Expand All @@ -115,9 +130,12 @@ function isViewItemRoute(name) {
return name === "view" || name === "editor" || name === "notebook" || name === "notebookEditor";
}

/* The sidebar's "Custom Views" nav stays lit across the list, both renderers, and both composers. */
/* The sidebar's "Custom Views" nav stays lit across the list, both renderers, and both composers; the "Alert Rules"
nav stays lit across its list + editor. */
function navKeyFor(r) {
return r.name === "views" || isViewItemRoute(r.name) ? "views" : r.name;
if (r.name === "views" || isViewItemRoute(r.name)) return "views";
if (r.name === "alertRules" || r.name === "alertEditor") return "alert-rules";
return r.name;
}

function setActiveNav(r) {
Expand Down Expand Up @@ -267,11 +285,11 @@ function refresh() {
refreshSidebar();
refreshViewList();
refreshAgNav();
/* Poll-clobber guard (#1563): never re-render a composer (dashboard OR notebook, #1563 D7) from the background
poll — a rebuild would discard an in-progress edit. hashchange still routes to it normally; only this periodic
refresh skips it. */
/* Poll-clobber guard (#1563, extended #3285): never re-render an editor (dashboard/notebook composer OR the
alert-rule editor) from the background poll — a rebuild would discard an in-progress edit. hashchange still
routes to it normally; only this periodic refresh skips it. */
const routeName = currentRoute().name;
if (routeName === "editor" || routeName === "notebookEditor") return;
if (routeName === "editor" || routeName === "notebookEditor" || routeName === "alertEditor") return;
route();
}

Expand Down
Loading
Loading