You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/05/17 13:50:22 UTC
(skywalking-horizon-ui) branch main updated (6457d69 -> 1d4ace6)
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a change to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
from 6457d69 ui layer selection: URL is now a share-link seed, not a live state mirror
new 45f1d38 ui layer selection: add share-link button + strip seed params after hydrate
new 1d4ace6 alerting: incident-merged page, dual-mode BFF, admin views (alerting rules + overview templates)
The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
apps/bff/src/bundled_templates/overviews/mesh.json | 3 +-
.../src/bundled_templates/overviews/services.json | 3 +-
apps/bff/src/client/index.ts | 8 +
apps/bff/src/http/admin/alarm-rules.ts | 272 ++++
apps/bff/src/http/admin/overview-templates.ts | 164 +++
apps/bff/src/http/config/alarms.ts | 66 +-
apps/bff/src/http/query/alarms.ts | 506 ++++---
apps/bff/src/logic/alarms/store.ts | 157 ++-
apps/bff/src/logic/overview/loader.ts | 37 +-
apps/bff/src/server.ts | 6 +-
apps/ui/src/api/client.ts | 105 +-
apps/ui/src/api/scopes/alarms.test.ts | 50 +-
apps/ui/src/api/scopes/alarms.ts | 32 +-
apps/ui/src/api/scopes/overview.ts | 37 +
apps/ui/src/components/charts/AlarmsTimeline.vue | 488 ++++---
.../admin/alert-page/AlertPageSetupView.vue | 532 +++++---
.../overview-templates/OverviewTemplatesAdmin.vue | 348 +++++
apps/ui/src/features/alarms/AlarmDetailPanel.vue | 322 ++++-
apps/ui/src/features/alarms/AlarmSnapshotChart.vue | 313 +++++
apps/ui/src/features/alarms/AlarmsView.vue | 1411 +++++++++++---------
.../operate/alerting-rules/AlertingRulesView.vue | 603 +++++++++
apps/ui/src/features/setup/LayerSetupCard.vue | 1206 -----------------
apps/ui/src/features/setup/SetupView.vue | 416 ------
apps/ui/src/layer/LayerShell.vue | 81 ++
.../src/render/overview/OverviewDashboardView.vue | 1 +
apps/ui/src/render/overview/OverviewLanding.vue | 4 +-
apps/ui/src/render/widgets/AlarmsWidget.vue | 207 ++-
apps/ui/src/shell/AppSidebar.vue | 14 +-
apps/ui/src/shell/AppTopbar.vue | 78 +-
apps/ui/src/shell/router/index.ts | 18 +-
apps/ui/src/shell/useAlarmCount.ts | 107 ++
apps/ui/src/utils/alarmEntity.ts | 98 ++
apps/ui/src/utils/alarmIncidents.ts | 175 +++
packages/api-client/src/alarm-status.ts | 184 +++
packages/api-client/src/index.ts | 10 +
35 files changed, 4960 insertions(+), 3102 deletions(-)
create mode 100644 apps/bff/src/http/admin/alarm-rules.ts
create mode 100644 apps/bff/src/http/admin/overview-templates.ts
create mode 100644 apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
create mode 100644 apps/ui/src/features/alarms/AlarmSnapshotChart.vue
create mode 100644 apps/ui/src/features/operate/alerting-rules/AlertingRulesView.vue
delete mode 100644 apps/ui/src/features/setup/LayerSetupCard.vue
delete mode 100644 apps/ui/src/features/setup/SetupView.vue
create mode 100644 apps/ui/src/shell/useAlarmCount.ts
create mode 100644 apps/ui/src/utils/alarmEntity.ts
create mode 100644 apps/ui/src/utils/alarmIncidents.ts
create mode 100644 packages/api-client/src/alarm-status.ts
(skywalking-horizon-ui) 02/02: alerting: incident-merged page, dual-mode BFF, admin views (alerting rules + overview templates)
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 1d4ace6f79e6e3c23a8ac5d3ffef4b18cfb458e8
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun May 17 21:50:06 2026 +0800
alerting: incident-merged page, dual-mode BFF, admin views (alerting rules + overview templates)
---
apps/bff/src/bundled_templates/overviews/mesh.json | 3 +-
.../src/bundled_templates/overviews/services.json | 3 +-
apps/bff/src/client/index.ts | 8 +
apps/bff/src/http/admin/alarm-rules.ts | 272 ++++
apps/bff/src/http/admin/overview-templates.ts | 164 +++
apps/bff/src/http/config/alarms.ts | 66 +-
apps/bff/src/http/query/alarms.ts | 506 ++++---
apps/bff/src/logic/alarms/store.ts | 157 ++-
apps/bff/src/logic/overview/loader.ts | 37 +-
apps/bff/src/server.ts | 6 +-
apps/ui/src/api/client.ts | 105 +-
apps/ui/src/api/scopes/alarms.test.ts | 50 +-
apps/ui/src/api/scopes/alarms.ts | 32 +-
apps/ui/src/api/scopes/overview.ts | 37 +
apps/ui/src/components/charts/AlarmsTimeline.vue | 488 ++++---
.../admin/alert-page/AlertPageSetupView.vue | 532 +++++---
.../overview-templates/OverviewTemplatesAdmin.vue | 348 +++++
apps/ui/src/features/alarms/AlarmDetailPanel.vue | 322 ++++-
apps/ui/src/features/alarms/AlarmSnapshotChart.vue | 313 +++++
apps/ui/src/features/alarms/AlarmsView.vue | 1411 +++++++++++---------
.../operate/alerting-rules/AlertingRulesView.vue | 603 +++++++++
apps/ui/src/features/setup/LayerSetupCard.vue | 1206 -----------------
apps/ui/src/features/setup/SetupView.vue | 416 ------
.../src/render/overview/OverviewDashboardView.vue | 1 +
apps/ui/src/render/overview/OverviewLanding.vue | 4 +-
apps/ui/src/render/widgets/AlarmsWidget.vue | 207 ++-
apps/ui/src/shell/AppSidebar.vue | 14 +-
apps/ui/src/shell/AppTopbar.vue | 78 +-
apps/ui/src/shell/router/index.ts | 18 +-
apps/ui/src/shell/useAlarmCount.ts | 107 ++
apps/ui/src/utils/alarmEntity.ts | 98 ++
apps/ui/src/utils/alarmIncidents.ts | 175 +++
packages/api-client/src/alarm-status.ts | 184 +++
packages/api-client/src/index.ts | 10 +
34 files changed, 4879 insertions(+), 3102 deletions(-)
diff --git a/apps/bff/src/bundled_templates/overviews/mesh.json b/apps/bff/src/bundled_templates/overviews/mesh.json
index 9d209fe..3c75d45 100644
--- a/apps/bff/src/bundled_templates/overviews/mesh.json
+++ b/apps/bff/src/bundled_templates/overviews/mesh.json
@@ -55,8 +55,9 @@
{
"id": "mesh_alarms",
"title": "Active alarms",
- "tip": "All currently firing alarms (layer filter is best-effort in v1).",
+ "tip": "Currently-firing alarms on mesh-reported services.",
"type": "alarms",
+ "layer": "MESH",
"limit": 12,
"span": 3,
"rowSpan": 6
diff --git a/apps/bff/src/bundled_templates/overviews/services.json b/apps/bff/src/bundled_templates/overviews/services.json
index 6ad9943..4c85dd7 100644
--- a/apps/bff/src/bundled_templates/overviews/services.json
+++ b/apps/bff/src/bundled_templates/overviews/services.json
@@ -113,8 +113,9 @@
{
"id": "svc_alarms",
"title": "Active alarms",
- "tip": "All currently firing alarms (layer filter is best-effort in v1).",
+ "tip": "Currently-firing alarms on agent-reported services.",
"type": "alarms",
+ "layer": "GENERAL",
"limit": 12,
"span": 3,
"rowSpan": 6
diff --git a/apps/bff/src/client/index.ts b/apps/bff/src/client/index.ts
index 12af234..d2ff539 100644
--- a/apps/bff/src/client/index.ts
+++ b/apps/bff/src/client/index.ts
@@ -23,6 +23,7 @@
*/
import {
+ AlarmStatusClient,
DslDebuggingClient,
InspectClient,
OalClient,
@@ -58,6 +59,10 @@ export interface OapClients {
* enumeration. Identical across nodes (storage is shared), so we
* bind to the first admin URL. */
inspect(): InspectClient;
+ /** Alarm-status client — `/status/alarm/*` admin REST. The handler
+ * internally fans out to peer OAP nodes via gRPC, so we only need
+ * to hit the primary URL. */
+ alarmStatus(): AlarmStatusClient;
/** All admin URLs, in config order. */
adminUrls(): readonly string[];
}
@@ -108,6 +113,9 @@ export function buildOapClients(
inspect(): InspectClient {
return new InspectClient({ adminUrl: primaryUrl, fetch, timeoutMs, headers });
},
+ alarmStatus(): AlarmStatusClient {
+ return new AlarmStatusClient({ adminUrl: primaryUrl, fetch, timeoutMs, headers });
+ },
adminUrls(): readonly string[] {
return config.oap.adminUrls;
},
diff --git a/apps/bff/src/http/admin/alarm-rules.ts b/apps/bff/src/http/admin/alarm-rules.ts
new file mode 100644
index 0000000..59fd60e
--- /dev/null
+++ b/apps/bff/src/http/admin/alarm-rules.ts
@@ -0,0 +1,272 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * `/api/admin/alarm-rules*` — read-only views into OAP's alarm-rule
+ * catalog. Backed by `/status/alarm/*` on the OAP admin port.
+ *
+ * GET /api/admin/alarm-rules — list every loaded rule with
+ * its body merged in (one
+ * round-trip per rule, runs
+ * in parallel).
+ * GET /api/admin/alarm-rules/:id — full detail for one rule.
+ *
+ * Read-only. The OAP alarm-rule lifecycle is "edit the YAML, restart
+ * (or let the watcher pick up the change)"; no mutation surface
+ * exists upstream and we don't fabricate one.
+ *
+ * Both routes invert the upstream `ClusterAlarmStatus` envelope into
+ * a flatter wire shape that's easier to render: the per-node load
+ * state is pivoted into a `nodes[]` array per rule so the UI sees
+ * "rule X is loaded on 2/3 nodes" at a glance.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type {
+ AlarmRuleDetail,
+ AlarmStatusClient,
+ ClusterAlarmStatus,
+ FetchLike,
+ InstanceAlarmStatus,
+} from '@skywalking-horizon-ui/api-client';
+import { AlarmStatusApiError } from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { buildOapClients } from '../../client/index.js';
+
+export interface AlarmRulesRouteDeps {
+ config: ConfigSource;
+ sessions: SessionStore;
+ fetch?: FetchLike;
+}
+
+export interface AlertingRuleNode {
+ /** OAP-node address (gRPC `host:port` or `Self()` literal). */
+ address: string;
+ /** True iff the node responded with a successful list payload. */
+ ok: boolean;
+ /** Per-node failure reason. Null when `ok`. */
+ error?: string;
+ /** True when the rule appeared in this node's `ruleList`. False
+ * signals the rule isn't loaded on this node (config drift). */
+ loaded: boolean;
+}
+
+export interface AlertingRuleSummary {
+ ruleId: string;
+ /** Best-effort detail body, when at least one node returned it.
+ * Null when every node failed or the rule wasn't found. */
+ detail: AlarmRuleDetail | null;
+ /** Per-node load state. */
+ nodes: AlertingRuleNode[];
+ /** Quick stats — how many nodes loaded this rule. */
+ loadedOn: number;
+ totalNodes: number;
+}
+
+export interface AlertingRulesListResponse {
+ generatedAt: number;
+ /** Whole-cluster reachability — true iff at least one node
+ * responded to `/status/alarm/rules`. */
+ reachable: boolean;
+ /** Top-level error when no node responded. */
+ error?: string;
+ rules: AlertingRuleSummary[];
+ /** Per-node fetch result for the initial /rules call — exposes
+ * partial-failure cases to the UI. */
+ nodes: Array<{ address: string; ok: boolean; error?: string }>;
+}
+
+export interface AlertingRuleDetailResponse {
+ ruleId: string;
+ generatedAt: number;
+ reachable: boolean;
+ error?: string;
+ /** Detail body picked from the first node that returned one. */
+ detail: AlarmRuleDetail | null;
+ /** Per-node detail snapshot — operators occasionally need to see
+ * the rule diverge across nodes (mid-rollout). */
+ nodes: Array<{
+ address: string;
+ ok: boolean;
+ error?: string;
+ detail: AlarmRuleDetail | null;
+ }>;
+}
+
+/* Pivot a per-rule x per-node matrix from the two-step fan-out. */
+function pivot(
+ listResp: ClusterAlarmStatus<{ ruleList: Array<{ id: string }> }>,
+ detailById: Map<string, ClusterAlarmStatus<AlarmRuleDetail> | Error>,
+): Pick<AlertingRulesListResponse, 'rules' | 'nodes'> {
+ const nodes = listResp.oapInstances.map((i) => ({
+ address: i.address,
+ ok: i.errorMsg === null && !!i.status,
+ error: i.errorMsg ?? undefined,
+ }));
+ const totalNodes = nodes.length || 1;
+
+ /* Build per-rule node membership from the list envelope. */
+ const perRuleNodes = new Map<string, Set<string>>();
+ for (const inst of listResp.oapInstances) {
+ if (!inst.status) continue;
+ for (const r of inst.status.ruleList ?? []) {
+ let set = perRuleNodes.get(r.id);
+ if (!set) {
+ set = new Set();
+ perRuleNodes.set(r.id, set);
+ }
+ set.add(inst.address);
+ }
+ }
+
+ const rules: AlertingRuleSummary[] = [];
+ for (const [ruleId, loadedAddrs] of perRuleNodes) {
+ const detailEnvOrErr = detailById.get(ruleId);
+ let bestDetail: AlarmRuleDetail | null = null;
+ if (detailEnvOrErr && !(detailEnvOrErr instanceof Error)) {
+ for (const inst of detailEnvOrErr.oapInstances) {
+ if (inst.status) {
+ bestDetail = inst.status;
+ break;
+ }
+ }
+ }
+ rules.push({
+ ruleId,
+ detail: bestDetail,
+ nodes: listResp.oapInstances.map((i) => ({
+ address: i.address,
+ ok: i.errorMsg === null && !!i.status,
+ error: i.errorMsg ?? undefined,
+ loaded: loadedAddrs.has(i.address),
+ })),
+ loadedOn: loadedAddrs.size,
+ totalNodes,
+ });
+ }
+ rules.sort((a, b) => a.ruleId.localeCompare(b.ruleId));
+ return { rules, nodes };
+}
+
+export function registerAlarmRulesRoutes(
+ app: FastifyInstance,
+ deps: AlarmRulesRouteDeps,
+): void {
+ const auth = requireAuth(deps);
+
+ function client(): AlarmStatusClient {
+ return buildOapClients(deps.config.current, { fetch: deps.fetch }).alarmStatus();
+ }
+
+ // ── GET /api/admin/alarm-rules ────────────────────────────────────
+ app.get(
+ '/api/admin/alarm-rules',
+ { preHandler: auth },
+ async (_req: FastifyRequest, reply: FastifyReply) => {
+ const c = client();
+ let listResp: ClusterAlarmStatus<{ ruleList: Array<{ id: string }> }>;
+ try {
+ listResp = await c.listRules();
+ } catch (err) {
+ return reply.send({
+ generatedAt: Date.now(),
+ reachable: false,
+ error: err instanceof Error ? err.message : String(err),
+ rules: [],
+ nodes: [],
+ } satisfies AlertingRulesListResponse);
+ }
+
+ /* Collect every unique rule id, then fetch detail bodies in
+ * parallel. Bounded concurrency isn't strictly needed (a
+ * typical install has ≤30 rules), but we run them concurrently
+ * so total wall-time is one detail-roundtrip + the list. */
+ const ids = new Set<string>();
+ for (const inst of listResp.oapInstances) {
+ for (const r of inst.status?.ruleList ?? []) ids.add(r.id);
+ }
+ const detailById = new Map<string, ClusterAlarmStatus<AlarmRuleDetail> | Error>();
+ await Promise.all(
+ Array.from(ids).map(async (id) => {
+ try {
+ const env = await c.ruleDetail(id);
+ detailById.set(id, env);
+ } catch (err) {
+ detailById.set(id, err instanceof Error ? err : new Error(String(err)));
+ }
+ }),
+ );
+
+ const { rules, nodes } = pivot(listResp, detailById);
+ const body: AlertingRulesListResponse = {
+ generatedAt: Date.now(),
+ reachable: nodes.some((n) => n.ok),
+ rules,
+ nodes,
+ };
+ return reply.send(body);
+ },
+ );
+
+ // ── GET /api/admin/alarm-rules/:id ────────────────────────────────
+ app.get(
+ '/api/admin/alarm-rules/:id',
+ { preHandler: auth },
+ async (req: FastifyRequest, reply: FastifyReply) => {
+ const id = (req.params as { id?: string }).id;
+ if (!id) return reply.code(400).send({ error: 'missing_id' });
+ const c = client();
+ let env: ClusterAlarmStatus<AlarmRuleDetail>;
+ try {
+ env = await c.ruleDetail(id);
+ } catch (err) {
+ const status =
+ err instanceof AlarmStatusApiError && err.status === 404 ? 404 : 502;
+ return reply.code(status).send({
+ ruleId: id,
+ generatedAt: Date.now(),
+ reachable: false,
+ error: err instanceof Error ? err.message : String(err),
+ detail: null,
+ nodes: [],
+ });
+ }
+ const perNode = env.oapInstances.map<{
+ address: string;
+ ok: boolean;
+ error?: string;
+ detail: AlarmRuleDetail | null;
+ }>((i: InstanceAlarmStatus<AlarmRuleDetail>) => ({
+ address: i.address,
+ ok: i.errorMsg === null && !!i.status,
+ error: i.errorMsg ?? undefined,
+ detail: i.status ?? null,
+ }));
+ const detail = perNode.find((n) => n.detail !== null)?.detail ?? null;
+ const body: AlertingRuleDetailResponse = {
+ ruleId: id,
+ generatedAt: Date.now(),
+ reachable: perNode.some((n) => n.ok),
+ detail,
+ nodes: perNode,
+ };
+ return reply.send(body);
+ },
+ );
+}
diff --git a/apps/bff/src/http/admin/overview-templates.ts b/apps/bff/src/http/admin/overview-templates.ts
new file mode 100644
index 0000000..a803633
--- /dev/null
+++ b/apps/bff/src/http/admin/overview-templates.ts
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * `/api/admin/overview-templates*` — admin CRUD for the per-dashboard
+ * JSON templates that drive overview pages. Mirrors the layer-template
+ * admin: write back to the same on-disk bundled file, invalidate the
+ * loader cache so the next SPA fetch sees the new shape.
+ *
+ * GET /api/admin/overview-templates — list (id, title,
+ * widgets summary).
+ * GET /api/admin/overview-templates/:id — full dashboard config.
+ * POST /api/admin/overview-templates/:id — write a dashboard's
+ * full config back.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { z } from 'zod';
+import type { OverviewDashboard } from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import {
+ findOverviewFile,
+ getOverviewDashboard,
+ loadOverviewDashboards,
+ writeOverviewDashboard,
+} from '../../logic/overview/loader.js';
+
+export interface OverviewTemplatesAdminDeps {
+ config: ConfigSource;
+ sessions: SessionStore;
+}
+
+const WIDGET_TYPES = [
+ 'service-count',
+ 'metric',
+ 'topology',
+ 'section-break',
+ 'kpi-tile',
+ 'alarms',
+ 'k8s-summary',
+ 'pilot-summary',
+] as const;
+
+const kpiSchema = z.object({
+ label: z.string().min(1),
+ mqe: z.string().min(1),
+ unit: z.string().optional(),
+ aggregation: z.enum(['sum', 'avg']).optional(),
+});
+
+const widgetSchema = z.object({
+ id: z.string().min(1),
+ title: z.string().min(1),
+ tip: z.string().optional(),
+ layer: z.string().optional(),
+ type: z.enum(WIDGET_TYPES),
+ mqe: z.string().optional(),
+ unit: z.string().optional(),
+ aggregation: z.enum(['sum', 'avg']).optional(),
+ cols: z.number().int().optional(),
+ kpis: z.array(kpiSchema).optional(),
+ showCount: z.boolean().optional(),
+ limit: z.number().int().min(1).max(100).optional(),
+ span: z.number().int().min(1).max(12).optional(),
+ rowSpan: z.number().int().min(1).max(12).optional(),
+});
+
+const dashboardSchema = z.object({
+ id: z.string().min(1),
+ title: z.string().min(1),
+ description: z.string().optional(),
+ visibility: z.enum(['public', 'operate']).optional(),
+ icon: z.string().optional(),
+ order: z.number().optional(),
+ layers: z.array(z.string()).optional(),
+ widgets: z.array(widgetSchema).max(64),
+});
+
+export function registerOverviewTemplatesAdminRoutes(
+ app: FastifyInstance,
+ deps: OverviewTemplatesAdminDeps,
+): void {
+ const auth = requireAuth(deps);
+
+ /* GET /api/admin/overview-templates — every loaded dashboard. */
+ app.get(
+ '/api/admin/overview-templates',
+ { preHandler: auth },
+ async (_req: FastifyRequest, reply: FastifyReply) => {
+ const dashboards = loadOverviewDashboards();
+ return reply.send({
+ generatedAt: Date.now(),
+ dashboards: dashboards.map((d) => ({
+ id: d.id,
+ title: d.title,
+ description: d.description,
+ widgetCount: d.widgets.length,
+ editable: !!findOverviewFile(d.id),
+ })),
+ });
+ },
+ );
+
+ /* GET /api/admin/overview-templates/:id — full editable config. */
+ app.get(
+ '/api/admin/overview-templates/:id',
+ { preHandler: auth },
+ async (req: FastifyRequest, reply: FastifyReply) => {
+ const { id } = req.params as { id: string };
+ const dash = getOverviewDashboard(id);
+ if (!dash) return reply.code(404).send({ error: 'not_found', id });
+ return reply.send({
+ generatedAt: Date.now(),
+ dashboard: dash,
+ editable: !!findOverviewFile(id),
+ });
+ },
+ );
+
+ /* POST /api/admin/overview-templates/:id — write back. The body
+ * MUST have the same `id` as the URL param (defensive against
+ * accidental cross-dashboard overwrites). */
+ app.post(
+ '/api/admin/overview-templates/:id',
+ { preHandler: auth },
+ async (req: FastifyRequest, reply: FastifyReply) => {
+ const { id } = req.params as { id: string };
+ const parsed = dashboardSchema.safeParse(req.body);
+ if (!parsed.success) {
+ return reply.code(400).send({ error: 'invalid_body', detail: parsed.error.flatten() });
+ }
+ if (parsed.data.id !== id) {
+ return reply
+ .code(400)
+ .send({ error: 'id_mismatch', urlId: id, bodyId: parsed.data.id });
+ }
+ try {
+ writeOverviewDashboard(id, parsed.data as OverviewDashboard);
+ } catch (err) {
+ return reply.code(500).send({
+ error: 'write_failed',
+ message: err instanceof Error ? err.message : String(err),
+ });
+ }
+ return reply.send({ ok: true, id });
+ },
+ );
+}
diff --git a/apps/bff/src/http/config/alarms.ts b/apps/bff/src/http/config/alarms.ts
index 17e9e84..9984192 100644
--- a/apps/bff/src/http/config/alarms.ts
+++ b/apps/bff/src/http/config/alarms.ts
@@ -16,10 +16,12 @@
*/
/**
- * `/api/alarms/config` — read + write the alarm-page setup
- * (per-traffic-layer MQE expression list). The `serviceLayer` cache is
- * invalidated on save so the next `GET /api/alarms` picks up any newly
- * configured layer immediately instead of waiting for the 60s TTL.
+ * `/api/alarms/config` — read + write the alarm-page setup. The shape
+ * is `{ pinnedLayers: string[] }`: OAP layer keys that get a
+ * dedicated tile + breakdown chip on the alarms page header. The
+ * `serviceLayer` cache is invalidated on save so per-row layer tags
+ * pick up any new layer immediately instead of waiting for the 60s
+ * TTL.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
@@ -29,7 +31,13 @@ import type { SessionStore } from '../../user/sessions.js';
import type { AuditLogger } from '../../audit/logger.js';
import { requireAuth } from '../../user/middleware.js';
import type { ServiceLayerMap } from '../../logic/alarms/service-layer-map.js';
-import type { AlarmsStore, AlarmsConfig } from '../../logic/alarms/store.js';
+import {
+ ALARMS_WINDOW_OPTIONS,
+ OVERVIEW_ALARMS_LIMIT_MAX,
+ OVERVIEW_ALARMS_LIMIT_MIN,
+ type AlarmsStore,
+ type AlarmsConfig,
+} from '../../logic/alarms/store.js';
export interface AlarmsConfigRouteDeps {
config: ConfigSource;
@@ -40,15 +48,23 @@ export interface AlarmsConfigRouteDeps {
}
const configSaveSchema = z.object({
- trafficLayers: z
- .array(
- z.object({
- layerKey: z.string().min(1),
- mqe: z.string().min(1),
- label: z.string().optional(),
- }).strict(),
- )
- .max(8),
+ pinnedLayers: z.array(z.string().min(1)).max(8),
+ /* Locked to the page-picker preset set so the admin's pick always
+ * lights up a real tab. Hand-edited values are rejected with a 400
+ * rather than silently coerced — keeps debugging predictable. */
+ defaultWindowMs: z
+ .number()
+ .int()
+ .refine((v) => (ALARMS_WINDOW_OPTIONS as readonly number[]).includes(v), {
+ message: `must be one of ${ALARMS_WINDOW_OPTIONS.join(', ')}`,
+ }),
+ /* Bounded range — too low starves the widget's incident merge of
+ * variety; too high pulls more bytes per poll than necessary. */
+ overviewAlarmsLimit: z
+ .number()
+ .int()
+ .min(OVERVIEW_ALARMS_LIMIT_MIN)
+ .max(OVERVIEW_ALARMS_LIMIT_MAX),
});
export function registerAlarmsConfigRoutes(
@@ -74,14 +90,32 @@ export function registerAlarmsConfigRoutes(
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', detail: parsed.error.flatten() });
}
- const next: AlarmsConfig = { trafficLayers: parsed.data.trafficLayers };
+ /* Normalise layer keys to upper-case + dedup so the persisted
+ * shape is canonical regardless of what the admin form sent. */
+ const seen = new Set<string>();
+ const pinnedLayers: string[] = [];
+ for (const raw of parsed.data.pinnedLayers) {
+ const k = raw.trim().toUpperCase();
+ if (!k || seen.has(k)) continue;
+ seen.add(k);
+ pinnedLayers.push(k);
+ }
+ const next: AlarmsConfig = {
+ pinnedLayers,
+ defaultWindowMs: parsed.data.defaultWindowMs,
+ overviewAlarmsLimit: parsed.data.overviewAlarmsLimit,
+ };
await deps.store.save(next);
deps.serviceLayer.invalidate();
deps.audit.record({
action: 'alarms.config.save',
actor: req.session?.username ?? null,
outcome: 'ok',
- details: { layers: next.trafficLayers.map((l) => `${l.layerKey}:${l.mqe}`) },
+ details: {
+ pinnedLayers: next.pinnedLayers,
+ defaultWindowMs: next.defaultWindowMs,
+ overviewAlarmsLimit: next.overviewAlarmsLimit,
+ },
fromIp: req.ip,
sessionId: req.session?.sid,
});
diff --git a/apps/bff/src/http/query/alarms.ts b/apps/bff/src/http/query/alarms.ts
index 0a95a0e..590dfb9 100644
--- a/apps/bff/src/http/query/alarms.ts
+++ b/apps/bff/src/http/query/alarms.ts
@@ -16,25 +16,33 @@
*/
/**
- * `/api/alarms/*` routes — alarm list + background-traffic series for
- * the alarms page + admin config.
+ * `/api/alarms/*` query routes — alarm list, count probe, and the
+ * cascading-filter service-list helper.
*
- * GET /api/alarms — OAP getAlarm proxy
- * + layer tag per row.
- * GET /api/alarms/traffic — per-configured-layer
- * RPM time-series.
- * GET /api/alarms/config — current alarm-page
- * setup.
- * POST /api/alarms/config — save alarm-page setup.
+ * GET /api/alarms — paged alarm list (dual-mode:
+ * queryAlarms when available,
+ * getAlarm otherwise).
+ * GET /api/alarms/count — total + firing tally for the
+ * topbar badge.
+ * GET /api/alarms/services?layer= — service roster for the alarms
+ * filter cascade.
*
- * Notes:
- * - All times on the wire are millisecond epoch. OAP's Duration
- * takes a `yyyy-MM-dd HHmm` string in OAP-server-TZ; the helper
- * below converts ms → that shape using the server tz from
- * `getTimeInfo` (cached upstream).
- * - Layer tagging uses the cached service-name → layer index. If
- * the index doesn't know the service (e.g. instance / endpoint
- * scope), `layerKey` is `null` and the UI groups under "Other".
+ * Wire-time notes:
+ * - `startTime` / `endTime` are ms epoch. OAP's `Duration` expects
+ * `yyyy-MM-dd HHmmss` strings in OAP-server-TZ; conversion uses
+ * the timezone advertised by `getTimeInfo` (cached upstream).
+ * - The window is hard-capped at 4 hours. Alarms are
+ * second-precision events with no chunking; allowing larger
+ * windows pulls thousands of rows and starves the page on slow
+ * storage backends. The UI's picker enforces the same cap; this
+ * server-side guard is defence-in-depth.
+ * - `pageSize` is capped at 500 so the header KPIs + frontend pager
+ * can work from a single fetch. The COUNT route uses a 200 cap
+ * since it skips the snapshot payload.
+ * - Layer tagging on each row uses the cached service-name → layer
+ * index. Entries the index can't resolve (e.g. instance-scope
+ * alarms whose name doesn't carry a service prefix) get
+ * `layerKey: null` and the UI groups them under "Other".
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
@@ -47,17 +55,13 @@ import { badRequest } from '../../errors.js';
import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
import { getOapCapabilities } from '../../logic/oap/capabilities.js';
import type { ServiceLayerMap } from '../../logic/alarms/service-layer-map.js';
-import type { AlarmsStore } from '../../logic/alarms/store.js';
export interface AlarmsQueryRouteDeps {
config: ConfigSource;
sessions: SessionStore;
- /** Shared with config/alarms.ts so a config save can invalidate
- * the cache and the next list call picks up the new layers. */
+ /** Shared with config/alarms.ts so a config save can invalidate the
+ * cache and the next list call picks up the new layers. */
serviceLayer: ServiceLayerMap;
- /** Used by `/api/alarms/traffic` to read which layers the operator
- * configured as traffic backdrops. */
- store: AlarmsStore;
fetch?: FetchLike;
}
@@ -120,41 +124,36 @@ export interface AlarmMessage {
}
export interface AlarmsResponse {
+ /** Rows returned for this page. The list route does not page through
+ * the OAP response; the UI pages this client-side. */
total: number;
pageNum: number;
pageSize: number;
+ /** True when `total === pageSize`, meaning OAP may have more rows
+ * than we fetched. The UI shows a "Nrows+" hint to nudge the
+ * operator to tighten the window. */
+ truncated: boolean;
generatedAt: number;
msgs: AlarmMessage[];
}
-export interface AlarmTrafficPoint {
- /** ms epoch */
- ts: number;
- value: number | null;
-}
-
-export interface AlarmTrafficSeries {
- layerKey: string;
- label: string;
- /** True when OAP recognised the layer AND `listServices` returned
- * at least one service. False slots still appear in the response
- * so the UI can show the empty / unavailable state. */
- present: boolean;
- /** ms-keyed points across the requested window. */
- points: AlarmTrafficPoint[];
- /** Per-layer error string when OAP returned a graphql error or the
- * layer is missing. */
- error?: string;
-}
-
-export interface AlarmTrafficResponse {
- generatedAt: number;
- /** Window start / end, ms epoch — echoed back so the UI can align
- * the alarm markers and the traffic lines on a single x-axis. */
+export interface AlarmsCountResponse {
+ /** Total individual events returned by OAP — capped at
+ * COUNT_FETCH_CAP. */
+ total: number;
+ /** Events with `recoveryTime === null`. */
+ firing: number;
+ /** Distinct (entity, rule) groups across `total` — one per OAP id.
+ * This is the "real" incident count regardless of re-firings. */
+ incidents: number;
+ /** Subset of `incidents` whose LATEST event is still firing. The
+ * topbar badge displays this — a fully-recovered incident counts
+ * as "no alarm" per the page spec. */
+ activeIncidents: number;
+ truncated: boolean;
startTime: number;
endTime: number;
- step: 'MINUTE';
- series: AlarmTrafficSeries[];
+ generatedAt: number;
}
// ── Helpers ──────────────────────────────────────────────────────────
@@ -162,7 +161,6 @@ export interface AlarmTrafficResponse {
interface ServerTzInfo {
/** Minutes from UTC (e.g. 480 for UTC+8). 0 fallback. */
offsetMinutes: number;
- /** ms when this snapshot was taken. */
fetchedAt: number;
}
let tzCache: ServerTzInfo | null = null;
@@ -189,7 +187,6 @@ async function getServerOffsetMinutes(
TIME_INFO_QUERY,
);
const raw = got.time?.timezone ?? '+0000';
- // Format `±HHmm`, e.g. `+0800` for UTC+8.
const m = /^([+-])(\d{2})(\d{2})$/.exec(raw);
if (m) {
const sign = m[1] === '-' ? -1 : 1;
@@ -206,22 +203,8 @@ async function getServerOffsetMinutes(
return 0;
}
-/** Format an epoch-ms into OAP's `yyyy-MM-dd HHmm` (MINUTE granularity)
- * in the server's timezone. */
-function fmtMinute(epochMs: number, offsetMinutes: number): string {
- const shifted = new Date(epochMs + offsetMinutes * 60_000);
- const y = shifted.getUTCFullYear();
- const mo = String(shifted.getUTCMonth() + 1).padStart(2, '0');
- const d = String(shifted.getUTCDate()).padStart(2, '0');
- const h = String(shifted.getUTCHours()).padStart(2, '0');
- const mi = String(shifted.getUTCMinutes()).padStart(2, '0');
- return `${y}-${mo}-${d} ${h}${mi}`;
-}
-
/** Format an epoch-ms into OAP's `yyyy-MM-dd HHmmss` (SECOND
- * granularity). Used for the alarms query so the window includes
- * alarms that fire in the current minute — MINUTE-precision rounds
- * the end down and chops them off. */
+ * granularity), in the server's timezone. */
function fmtSecond(epochMs: number, offsetMinutes: number): string {
const shifted = new Date(epochMs + offsetMinutes * 60_000);
const y = shifted.getUTCFullYear();
@@ -233,34 +216,13 @@ function fmtSecond(epochMs: number, offsetMinutes: number): string {
return `${y}-${mo}-${d} ${h}${mi}${s}`;
}
-function safeFloat(v: string | null | undefined): number | null {
- if (v === null || v === undefined) return null;
- const n = Number(v);
- return Number.isFinite(n) ? n : null;
-}
-
-/** Minutes between two MINUTE-rounded epochs (inclusive endpoints). */
-function bucketCount(startMs: number, endMs: number): number {
- return Math.max(1, Math.round((endMs - startMs) / 60_000) + 1);
-}
-
-function buildTimeline(startMs: number, endMs: number): number[] {
- const n = bucketCount(startMs, endMs);
- const out: number[] = [];
- for (let i = 0; i < n; i++) out.push(startMs + i * 60_000);
- return out;
-}
-
-// ── GraphQL queries ───────────────────────────────────────────────────
+// ── GraphQL queries ──────────────────────────────────────────────────
-/* `events` is deliberately OMITTED — demo OAP (and likely any OAP
- * backed by some storage variants) throws `java.io.IOException:
- * fail to query stream` when the events resolver streams per-alarm
- * events alongside a 30-row alarm list. The snapshot already
- * carries everything the detail panel needs (expression + the MQE
- * values that crossed threshold). When per-alarm events are needed
- * later, fetch them via a separate keyword-filtered call against
- * this same endpoint, or via the Events query directly. */
+/* `events` is deliberately OMITTED — demo OAP (and some storage
+ * backends) throw `java.io.IOException: fail to query stream` when
+ * events stream per-row alongside a 30+ row page. The snapshot
+ * already carries everything the detail panel needs (expression +
+ * the MQE values that crossed threshold). */
const GET_ALARM_QUERY = /* GraphQL */ `
query HorizonGetAlarm(
$duration: Duration!
@@ -271,12 +233,7 @@ const GET_ALARM_QUERY = /* GraphQL */ `
) {
getAlarm(duration: $duration, scope: $scope, keyword: $keyword, paging: $paging, tags: $tags) {
msgs {
- id
- startTime
- recoveryTime
- scope
- name
- message
+ id startTime recoveryTime scope name message
tags { key value }
snapshot {
expression
@@ -292,22 +249,11 @@ const GET_ALARM_QUERY = /* GraphQL */ `
}
}
`;
-
-/* New-API variant — added in query-protocol #157 alongside the
- * deprecation of `getAlarm`. Same `Alarms.msgs` selection set as the
- * legacy query so the row mapper stays shared; the only difference is
- * the wrapper (`condition` input vs. individual args) and the future
- * filter surface (entities / layers / ruleNames — wired in step 4). */
const QUERY_ALARMS_QUERY = /* GraphQL */ `
query HorizonQueryAlarms($condition: AlarmQueryCondition!) {
queryAlarms(condition: $condition) {
msgs {
- id
- startTime
- recoveryTime
- scope
- name
- message
+ id startTime recoveryTime scope name message
tags { key value }
snapshot {
expression
@@ -323,43 +269,132 @@ const QUERY_ALARMS_QUERY = /* GraphQL */ `
}
}
`;
-
+/* Lightweight selection for the topbar badge — count primitives + a
+ * startTime so the incident merger can pick the latest event per
+ * (entity, rule) group. Snapshot / tags / message stay omitted to
+ * keep the payload cheap (target: < 5kB per poll at the 200-row
+ * cap). */
+const COUNT_GET_ALARM_QUERY = /* GraphQL */ `
+ query HorizonCountGetAlarm($duration: Duration!, $paging: Pagination!) {
+ getAlarm(duration: $duration, paging: $paging) { msgs { id startTime recoveryTime } }
+ }
+`;
+const COUNT_QUERY_ALARMS_QUERY = /* GraphQL */ `
+ query HorizonCountQueryAlarms($condition: AlarmQueryCondition!) {
+ queryAlarms(condition: $condition) { msgs { id startTime recoveryTime } }
+ }
+`;
const LIST_SERVICES_QUERY = /* GraphQL */ `
query HorizonAlarmServices($layer: String!) {
listServices(layer: $layer) { name normal }
}
`;
-interface ListServicesRaw {
- listServices: Array<{ name: string; normal: boolean | null }>;
-}
-
interface GetAlarmRaw {
getAlarm?: { msgs?: AlarmMessage[] } | null;
}
interface QueryAlarmsRaw {
queryAlarms?: { msgs?: AlarmMessage[] } | null;
}
+interface ListServicesRaw {
+ listServices: Array<{ name: string; normal: boolean | null }>;
+}
-// ── Routes ───────────────────────────────────────────────────────────
+// ── Schemas + caps ───────────────────────────────────────────────────
+
+/** Window cap for `/api/alarms` and `/api/alarms/count`. Defence-in-
+ * depth — the UI picker already enforces this, but a hand-crafted
+ * URL shouldn't pull a 24h fan-out from OAP. */
+const WINDOW_CAP_MS = 4 * 60 * 60_000;
+const LIST_PAGE_SIZE_CAP = 500;
+const COUNT_FETCH_CAP = 200;
const alarmsQuerySchema = z.object({
startTime: z.coerce.number().int().positive(),
endTime: z.coerce.number().int().positive(),
+ /** Legacy-mode only. Ignored in new mode (use `layer` + entity
+ * fields instead). */
scope: z.string().optional(),
keyword: z.string().optional(),
pageNum: z.coerce.number().int().min(1).default(1),
- pageSize: z.coerce.number().int().min(1).max(200).default(100),
+ pageSize: z.coerce.number().int().min(1).max(LIST_PAGE_SIZE_CAP).default(LIST_PAGE_SIZE_CAP),
+ /** New-mode only. Maps to `condition.layers: [layer]`. */
+ layer: z.string().optional(),
+ /** New-mode only. Combined with `instance` / `endpoint` to build a
+ * single `Entity` filter. When absent, no entity narrowing. */
service: z.string().optional(),
instance: z.string().optional(),
endpoint: z.string().optional(),
});
-const trafficQuerySchema = z.object({
+const countQuerySchema = z.object({
startTime: z.coerce.number().int().positive(),
endTime: z.coerce.number().int().positive(),
});
+// ── Entity builder (new mode) ────────────────────────────────────────
+
+/* Translate the cascade fields (`layer`, `service`, `instance`,
+ * `endpoint`) into the smallest precise `Entity` that the
+ * queryAlarms `condition.entities` filter accepts. Scope is inferred
+ * from which name fields are populated — same convention OAP itself
+ * uses (see alarm.graphqls comment on `entities`).
+ *
+ * Returns null when no entity narrowing is requested. The caller then
+ * omits `entities` from the condition entirely, leaving `layers`
+ * as the only entity-side filter (or no entity filter at all when
+ * `layer` is also blank).
+ */
+interface EntityFilter {
+ scope: string;
+ serviceName?: string;
+ normal?: boolean;
+ serviceInstanceName?: string;
+ endpointName?: string;
+}
+function buildEntity(q: {
+ service?: string;
+ instance?: string;
+ endpoint?: string;
+}): EntityFilter | null {
+ if (!q.service) return null;
+ const base: EntityFilter = { scope: 'Service', serviceName: q.service, normal: true };
+ if (q.endpoint && !q.instance) {
+ return { ...base, scope: 'Endpoint', endpointName: q.endpoint };
+ }
+ if (q.instance) {
+ return { ...base, scope: 'ServiceInstance', serviceInstanceName: q.instance };
+ }
+ return base;
+}
+
+// ── Row tagging ──────────────────────────────────────────────────────
+
+async function tagWithLayer(
+ msgsRaw: AlarmMessage[],
+ serviceLayer: ServiceLayerMap,
+): Promise<AlarmMessage[]> {
+ const layerIdx = await serviceLayer.get();
+ return msgsRaw.map((m) => {
+ /* Entity name on Service scope is the service name directly; on
+ * ServiceInstance / Endpoint the wire packs the service name as
+ * a prefix before a separator. Try the literal first, then strip
+ * after common separators. */
+ const candidates = [m.name, m.name.split('::')[0], m.name.split(' ')[0]];
+ let layerKey: string | null = null;
+ for (const cand of candidates) {
+ const hit = layerIdx.byName.get(cand.toLowerCase());
+ if (hit) {
+ layerKey = hit;
+ break;
+ }
+ }
+ return { ...m, layerKey };
+ });
+}
+
+// ── Routes ───────────────────────────────────────────────────────────
+
export function registerAlarmsQueryRoutes(app: FastifyInstance, deps: AlarmsQueryRouteDeps): void {
const auth = requireAuth(deps);
const serviceLayer = deps.serviceLayer;
@@ -372,36 +407,41 @@ export function registerAlarmsQueryRoutes(app: FastifyInstance, deps: AlarmsQuer
}
const q = parsed.data;
if (q.endTime <= q.startTime) return badRequest('endTime must be greater than startTime');
+ if (q.endTime - q.startTime > WINDOW_CAP_MS) {
+ return badRequest(`window exceeds ${WINDOW_CAP_MS / 60_000}m cap`);
+ }
const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
- // SECOND-precision duration window. Booster-ui uses SECOND here
- // and it's the right call: MINUTE-rounding the end excludes any
- // alarm whose startTime is in the current/upcoming minute, which
- // for OAP installs that emit alarms continuously means the UI
- // perpetually misses the most-recent firing. Per-second precision
- // keeps the latest alarm in the window.
const start = fmtSecond(q.startTime, offset);
const end = fmtSecond(q.endTime, offset);
const opts = buildOapOpts(deps.config.current, deps.fetch);
const caps = await getOapCapabilities(deps.config.current, deps.fetch);
- /* Branch on schema capability: `queryAlarms(condition)` is the
- * forward path — same returned shape, bundles every filter into
- * one input type so step-4 entity / layer / ruleName filters land
- * here without a route change. `getAlarm` stays as the fallback
- * for older OAPs (scope+keyword+tags-only). */
let msgsRaw: AlarmMessage[];
try {
if (caps.queryAlarms) {
+ /* New-mode condition. `entities` + `layers` ride server-side;
+ * `scope` is intentionally ignored here because it's a
+ * legacy-only coarse filter and `entities` + `layers` cover
+ * its use-cases more precisely. */
const condition: Record<string, unknown> = {
duration: { start, end, step: 'SECOND' },
paging: { pageNum: q.pageNum, pageSize: q.pageSize },
};
if (q.keyword) condition.keyword = q.keyword;
+ if (q.layer) condition.layers = [q.layer];
+ const entity = buildEntity(q);
+ if (entity) condition.entities = [entity];
const raw = await graphqlPost<QueryAlarmsRaw>(opts, QUERY_ALARMS_QUERY, { condition });
msgsRaw = raw.queryAlarms?.msgs ?? [];
} else {
+ /* Legacy mode: scope + keyword + tags only. The UI hides the
+ * layer / cascade filter row in this mode, so `layer` /
+ * `service` / `instance` / `endpoint` query params should
+ * not be present — but if they are (operator hand-rolled a
+ * URL), the BFF silently ignores them rather than 400-ing,
+ * matching the spirit of the spec's "drop fake filters". */
const variables: Record<string, unknown> = {
duration: { start, end, step: 'SECOND' },
paging: { pageNum: q.pageNum, pageSize: q.pageSize },
@@ -418,163 +458,112 @@ export function registerAlarmsQueryRoutes(app: FastifyInstance, deps: AlarmsQuer
});
}
- // Service-name → layer tag (best-effort, see service-layer-map docs).
- const layerIdx = await serviceLayer.get();
-
- // Post-filter — OAP doesn't expose service/instance/endpoint filters
- // on `getAlarm` directly, so we filter the page client-side. The
- // page-size cap (200) keeps this O(pageSize).
- const filtered = msgsRaw.filter((m) => {
- if (q.service && !m.name.toLowerCase().includes(q.service.toLowerCase())) return false;
- if (q.instance && !m.name.toLowerCase().includes(q.instance.toLowerCase())) return false;
- if (q.endpoint && !m.name.toLowerCase().includes(q.endpoint.toLowerCase())) return false;
- return true;
- });
-
- const tagged: AlarmMessage[] = filtered.map((m) => {
- // Entity name on `Service` scope is the service name directly;
- // on `ServiceInstance` / `Endpoint` the wire packs the service
- // name as a prefix before a separator — try the literal name
- // first, then strip after common separators.
- const candidates = [m.name, m.name.split('::')[0], m.name.split(' ')[0]];
- let layerKey: string | null = null;
- for (const cand of candidates) {
- const hit = layerIdx.byName.get(cand.toLowerCase());
- if (hit) {
- layerKey = hit;
- break;
- }
- }
- return { ...m, layerKey };
- });
+ const tagged = await tagWithLayer(msgsRaw, serviceLayer);
const body: AlarmsResponse = {
total: tagged.length,
pageNum: q.pageNum,
pageSize: q.pageSize,
+ truncated: tagged.length >= q.pageSize,
generatedAt: Date.now(),
msgs: tagged,
};
return reply.send(body);
});
- // ── GET /api/alarms/traffic ────────────────────────────────────────
+ // ── GET /api/alarms/count ──────────────────────────────────────────
app.get(
- '/api/alarms/traffic',
+ '/api/alarms/count',
{ preHandler: auth },
async (req: FastifyRequest, reply: FastifyReply) => {
- const parsed = trafficQuerySchema.safeParse(req.query);
+ const parsed = countQuerySchema.safeParse(req.query);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_query', detail: parsed.error.flatten() });
}
const q = parsed.data;
if (q.endTime <= q.startTime) return badRequest('endTime must be greater than startTime');
-
- // Round to minute boundaries to match the OAP MINUTE step.
- const startMs = Math.floor(q.startTime / 60_000) * 60_000;
- const endMs = Math.floor(q.endTime / 60_000) * 60_000;
+ if (q.endTime - q.startTime > WINDOW_CAP_MS) {
+ return badRequest(`window exceeds ${WINDOW_CAP_MS / 60_000}m cap`);
+ }
const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
- const start = fmtMinute(startMs, offset);
- const end = fmtMinute(endMs, offset);
- const timeline = buildTimeline(startMs, endMs);
+ const start = fmtSecond(q.startTime, offset);
+ const end = fmtSecond(q.endTime, offset);
- const cfg = await deps.store.load();
const opts = buildOapOpts(deps.config.current, deps.fetch);
+ const caps = await getOapCapabilities(deps.config.current, deps.fetch);
- const series: AlarmTrafficSeries[] = await Promise.all(
- cfg.trafficLayers.map<Promise<AlarmTrafficSeries>>(async (L) => {
- const label = L.label ?? prettyLayer(L.layerKey);
- // 1) list services in the layer.
- let services: Array<{ name: string; normal: boolean | null }>;
- try {
- const ls = await graphqlPost<ListServicesRaw>(opts, LIST_SERVICES_QUERY, {
- layer: L.layerKey,
- });
- services = Array.isArray(ls.listServices) ? ls.listServices : [];
- } catch (err) {
- return {
- layerKey: L.layerKey,
- label,
- present: false,
- points: timeline.map((ts) => ({ ts, value: null })),
- error: err instanceof Error ? err.message : String(err),
- };
- }
- if (services.length === 0) {
- return {
- layerKey: L.layerKey,
- label,
- present: false,
- points: timeline.map((ts) => ({ ts, value: null })),
- };
- }
-
- // 2) fan out MQE for each service. Cap at 40 services to
- // bound the upstream graphql blast — the alarms timeline
- // is a glanceable backdrop, not a precise dashboard.
- const cap = 40;
- const sliced = services.slice(0, cap);
- const aliased = sliced
- .map(
- (s, i) =>
- `s${i}: execExpression(\n` +
- ` expression: ${JSON.stringify(L.mqe)},\n` +
- ` entity: { scope: Service, serviceName: ${JSON.stringify(s.name)}, normal: ${s.normal === false ? 'false' : 'true'} },\n` +
- ` duration: { start: ${JSON.stringify(start)}, end: ${JSON.stringify(end)}, step: MINUTE }\n` +
- ` ) { type error results { values { value } } }`,
- )
- .join('\n');
- const query = `query HorizonAlarmTrafficLayer { ${aliased} }`;
- let data: Record<string, { error?: string | null; results?: Array<{ values: Array<{ value: string | null }> }> }>;
- try {
- data = await graphqlPost(opts, query);
- } catch (err) {
- return {
- layerKey: L.layerKey,
- label,
- present: false,
- points: timeline.map((ts) => ({ ts, value: null })),
- error: err instanceof Error ? err.message : String(err),
- };
- }
- // 3) Sum per-bucket across services.
- const summed: Array<number | null> = timeline.map(() => null);
- for (let i = 0; i < sliced.length; i++) {
- const row = data[`s${i}`];
- const values = row?.results?.[0]?.values ?? [];
- for (let b = 0; b < timeline.length; b++) {
- const v = safeFloat(values[b]?.value);
- if (v === null) continue;
- summed[b] = (summed[b] ?? 0) + v;
- }
- }
- return {
- layerKey: L.layerKey,
- label,
- present: true,
- points: timeline.map((ts, i) => ({ ts, value: summed[i] })),
+ let rows: Array<{ id: string; startTime: number; recoveryTime: number | null }>;
+ try {
+ if (caps.queryAlarms) {
+ const condition = {
+ duration: { start, end, step: 'SECOND' },
+ paging: { pageNum: 1, pageSize: COUNT_FETCH_CAP },
+ };
+ const raw = await graphqlPost<QueryAlarmsRaw>(opts, COUNT_QUERY_ALARMS_QUERY, { condition });
+ rows = (raw.queryAlarms?.msgs ?? []).map((m) => ({
+ id: m.id,
+ startTime: m.startTime,
+ recoveryTime: m.recoveryTime,
+ }));
+ } else {
+ const variables = {
+ duration: { start, end, step: 'SECOND' },
+ paging: { pageNum: 1, pageSize: COUNT_FETCH_CAP },
};
- }),
- );
+ const raw = await graphqlPost<GetAlarmRaw>(opts, COUNT_GET_ALARM_QUERY, variables);
+ rows = (raw.getAlarm?.msgs ?? []).map((m) => ({
+ id: m.id,
+ startTime: m.startTime,
+ recoveryTime: m.recoveryTime,
+ }));
+ }
+ } catch (err) {
+ return reply.code(502).send({
+ error: 'oap_unreachable',
+ message: err instanceof Error ? err.message : String(err),
+ });
+ }
- const body: AlarmTrafficResponse = {
+ const total = rows.length;
+ const firing = rows.reduce((n, r) => n + (r.recoveryTime === null ? 1 : 0), 0);
+
+ /* Group by OAP id (= entity.rule key); the incident's state is
+ * the LATEST event's state. Matches the UI's `mergeIncidents`
+ * semantics — keep both implementations in lock-step. */
+ const latestByGroup = new Map<string, { startTime: number; recoveryTime: number | null }>();
+ for (const r of rows) {
+ const cur = latestByGroup.get(r.id);
+ if (!cur || r.startTime > cur.startTime) {
+ latestByGroup.set(r.id, { startTime: r.startTime, recoveryTime: r.recoveryTime });
+ }
+ }
+ const incidents = latestByGroup.size;
+ let activeIncidents = 0;
+ for (const v of latestByGroup.values()) {
+ if (v.recoveryTime === null) activeIncidents += 1;
+ }
+
+ const body: AlarmsCountResponse = {
+ total,
+ firing,
+ incidents,
+ activeIncidents,
+ truncated: total >= COUNT_FETCH_CAP,
+ startTime: q.startTime,
+ endTime: q.endTime,
generatedAt: Date.now(),
- startTime: startMs,
- endTime: endMs,
- step: 'MINUTE',
- series,
};
return reply.send(body);
},
);
// ── GET /api/alarms/services?layer=X ──────────────────────────────
- // Cascading-filter helper for the alarms page. Returns the service
- // roster for one OAP layer in alpha order so the UI can populate
- // a dropdown without re-implementing the listServices wire. The
- // instance + endpoint pickers reuse the existing
- // /api/layer/:key/instances and /api/layer/:key/endpoints endpoints.
+ /* Cascading-filter helper. Returns the service roster for one OAP
+ * layer in alpha order so the UI populates a dropdown without
+ * re-implementing the listServices wire. The instance + endpoint
+ * pickers reuse the existing /api/layer/:key/instances and
+ * /api/layer/:key/endpoints endpoints. */
app.get(
'/api/alarms/services',
{ preHandler: auth },
@@ -598,13 +587,4 @@ export function registerAlarmsQueryRoutes(app: FastifyInstance, deps: AlarmsQuer
}
},
);
-
-}
-
-function prettyLayer(key: string): string {
- return key
- .toLowerCase()
- .split('_')
- .map((w) => (w.length > 0 ? w[0]!.toUpperCase() + w.slice(1) : ''))
- .join(' ');
}
diff --git a/apps/bff/src/logic/alarms/store.ts b/apps/bff/src/logic/alarms/store.ts
index 4ce091d..2c9299e 100644
--- a/apps/bff/src/logic/alarms/store.ts
+++ b/apps/bff/src/logic/alarms/store.ts
@@ -17,16 +17,22 @@
/**
* File-backed store for the Alarms-page setup. Holds the list of OAP
- * layers whose RPM traffic is rendered as the background series on
- * the alarms timeline, with the MQE expression to fire per layer.
+ * layers that get their own dedicated KPI tile + breakdown chip on the
+ * alarms page header (the "pinned" layers). Everything not on this
+ * list shows up under the overflow-chip row when it has at least one
+ * alarm in the visible window.
*
- * Defaults (when no override file exists) seed agent + mesh so a
- * fresh install shows traffic out-of-the-box. Operators add / remove
- * layers and tune the MQE via the Alert Page Setup admin view.
+ * Defaults seed agent (`GENERAL`) + mesh (`MESH`) so a fresh install
+ * has sensible top-of-page tiles out-of-the-box.
*
- * Storage convention mirrors `SetupStore`: tolerant of `{layers:...}`
- * envelope OR bare object; serialised writes with a `.tmp` rename so
- * the file never appears half-written to a watcher.
+ * Backward compatibility: an older shape stored `trafficLayers:
+ * [{layerKey, mqe, label}]` for the (now-removed) traffic-backdrop
+ * chart. The loader silently projects that down to `pinnedLayers`
+ * (just the layer keys, deduped, order preserved) so operators don't
+ * have to re-pick their layers after upgrade.
+ *
+ * Storage convention mirrors `SetupStore`: serialised writes with a
+ * `.tmp` rename so the file never appears half-written to a watcher.
*/
import { existsSync } from 'node:fs';
@@ -34,36 +40,53 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { logger } from '../../logger.js';
-/** One background-traffic series the alarms timeline renders. */
-export interface AlarmTrafficLayer {
- /** OAP layer key in canonical form — `GENERAL`, `MESH`, `K8S`,
- * `MESH_DP`, etc. Used both as the entity scope's layer filter and
- * the display label fallback. */
- layerKey: string;
- /** MQE expression to fire for this layer. Must yield a per-service
- * labeled time-series; the BFF sums across services to produce a
- * single layer-aggregate line. */
- mqe: string;
- /** Optional display label override. If absent the BFF derives it
- * from the layer's canonical name. */
- label?: string;
-}
-
export interface AlarmsConfig {
- trafficLayers: AlarmTrafficLayer[];
+ /** OAP layer keys (canonical `GENERAL`, `MESH`, `K8S_SERVICE`, …)
+ * that get a dedicated tile on the alarms page header. The order
+ * here is the render order on the page. Defaults to
+ * `['GENERAL', 'MESH']`. */
+ pinnedLayers: string[];
+ /** Default time window in milliseconds for the topbar alarm badge
+ * AND the alarms page's initial picker selection. The overview
+ * "Active alarms" widget keeps its own 60m window; this only
+ * affects the recent-triage surfaces. Must be one of the page
+ * picker presets — 20m / 2h / 4h. Defaults to 20m. */
+ defaultWindowMs: number;
+ /** Fetch cap for the overview "Active alarms" widget. The widget
+ * pulls this many rows from `/api/alarms` per poll, merges them
+ * into incidents client-side, and surfaces the top `widget.limit`
+ * rows. Higher caps catch more incident variety in noisy
+ * installs at the cost of more wire bytes per poll. Defaults to
+ * 200; must be in [10, 500]. */
+ overviewAlarmsLimit: number;
}
-/* Default seed — agent (`GENERAL` in OAP) + Istio data plane (`MESH`).
- * Both use `service_cpm` because that's the RPM metric every service-
- * scoped layer reports. Layers that aren't active on the OAP install
- * are silently skipped by the traffic route. */
+/** Allowed values for `defaultWindowMs`. Matches the alarms page's
+ * preset list so the admin's choice always lights up an existing
+ * tab on the page. */
+export const ALARMS_WINDOW_OPTIONS = [
+ 20 * 60_000,
+ 2 * 60 * 60_000,
+ 4 * 60 * 60_000,
+] as const;
+
+export const OVERVIEW_ALARMS_LIMIT_MIN = 10;
+export const OVERVIEW_ALARMS_LIMIT_MAX = 500;
+export const OVERVIEW_ALARMS_LIMIT_DEFAULT = 200;
+
const DEFAULT_CONFIG: AlarmsConfig = {
- trafficLayers: [
- { layerKey: 'GENERAL', mqe: 'service_cpm', label: 'Agent' },
- { layerKey: 'MESH', mqe: 'service_cpm', label: 'Mesh' },
- ],
+ pinnedLayers: ['GENERAL', 'MESH'],
+ defaultWindowMs: ALARMS_WINDOW_OPTIONS[0],
+ overviewAlarmsLimit: OVERVIEW_ALARMS_LIMIT_DEFAULT,
};
+/* Legacy shape — kept only as a tolerant input type for `load()`. New
+ * code reads / writes `pinnedLayers` exclusively; the next `save()`
+ * after an upgraded load rewrites the file in the new shape. */
+interface LegacyTrafficLayer {
+ layerKey?: unknown;
+}
+
export class AlarmsStore {
private readonly absPath: string;
private cache: AlarmsConfig | null = null;
@@ -76,26 +99,64 @@ export class AlarmsStore {
async load(): Promise<AlarmsConfig> {
if (this.cache) return this.cache;
if (!existsSync(this.absPath)) {
- this.cache = { ...DEFAULT_CONFIG, trafficLayers: [...DEFAULT_CONFIG.trafficLayers] };
+ this.cache = { ...DEFAULT_CONFIG, pinnedLayers: [...DEFAULT_CONFIG.pinnedLayers] };
return this.cache;
}
try {
const raw = await readFile(this.absPath, 'utf8');
- const parsed = JSON.parse(raw) as Partial<AlarmsConfig> & { generatedAt?: number };
- const trafficLayers = Array.isArray(parsed.trafficLayers)
- ? parsed.trafficLayers.filter(
- (l): l is AlarmTrafficLayer =>
- typeof l === 'object' &&
- l !== null &&
- typeof (l as AlarmTrafficLayer).layerKey === 'string' &&
- typeof (l as AlarmTrafficLayer).mqe === 'string',
- )
- : [...DEFAULT_CONFIG.trafficLayers];
- this.cache = { trafficLayers };
+ const parsed = JSON.parse(raw) as {
+ pinnedLayers?: unknown;
+ trafficLayers?: unknown;
+ defaultWindowMs?: unknown;
+ overviewAlarmsLimit?: unknown;
+ };
+
+ // Prefer the new shape when present.
+ let pinned: string[] | null = null;
+ if (Array.isArray(parsed.pinnedLayers)) {
+ pinned = parsed.pinnedLayers.filter(
+ (v): v is string => typeof v === 'string' && v.length > 0,
+ );
+ } else if (Array.isArray(parsed.trafficLayers)) {
+ // Project legacy `trafficLayers: [{layerKey,...}]` down to the
+ // pinned-layer set. Dedup + preserve first-seen order so the
+ // header reads the same as the prior traffic-chart order.
+ const seen = new Set<string>();
+ const out: string[] = [];
+ for (const l of parsed.trafficLayers as LegacyTrafficLayer[]) {
+ const k = typeof l?.layerKey === 'string' ? l.layerKey : '';
+ if (!k || seen.has(k)) continue;
+ seen.add(k);
+ out.push(k);
+ }
+ pinned = out.length > 0 ? out : null;
+ }
+
+ /* defaultWindowMs is one of the page-picker preset values; any
+ * out-of-allowlist value (file edited by hand, drifted enum)
+ * falls back to the default. */
+ const rawWin = typeof parsed.defaultWindowMs === 'number' ? parsed.defaultWindowMs : NaN;
+ const defaultWindowMs = (ALARMS_WINDOW_OPTIONS as readonly number[]).includes(rawWin)
+ ? rawWin
+ : DEFAULT_CONFIG.defaultWindowMs;
+
+ const rawLim = typeof parsed.overviewAlarmsLimit === 'number' ? parsed.overviewAlarmsLimit : NaN;
+ const overviewAlarmsLimit =
+ Number.isInteger(rawLim) &&
+ rawLim >= OVERVIEW_ALARMS_LIMIT_MIN &&
+ rawLim <= OVERVIEW_ALARMS_LIMIT_MAX
+ ? rawLim
+ : DEFAULT_CONFIG.overviewAlarmsLimit;
+
+ this.cache = {
+ pinnedLayers: pinned ?? [...DEFAULT_CONFIG.pinnedLayers],
+ defaultWindowMs,
+ overviewAlarmsLimit,
+ };
return this.cache;
} catch (err) {
logger.warn({ err, path: this.absPath }, 'alarms store unreadable; using defaults');
- this.cache = { ...DEFAULT_CONFIG, trafficLayers: [...DEFAULT_CONFIG.trafficLayers] };
+ this.cache = { ...DEFAULT_CONFIG, pinnedLayers: [...DEFAULT_CONFIG.pinnedLayers] };
return this.cache;
}
}
@@ -103,7 +164,11 @@ export class AlarmsStore {
async save(next: AlarmsConfig): Promise<void> {
while (this.writing) await this.writing;
const tmp = `${this.absPath}.tmp`;
- const snapshot: AlarmsConfig = JSON.parse(JSON.stringify(next));
+ const snapshot: AlarmsConfig = {
+ pinnedLayers: [...next.pinnedLayers],
+ defaultWindowMs: next.defaultWindowMs,
+ overviewAlarmsLimit: next.overviewAlarmsLimit,
+ };
const work = (async () => {
await mkdir(dirname(this.absPath), { recursive: true });
await writeFile(
diff --git a/apps/bff/src/logic/overview/loader.ts b/apps/bff/src/logic/overview/loader.ts
index ae78189..e9ec1b6 100644
--- a/apps/bff/src/logic/overview/loader.ts
+++ b/apps/bff/src/logic/overview/loader.ts
@@ -186,7 +186,8 @@ export function loadOverviewDashboards(): OverviewDashboard[] {
return cache;
}
-/** Force a re-read on the next call. Used in tests. */
+/** Force a re-read on the next call. Used in tests + by the admin
+ * write path so the next dashboard fetch sees the freshly-saved JSON. */
export function invalidateOverviewCache(): void {
cache = null;
}
@@ -194,3 +195,37 @@ export function invalidateOverviewCache(): void {
export function getOverviewDashboard(id: string): OverviewDashboard | null {
return loadOverviewDashboards().find((d) => d.id === id) ?? null;
}
+
+/** Locate the on-disk JSON file backing `id`, if any. Bundle files are
+ * named by id but operators may use any filename — scan the dir for a
+ * match. Returns null when no file in the bundle dir parses with that
+ * id (admin then has nowhere to write). */
+export function findOverviewFile(id: string): string | null {
+ if (!fs.existsSync(CONFIG_DIR)) return null;
+ for (const f of fs.readdirSync(CONFIG_DIR)) {
+ if (!f.endsWith('.json')) continue;
+ try {
+ const raw = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, f), 'utf8'));
+ if (raw && typeof raw === 'object' && (raw as { id?: unknown }).id === id) {
+ return path.join(CONFIG_DIR, f);
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+ return null;
+}
+
+/** Persist a dashboard's full config back to disk and invalidate the
+ * cache. Atomic via `.tmp` rename so a partial write never leaves
+ * half-JSON on disk. */
+export function writeOverviewDashboard(id: string, dash: OverviewDashboard): void {
+ const file = findOverviewFile(id);
+ if (!file) {
+ throw new Error(`overview/${id}: no source file to write back to`);
+ }
+ const tmp = `${file}.tmp`;
+ fs.writeFileSync(tmp, JSON.stringify(dash, null, 2), 'utf8');
+ fs.renameSync(tmp, file);
+ invalidateOverviewCache();
+}
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 23399d7..f8d53c1 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -58,6 +58,8 @@ import { registerDslOalRoutes } from './http/admin/dsl/oal.js';
import { registerClusterRoutes } from './http/admin/cluster.js';
import { registerDebugRoutes } from './http/admin/live-debug.js';
import { registerInspectRoutes } from './http/admin/inspect.js';
+import { registerAlarmRulesRoutes } from './http/admin/alarm-rules.js';
+import { registerOverviewTemplatesAdminRoutes } from './http/admin/overview-templates.js';
// Logic / stores
import { AlarmsStore } from './logic/alarms/store.js';
import { SetupStore } from './logic/setup/store.js';
@@ -113,7 +115,7 @@ registerTraceTagRoutes(app, { config: source, sessions });
registerZipkinRoutes(app, { config: source, sessions });
registerLogRoute(app, { config: source, sessions });
registerDashboardQueryRoute(app, { config: source, sessions });
-registerAlarmsQueryRoutes(app, { config: source, sessions, serviceLayer, store: alarmsStore });
+registerAlarmsQueryRoutes(app, { config: source, sessions, serviceLayer });
registerPreflightRoutes(app, { config: source, sessions });
registerProfileRoutes(app, { config: source, sessions });
registerEBPFRoutes(app, { config: source, sessions });
@@ -135,6 +137,8 @@ registerDslOalRoutes(app, { config: source, sessions, audit });
registerClusterRoutes(app, { config: source, sessions, audit });
registerDebugRoutes(app, { config: source, sessions, audit });
registerInspectRoutes(app, { config: source, sessions, audit });
+registerAlarmRulesRoutes(app, { config: source, sessions });
+registerOverviewTemplatesAdminRoutes(app, { config: source, sessions });
// Serve the built SPA out of the BFF when HORIZON_STATIC_DIR points at a
// directory (Docker image layout: /app/static contains the Vite dist).
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index bcc6929..88bfa36 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -421,42 +421,115 @@ export interface AlarmsResponse {
total: number;
pageNum: number;
pageSize: number;
+ /** True iff `total === pageSize`. The page should warn the operator
+ * that there may be more alarms than shown. */
+ truncated: boolean;
generatedAt: number;
msgs: AlarmMessage[];
}
export interface AlarmsQuery {
startTime: number;
endTime: number;
+ /** Legacy mode only — Service / Endpoint / etc. Ignored in new-API
+ * mode in favor of the `layer` + entity-cascade fields. */
scope?: string;
keyword?: string;
pageNum?: number;
pageSize?: number;
+ /** New-API mode only — narrows to alarms in this OAP layer. */
+ layer?: string;
service?: string;
instance?: string;
endpoint?: string;
}
-export interface AlarmTrafficPoint { ts: number; value: number | null }
-export interface AlarmTrafficSeries {
- layerKey: string;
- label: string;
- present: boolean;
- points: AlarmTrafficPoint[];
+export interface AlarmsCountResponse {
+ /** Total individual events returned (capped). */
+ total: number;
+ /** Events where `recoveryTime === null`. */
+ firing: number;
+ /** Distinct (entity, rule) incidents. */
+ incidents: number;
+ /** Incidents whose latest event is firing. Topbar badge reads
+ * this — fully-recovered incidents count as "no alarm". */
+ activeIncidents: number;
+ truncated: boolean;
+ startTime: number;
+ endTime: number;
+ generatedAt: number;
+}
+/** Mirror of `AlarmRuleDetail` on the BFF (which mirrors OAP's
+ * `AlarmRuleDetail` Java type). Used by the alarms detail panel +
+ * the Operate › Alerting Rules page. */
+export interface AlarmRuleDetail {
+ ruleId: string;
+ expression: string;
+ period: number;
+ silencePeriod: number;
+ recoveryObservationPeriod: number;
+ additionalPeriod: number;
+ includeEntityNames: string[];
+ excludeEntityNames: string[];
+ includeEntityNamesRegex?: string;
+ excludeEntityNamesRegex?: string;
+ runningEntities: Array<{ scope: string; name: string; formattedMessage: string }>;
+ tags: Array<{ key: string; value: string }>;
+ hooks: string[];
+ includeMetrics: string[];
+}
+export interface AlertingRuleNode {
+ address: string;
+ ok: boolean;
error?: string;
+ loaded: boolean;
+}
+export interface AlertingRuleSummary {
+ ruleId: string;
+ detail: AlarmRuleDetail | null;
+ nodes: AlertingRuleNode[];
+ loadedOn: number;
+ totalNodes: number;
}
-export interface AlarmTrafficResponse {
+export interface AlertingRulesListResponse {
generatedAt: number;
- startTime: number;
- endTime: number;
- step: 'MINUTE';
- series: AlarmTrafficSeries[];
+ reachable: boolean;
+ error?: string;
+ rules: AlertingRuleSummary[];
+ nodes: Array<{ address: string; ok: boolean; error?: string }>;
}
-export interface AlarmTrafficLayerConfig {
- layerKey: string;
- mqe: string;
- label?: string;
+export interface AlertingRuleDetailResponse {
+ ruleId: string;
+ generatedAt: number;
+ reachable: boolean;
+ error?: string;
+ detail: AlarmRuleDetail | null;
+ nodes: Array<{ address: string; ok: boolean; error?: string; detail: AlarmRuleDetail | null }>;
}
+/** Allowed values for `AlarmsConfig.defaultWindowMs`, in ms. Matches
+ * the alarms page's preset list so the admin's choice always
+ * corresponds to a real tab. */
+export const ALARMS_WINDOW_OPTIONS = [
+ 20 * 60_000,
+ 2 * 60 * 60_000,
+ 4 * 60 * 60_000,
+] as const;
+export type AlarmsWindowMs = (typeof ALARMS_WINDOW_OPTIONS)[number];
+
+export const OVERVIEW_ALARMS_LIMIT_MIN = 10;
+export const OVERVIEW_ALARMS_LIMIT_MAX = 500;
+export const OVERVIEW_ALARMS_LIMIT_DEFAULT = 200;
+
export interface AlarmsConfig {
- trafficLayers: AlarmTrafficLayerConfig[];
+ /** OAP layer keys (canonical `GENERAL`, `MESH`, …) that get a
+ * dedicated tile on the alarms page header. Render order matches
+ * the array order. */
+ pinnedLayers: string[];
+ /** Default time window in milliseconds for the topbar alarm badge
+ * AND the alarms page's initial picker selection. */
+ defaultWindowMs: number;
+ /** Fetch cap for the overview "Active alarms" widget. Default 200,
+ * range [10, 500]. Bigger = more incident variety; smaller =
+ * cheaper poll. */
+ overviewAlarmsLimit: number;
}
type On401 = () => void;
diff --git a/apps/ui/src/api/scopes/alarms.test.ts b/apps/ui/src/api/scopes/alarms.test.ts
index 01d517a..ca6cc5a 100644
--- a/apps/ui/src/api/scopes/alarms.test.ts
+++ b/apps/ui/src/api/scopes/alarms.test.ts
@@ -34,27 +34,28 @@ describe('AlarmsApi.list — query param assembly', () => {
it('includes only the required fields when filters are empty', async () => {
const { bff, calls } = makeStub();
await new AlarmsApi(bff).list({ startTime: 100, endTime: 200 });
- expect(calls[0][1]).toBe('/api/alarms?startTime=100&endTime=200&pageNum=1&pageSize=100');
+ expect(calls[0][1]).toBe('/api/alarms?startTime=100&endTime=200&pageNum=1&pageSize=500');
});
- it('defaults pageNum=1 and pageSize=100 when not provided', async () => {
+ it('defaults pageNum=1 and pageSize=500 when not provided', async () => {
const { bff, calls } = makeStub();
await new AlarmsApi(bff).list({ startTime: 1, endTime: 2 });
expect(calls[0][1]).toContain('pageNum=1');
- expect(calls[0][1]).toContain('pageSize=100');
+ expect(calls[0][1]).toContain('pageSize=500');
});
- it('forwards entity filters (service / instance / endpoint) with URL encoding', async () => {
+ it('forwards entity filters (layer / service / instance / endpoint) with URL encoding', async () => {
const { bff, calls } = makeStub();
await new AlarmsApi(bff).list({
startTime: 1,
endTime: 2,
+ layer: 'MESH',
service: 'mesh-svr::reviews',
instance: 'reviews-pod-1',
endpoint: '/api/orders',
});
expect(calls[0][1]).toBe(
- '/api/alarms?startTime=1&endTime=2&pageNum=1&pageSize=100&service=mesh-svr%3A%3Areviews&instance=reviews-pod-1&endpoint=%2Fapi%2Forders',
+ '/api/alarms?startTime=1&endTime=2&pageNum=1&pageSize=500&layer=MESH&service=mesh-svr%3A%3Areviews&instance=reviews-pod-1&endpoint=%2Fapi%2Forders',
);
});
@@ -71,11 +72,11 @@ describe('AlarmsApi.list — query param assembly', () => {
});
});
-describe('AlarmsApi.traffic + services + config', () => {
- it('traffic GETs /api/alarms/traffic with start + end', async () => {
+describe('AlarmsApi.services + config + count', () => {
+ it('count GETs /api/alarms/count with start + end', async () => {
const { bff, calls } = makeStub();
- await new AlarmsApi(bff).traffic(1000, 2000);
- expect(calls[0]).toEqual(['GET', '/api/alarms/traffic?startTime=1000&endTime=2000', undefined]);
+ await new AlarmsApi(bff).count(1000, 2000);
+ expect(calls[0]).toEqual(['GET', '/api/alarms/count?startTime=1000&endTime=2000', undefined]);
});
it('services GETs with layer param', async () => {
@@ -88,12 +89,39 @@ describe('AlarmsApi.traffic + services + config', () => {
const { bff, calls } = makeStub();
const api = new AlarmsApi(bff);
await api.config();
- await api.saveConfig({ trafficLayers: [{ layerKey: 'MESH', mqe: 'service_cpm' }] });
+ await api.saveConfig({
+ pinnedLayers: ['GENERAL', 'MESH'],
+ defaultWindowMs: 1200000,
+ overviewAlarmsLimit: 200,
+ });
expect(calls[0]).toEqual(['GET', '/api/alarms/config', undefined]);
expect(calls[1][0]).toBe('POST');
expect(calls[1][1]).toBe('/api/alarms/config');
expect(calls[1][2]).toEqual({
- trafficLayers: [{ layerKey: 'MESH', mqe: 'service_cpm' }],
+ pinnedLayers: ['GENERAL', 'MESH'],
+ defaultWindowMs: 1200000,
+ overviewAlarmsLimit: 200,
});
});
+
+ it('adminRules + adminRule hit /api/admin/alarm-rules', async () => {
+ const { bff, calls } = makeStub();
+ const api = new AlarmsApi(bff);
+ await api.adminRules();
+ await api.adminRule('service_resp_time_rule');
+ expect(calls[0]).toEqual(['GET', '/api/admin/alarm-rules', undefined]);
+ expect(calls[1]).toEqual([
+ 'GET',
+ '/api/admin/alarm-rules/service_resp_time_rule',
+ undefined,
+ ]);
+ });
+
+ it('adminRule URL-encodes the rule id', async () => {
+ const { bff, calls } = makeStub();
+ await new AlarmsApi(bff).adminRule('rule with space / slash');
+ expect(calls[0][1]).toBe(
+ '/api/admin/alarm-rules/rule%20with%20space%20%2F%20slash',
+ );
+ });
});
diff --git a/apps/ui/src/api/scopes/alarms.ts b/apps/ui/src/api/scopes/alarms.ts
index 9aa7d05..13c5f1f 100644
--- a/apps/ui/src/api/scopes/alarms.ts
+++ b/apps/ui/src/api/scopes/alarms.ts
@@ -17,13 +17,15 @@
import type {
AlarmsConfig,
+ AlarmsCountResponse,
AlarmsQuery,
AlarmsResponse,
- AlarmTrafficResponse,
+ AlertingRuleDetailResponse,
+ AlertingRulesListResponse,
BffClient,
} from '../client';
-/** `bff.alarms` — alarm list + per-layer traffic backdrop + alarm-page
+/** `bff.alarms` — alarm list + topbar count probe + alarm-page
* config CRUD. */
export class AlarmsApi {
constructor(private readonly bff: BffClient) {}
@@ -33,19 +35,24 @@ export class AlarmsApi {
startTime: String(q.startTime),
endTime: String(q.endTime),
pageNum: String(q.pageNum ?? 1),
- pageSize: String(q.pageSize ?? 100),
+ pageSize: String(q.pageSize ?? 500),
});
if (q.scope) p.set('scope', q.scope);
if (q.keyword) p.set('keyword', q.keyword);
+ if (q.layer) p.set('layer', q.layer);
if (q.service) p.set('service', q.service);
if (q.instance) p.set('instance', q.instance);
if (q.endpoint) p.set('endpoint', q.endpoint);
return this.bff.request<AlarmsResponse>('GET', `/api/alarms?${p.toString()}`);
}
- traffic(startTime: number, endTime: number): Promise<AlarmTrafficResponse> {
+ /** Lightweight count probe for the topbar badge — bounded selection
+ * set (id + recoveryTime only), capped result. Polled on the
+ * badge's own interval, independent of the global time-range
+ * ticker. */
+ count(startTime: number, endTime: number): Promise<AlarmsCountResponse> {
const p = new URLSearchParams({ startTime: String(startTime), endTime: String(endTime) });
- return this.bff.request<AlarmTrafficResponse>('GET', `/api/alarms/traffic?${p.toString()}`);
+ return this.bff.request<AlarmsCountResponse>('GET', `/api/alarms/count?${p.toString()}`);
}
/** Service roster for one OAP layer (alpha-sorted) — feeds the
@@ -65,4 +72,19 @@ export class AlarmsApi {
saveConfig(next: AlarmsConfig): Promise<AlarmsConfig> {
return this.bff.request<AlarmsConfig>('POST', '/api/alarms/config', next);
}
+
+ /** OAP `/status/alarm/rules` fan-out + per-rule detail merge.
+ * Drives the Operate › Alerting Rules page. */
+ adminRules(): Promise<AlertingRulesListResponse> {
+ return this.bff.request<AlertingRulesListResponse>('GET', '/api/admin/alarm-rules');
+ }
+
+ /** Single-rule detail — used by the alarms detail panel kicker
+ * AND the operate page's right pane. */
+ adminRule(id: string): Promise<AlertingRuleDetailResponse> {
+ return this.bff.request<AlertingRuleDetailResponse>(
+ 'GET',
+ `/api/admin/alarm-rules/${encodeURIComponent(id)}`,
+ );
+ }
}
diff --git a/apps/ui/src/api/scopes/overview.ts b/apps/ui/src/api/scopes/overview.ts
index be7b300..2c741a9 100644
--- a/apps/ui/src/api/scopes/overview.ts
+++ b/apps/ui/src/api/scopes/overview.ts
@@ -16,11 +16,29 @@
*/
import type {
+ OverviewDashboard,
OverviewDashboardListResponse,
OverviewDashboardResponse,
} from '@skywalking-horizon-ui/api-client';
import type { BffClient } from '../client';
+export interface OverviewTemplateSummary {
+ id: string;
+ title: string;
+ description?: string;
+ widgetCount: number;
+ editable: boolean;
+}
+export interface OverviewTemplateListResponse {
+ generatedAt: number;
+ dashboards: OverviewTemplateSummary[];
+}
+export interface OverviewTemplateDetailResponse {
+ generatedAt: number;
+ dashboard: OverviewDashboard;
+ editable: boolean;
+}
+
/** `bff.overview` — cross-layer overview dashboards (Services / Mesh / …). */
export class OverviewApi {
constructor(private readonly bff: BffClient) {}
@@ -34,4 +52,23 @@ export class OverviewApi {
`/api/overview/dashboards/${encodeURIComponent(id)}`,
);
}
+
+ /* Admin endpoints — same dashboard JSON, write surface for editing
+ * per-widget layer / limit / title / tip / span / rowSpan etc. */
+ adminList(): Promise<OverviewTemplateListResponse> {
+ return this.bff.request<OverviewTemplateListResponse>('GET', '/api/admin/overview-templates');
+ }
+ adminGet(id: string): Promise<OverviewTemplateDetailResponse> {
+ return this.bff.request<OverviewTemplateDetailResponse>(
+ 'GET',
+ `/api/admin/overview-templates/${encodeURIComponent(id)}`,
+ );
+ }
+ adminSave(id: string, body: OverviewDashboard): Promise<{ ok: true; id: string }> {
+ return this.bff.request<{ ok: true; id: string }>(
+ 'POST',
+ `/api/admin/overview-templates/${encodeURIComponent(id)}`,
+ body,
+ );
+ }
}
diff --git a/apps/ui/src/components/charts/AlarmsTimeline.vue b/apps/ui/src/components/charts/AlarmsTimeline.vue
index ca5a9b7..31ea706 100644
--- a/apps/ui/src/components/charts/AlarmsTimeline.vue
+++ b/apps/ui/src/components/charts/AlarmsTimeline.vue
@@ -15,239 +15,116 @@
limitations under the License.
-->
<!--
- Alarms timeline. Multi-line background traffic (RPM) over the window
- with alarm markers overlaid as colored dots at each alarm's start
- time. The X axis is real epoch-ms (time type), not category-indexed
- buckets, so alarms anchor at their actual timestamp without needing
- the caller to align them to traffic buckets.
+ Alarms timeline. Single line chart of "alarms-per-minute" across the
+ window. Each non-zero bucket gets a pin flag with the count label so
+ the operator can read intensity at a glance — empty minutes get a
+ zero-baseline point but no flag (label / marker hidden).
- Interactions:
- - Click an alarm dot → emits `select-alarm` with that id.
- - Drag a region on the chart → emits `select-time-range` with
- `{startTime, endTime}` (ms).
- - Click the chart background → emits `clear-selection`.
+ X axis: real epoch-ms (time type). The line steps minute-by-minute.
+ Y axis: integer count.
- This is the page's headline widget — kept inline rather than in
- TimeChart because the alarm-flag overlay + brush behavior don't
- belong in the per-layer dashboards' generic line chart wrapper.
+ Interactions:
+ - Click a flag → emits `select-time-range` for that
+ single one-minute bucket.
+ - Drag a region (lineX brush) → emits `select-time-range` with
+ `{startTime, endTime}` ms.
+ - Click empty area / clear → emits `clear-selection`.
-->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import * as echarts from 'echarts/core';
-import { LineChart, ScatterChart } from 'echarts/charts';
+import { LineChart } from 'echarts/charts';
import {
BrushComponent,
GridComponent,
- LegendComponent,
MarkLineComponent,
TooltipComponent,
- ToolboxComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import type { EChartsType } from 'echarts/core';
-import type { AlarmMessage, AlarmTrafficSeries } from '@/api/client';
+import type { AlarmMessage } from '@/api/client';
echarts.use([
LineChart,
- ScatterChart,
BrushComponent,
GridComponent,
- LegendComponent,
MarkLineComponent,
TooltipComponent,
- ToolboxComponent,
CanvasRenderer,
]);
const props = defineProps<{
- /** Aligned, per-layer RPM series (one line per layer). */
- traffic: AlarmTrafficSeries[];
- /** Alarms to overlay as dots at their startTime. */
+ /** Alarms to bucket into per-minute counts. */
alarms: AlarmMessage[];
startTime: number;
endTime: number;
height?: number;
- /** Composite key `${alarm.id}::${alarm.startTime}` — OAP's `id`
- * alone collapses every firing of the same rule on the same
- * entity, so we key selection by (id + startTime) instead. */
- selectedAlarmKey?: string | null;
+ /** Two-way reflection of the parent's brushed range. When `null`,
+ * the chart clears its on-screen brush rectangle programmatically
+ * — lets the parent's own "clear selection" button wipe the
+ * brush, so the operator never gets stuck with a stale band. */
+ selectedRange?: { startTime: number; endTime: number } | null;
}>();
const emit = defineEmits<{
- (e: 'select-alarm', alarmKey: string): void;
(e: 'select-time-range', range: { startTime: number; endTime: number }): void;
(e: 'clear-selection'): void;
}>();
-function keyFor(a: AlarmMessage): string {
- return `${a.id}::${a.startTime}`;
-}
-
-const PALETTE = [
- '#f97316', // orange (accent)
- '#60a5fa', // blue
- '#22d3ee', // cyan
- '#a78bfa', // purple
- '#34d399', // green
- '#f472b6', // pink
-];
-
-const container = ref<HTMLDivElement | null>(null);
-let chart: EChartsType | null = null;
-
-function trafficSeriesOption(s: AlarmTrafficSeries, idx: number, addNowMark: boolean) {
- const color = PALETTE[idx % PALETTE.length];
- return {
- name: s.label,
- type: 'line',
- showSymbol: false,
- smooth: true,
- lineStyle: { width: 1.5, color, opacity: s.present ? 0.85 : 0.25 },
- areaStyle: s.present
- ? {
- color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
- { offset: 0, color: color + '33' },
- { offset: 1, color: color + '00' },
- ]),
- }
- : undefined,
- data: s.points.map((p) => [p.ts, p.value]),
- // Anchor the `now` reference line on the first series only —
- // ECharts collapses markLines from multiple series so we only
- // need it once. Drawn as a faint vertical dashed rule the
- // operator uses to gauge "is the data current or stale?".
- markLine: addNowMark
- ? {
- symbol: 'none',
- silent: true,
- lineStyle: { color: 'rgba(255,255,255,0.25)', type: 'dashed', width: 1 },
- label: {
- show: true,
- position: 'insideEndTop',
- color: 'rgba(255,255,255,0.5)',
- fontSize: 10,
- formatter: 'now',
- },
- data: [{ xAxis: Date.now() }],
- }
- : undefined,
- z: 1,
- };
-}
+const MINUTE_MS = 60_000;
-/* Bucket alarms by layerKey AND pair each layer with the same color
- * the traffic series uses for that layer. Result is one scatter
- * series per layer, color-matched to its traffic line, so the
- * legend reads layer-by-layer instead of one undifferentiated
- * "Alarms" lump. Unmatched alarms (layerKey null, or layer not in
- * the traffic config) fall into an "Other" bucket with a neutral
- * grey + a diamond marker so the operator can still see them. */
-interface AlarmBucket {
- layerKey: string;
- label: string;
- color: string;
- symbol: 'pin' | 'diamond';
- alarms: AlarmMessage[];
+/** Per-minute bucket split by event state. `firing` and `recovered`
+ * are stacked in the chart so the column height matches the total
+ * while the color breakdown surfaces the active vs cleared share. */
+interface Bucket {
+ ts: number;
+ firing: number;
+ recovered: number;
}
-function bucketAlarmsByLayer(): AlarmBucket[] {
- // Layer order from the traffic config drives the legend order.
- const buckets = new Map<string, AlarmBucket>();
- props.traffic.forEach((s, idx) => {
- buckets.set(s.layerKey, {
- layerKey: s.layerKey,
- label: s.label,
- color: PALETTE[idx % PALETTE.length],
- symbol: 'pin',
- alarms: [],
- });
- });
- const otherKey = '__other__';
+/* Floor a window to minute boundaries and walk every minute in
+ * between, so the line has a point per minute even when the bucket
+ * is empty. */
+function bucketize(): Bucket[] {
+ const startMin = Math.floor(props.startTime / MINUTE_MS) * MINUTE_MS;
+ const endMin = Math.floor(props.endTime / MINUTE_MS) * MINUTE_MS;
+ const buckets = new Map<number, Bucket>();
+ for (let ts = startMin; ts <= endMin; ts += MINUTE_MS) {
+ buckets.set(ts, { ts, firing: 0, recovered: 0 });
+ }
for (const a of props.alarms) {
- const k = a.layerKey ?? '';
- let target = k && buckets.get(k);
- if (!target) {
- target = buckets.get(otherKey);
- if (!target) {
- target = {
- layerKey: otherKey,
- label: 'Other',
- color: '#818a9c',
- symbol: 'diamond',
- alarms: [],
- };
- buckets.set(otherKey, target);
- }
+ const bucket = Math.floor(a.startTime / MINUTE_MS) * MINUTE_MS;
+ let b = buckets.get(bucket);
+ if (!b) {
+ b = { ts: bucket, firing: 0, recovered: 0 };
+ buckets.set(bucket, b);
}
- target.alarms.push(a);
+ if (a.recoveryTime === null) b.firing += 1;
+ else b.recovered += 1;
}
- // Drop empty buckets so we don't pollute the legend with layers
- // that have no alarms in the window.
- return Array.from(buckets.values()).filter((b) => b.alarms.length > 0);
+ return Array.from(buckets.values()).sort((a, b) => a.ts - b.ts);
}
-function alarmSeriesFor(bucket: AlarmBucket) {
- const seriesName = `${bucket.label} · alarms (${bucket.alarms.length})`;
- const data = bucket.alarms.map((a) => {
- const firing = a.recoveryTime === null;
- const k = keyFor(a);
- const selected = k === props.selectedAlarmKey;
- return {
- value: [a.startTime, 0],
- // `name` carries the composite (id + startTime) so the click
- // handler emits the right key — ECharts echoes back data.name
- // on click. id alone collapses every firing of one rule.
- name: k,
- itemStyle: {
- // Firing alarms keep the layer color full-saturation; recovered
- // ones drop opacity so the operator can tell active from past
- // at a glance without losing the layer mapping.
- color: bucket.color,
- opacity: firing ? 1 : 0.4,
- borderColor: selected ? '#fff' : 'transparent',
- borderWidth: selected ? 1.5 : 0,
- },
- alarm: a,
- };
- });
- return {
- name: seriesName,
- type: 'scatter',
- yAxisIndex: 1,
- symbol: bucket.symbol === 'pin' ? 'pin' : 'diamond',
- symbolSize: 14,
- z: 5,
- data,
- tooltip: {
- formatter: (params: unknown): string => {
- const p = params as { data: { alarm: AlarmMessage } };
- const a = p.data.alarm;
- const firing = a.recoveryTime === null;
- const time = new Date(a.startTime).toLocaleString();
- return [
- `<div style="font-weight:600;color:${bucket.color};">${escapeHtml(bucket.label)} · ${firing ? 'firing' : 'recovered'}</div>`,
- `<div style="margin-top:4px;font-size:11px;color:var(--sw-fg-0);">${escapeHtml(a.message)}</div>`,
- `<div style="margin-top:2px;font-size:10.5px;color:var(--sw-fg-2);">${escapeHtml(a.name)}</div>`,
- `<div style="margin-top:2px;font-size:10.5px;color:var(--sw-fg-3);">${time}</div>`,
- ].join('');
- },
- },
- };
+function formatMinute(ts: number): string {
+ const d = new Date(ts);
+ const h = String(d.getHours()).padStart(2, '0');
+ const m = String(d.getMinutes()).padStart(2, '0');
+ return `${h}:${m}`;
}
-function escapeHtml(s: string): string {
- return s
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"');
-}
+const container = ref<HTMLDivElement | null>(null);
+let chart: EChartsType | null = null;
function buildOption(): echarts.EChartsCoreOption {
- const alarmBuckets = bucketAlarmsByLayer();
+ const buckets = bucketize();
+ /* Y-axis max: echarts handles auto-scaling, but pin a floor so an
+ * all-zero window doesn't collapse to a 0-height axis. */
+ const maxCount = buckets.reduce((m, b) => Math.max(m, b.firing + b.recovered), 0);
+ const yMax = maxCount === 0 ? 1 : undefined;
+
return {
animation: false,
- grid: { left: 48, right: 12, top: 36, bottom: 22 },
+ grid: { left: 32, right: 10, top: 18, bottom: 18 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'line', lineStyle: { color: 'rgba(255,255,255,0.15)' } },
@@ -255,45 +132,39 @@ function buildOption(): echarts.EChartsCoreOption {
borderColor: 'rgba(255,255,255,0.08)',
textStyle: { color: '#e8ecf3', fontSize: 11 },
extraCssText: 'box-shadow: 0 4px 16px rgba(0,0,0,0.4);',
- },
- legend: {
- top: 4,
- left: 8,
- itemWidth: 10,
- itemHeight: 8,
- textStyle: { color: '#b6bdcc', fontSize: 11 },
- type: 'scroll',
- data: [
- // Traffic lines first, then alarm flag series per layer.
- // Names match the `name` we assign on each series — ECharts
- // pairs them by string identity.
- ...props.traffic.map((s) => s.label),
- ...alarmBuckets.map((b) => `${b.label} · alarms (${b.alarms.length})`),
- ],
+ formatter: (params: unknown): string => {
+ const arr = Array.isArray(params) ? params : [params];
+ const first = arr[0] as { dataIndex?: number; data?: [number, number] };
+ if (!first?.data) return '';
+ const [ts] = first.data;
+ const b = buckets[first.dataIndex ?? 0];
+ const firing = b?.firing ?? 0;
+ const recovered = b?.recovered ?? 0;
+ const total = firing + recovered;
+ return [
+ `<div style="font-weight:600;color:#f97316;">${formatMinute(ts)}</div>`,
+ `<div style="margin-top:4px;font-size:11px;color:var(--sw-fg-0);">${total} event${total === 1 ? '' : 's'}</div>`,
+ total > 0
+ ? `<div style="margin-top:2px;font-size:10.5px;">
+ <span style="color:#ef4444;">${firing} firing</span>
+ · <span style="color:#22c55e;">${recovered} recovered</span>
+ </div>`
+ : '',
+ total > 0
+ ? `<div style="margin-top:2px;font-size:10.5px;color:var(--sw-fg-3);">click to filter to this minute</div>`
+ : '',
+ ].join('');
+ },
},
brush: {
- toolbox: ['lineX', 'clear'],
xAxisIndex: 0,
+ brushType: 'lineX',
+ brushMode: 'single',
brushStyle: { color: 'rgba(249,115,22,0.18)', borderColor: 'rgba(249,115,22,0.6)' },
+ transformable: false,
throttleType: 'debounce',
throttleDelay: 200,
},
- toolbox: {
- show: true,
- top: 2,
- right: 8,
- itemSize: 12,
- feature: {
- brush: {
- type: ['lineX', 'clear'],
- title: { lineX: 'select time range', clear: 'clear' } as Record<string, string>,
- icon: {
- // ECharts default icons render fine; just style the text colour.
- } as Record<string, string>,
- },
- },
- iconStyle: { borderColor: '#818a9c' },
- },
xAxis: {
type: 'time',
min: props.startTime,
@@ -302,57 +173,145 @@ function buildOption(): echarts.EChartsCoreOption {
axisLabel: { color: '#64748b', fontSize: 10 },
splitLine: { show: false },
},
- yAxis: [
+ yAxis: {
+ type: 'value',
+ min: 0,
+ max: yMax,
+ minInterval: 1,
+ axisLine: { show: false },
+ axisTick: { show: false },
+ axisLabel: {
+ color: '#64748b',
+ fontSize: 10,
+ formatter: (v: number): string => String(Math.round(v)),
+ },
+ splitLine: { lineStyle: { color: 'rgba(255,255,255,0.04)' } },
+ },
+ series: [
+ /* Firing events first (bottom of the stack — red), then
+ * recovered events on top (green). Stack key shared so the
+ * column heights add up. Pin label shows count when non-zero,
+ * with each stack contributing its own pin so the operator
+ * sees the firing/recovered split at a glance. */
{
- // Traffic axis (left).
- type: 'value',
- name: 'RPM',
- nameTextStyle: { color: '#64748b', fontSize: 10, padding: [0, 0, 0, -4] },
- axisLine: { show: false },
- axisLabel: { color: '#64748b', fontSize: 10 },
- splitLine: { lineStyle: { color: 'rgba(255,255,255,0.04)' } },
+ name: 'firing',
+ type: 'line',
+ stack: 'events',
+ smooth: false,
+ step: 'middle',
+ showSymbol: true,
+ symbol: (val: number[]): string => (val[1] > 0 ? 'pin' : 'none'),
+ symbolSize: 16,
+ lineStyle: { width: 1, color: '#ef4444' },
+ itemStyle: { color: '#ef4444', borderColor: '#0f141d', borderWidth: 1 },
+ areaStyle: {
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+ { offset: 0, color: 'rgba(239,68,68,0.34)' },
+ { offset: 1, color: 'rgba(239,68,68,0)' },
+ ]),
+ },
+ label: {
+ show: true,
+ position: 'inside',
+ color: '#fff',
+ fontSize: 10,
+ fontWeight: 700,
+ formatter: (p: { data?: [number, number] }): string => {
+ const v = p.data?.[1] ?? 0;
+ return v > 0 ? String(v) : '';
+ },
+ },
+ emphasis: { focus: 'series' },
+ data: buckets.map((b) => [b.ts, b.firing]),
+ z: 3,
},
{
- // Alarms axis — hidden, just a fixed band so the scatter dots
- // sit on a horizontal line at the bottom of the plot.
- type: 'value',
- show: false,
- min: -1,
- max: 1,
+ name: 'recovered',
+ type: 'line',
+ stack: 'events',
+ smooth: false,
+ step: 'middle',
+ showSymbol: true,
+ symbol: (val: number[]): string => (val[1] > 0 ? 'pin' : 'none'),
+ symbolSize: 16,
+ lineStyle: { width: 1, color: '#22c55e' },
+ itemStyle: { color: '#22c55e', borderColor: '#0f141d', borderWidth: 1 },
+ areaStyle: {
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+ { offset: 0, color: 'rgba(34,197,94,0.32)' },
+ { offset: 1, color: 'rgba(34,197,94,0)' },
+ ]),
+ },
+ label: {
+ show: true,
+ position: 'inside',
+ color: '#fff',
+ fontSize: 10,
+ fontWeight: 700,
+ formatter: (p: { data?: [number, number] }): string => {
+ const v = p.data?.[1] ?? 0;
+ return v > 0 ? String(v) : '';
+ },
+ },
+ emphasis: { focus: 'series' },
+ data: buckets.map((b) => [b.ts, b.recovered]),
+ z: 2,
},
],
- series: [
- ...props.traffic.map((s, i) => trafficSeriesOption(s, i, i === 0)),
- ...alarmBuckets.map((b) => alarmSeriesFor(b)),
- ],
};
}
+function activateBrush(): void {
+ /* Permanent lineX brush cursor. Without this the operator's first
+ * drag silently fails (ECharts ships brush mode opt-in via a
+ * toolbox icon we removed). Click events on data symbols still
+ * fire — ECharts dispatches `click` before the brush sees a
+ * mousedown-as-drag-start. */
+ chart?.dispatchAction({
+ type: 'takeGlobalCursor',
+ key: 'brush',
+ brushOption: { brushType: 'lineX', brushMode: 'single' },
+ });
+}
+
+/** Wipe the brush rectangle from the chart. Called when the parent's
+ * selectedRange goes null (e.g. the operator hit "clear selection"
+ * in the list header). */
+function clearBrush(): void {
+ chart?.dispatchAction({ type: 'brush', areas: [] });
+ /* `brush` with empty areas can drop the global cursor — re-arm so
+ * the next drag still draws. */
+ activateBrush();
+}
+
function attach(): void {
if (!container.value) return;
chart = echarts.init(container.value, undefined, { renderer: 'canvas' });
chart.setOption(buildOption());
+ activateBrush();
+ /* Click on either series' non-zero point → narrow the list to that
+ * one minute. Click on a zero point or off-data → clear selection
+ * (and the brush rectangle via the parent's prop sync). */
chart.on('click', (params: unknown) => {
- // All alarm scatter series have `seriesType: 'scatter'` plus an
- // `alarm` field on the datum. Discriminate by series type rather
- // than name so the new per-layer series names (e.g. "Mesh ·
- // alarms (3)") all match without a regex.
- const p = params as {
- seriesType?: string;
- data?: { name?: string; alarm?: AlarmMessage };
- };
- if (p.seriesType === 'scatter' && p.data?.alarm && p.data.name) {
- emit('select-alarm', p.data.name);
+ const p = params as { seriesType?: string; data?: [number, number] };
+ if (p.seriesType === 'line' && p.data) {
+ const [ts, count] = p.data;
+ if (count > 0) {
+ emit('select-time-range', { startTime: ts, endTime: ts + MINUTE_MS - 1 });
+ return;
+ }
}
+ emit('clear-selection');
});
- /* Brush emits two events: `brushSelected` while the user drags AND
- * a final one after release. We listen to `brushEnd` so we only
- * commit a range when the gesture finishes. */
+ /* `brushEnd` is the drag-finished event — fires once per gesture
+ * after mouseup, so we emit exactly one `select-time-range` per
+ * drag. The "drag cleared" case (release on the same pixel) shows
+ * up as a zero-width area; treat it as a clear. */
chart.on('brushEnd', (params: unknown) => {
const p = params as {
- areas: Array<{ brushType: string; coordRange: [number, number] }>;
+ areas?: Array<{ brushType: string; coordRange: [number, number] }>;
};
const area = p.areas?.[0];
if (!area || area.brushType !== 'lineX') {
@@ -360,15 +319,24 @@ function attach(): void {
return;
}
const [a, b] = area.coordRange;
- const startTime = Math.min(a, b);
- const endTime = Math.max(a, b);
- emit('select-time-range', { startTime, endTime });
+ if (a === b) {
+ emit('clear-selection');
+ return;
+ }
+ /* Snap the brushed range to whole minutes so the BFF query keys
+ * + the per-minute bucket lookup line up cleanly. */
+ const start = Math.floor(Math.min(a, b) / MINUTE_MS) * MINUTE_MS;
+ const end = Math.ceil(Math.max(a, b) / MINUTE_MS) * MINUTE_MS - 1;
+ emit('select-time-range', { startTime: start, endTime: end });
});
}
function refresh(): void {
if (!chart) return;
- chart.setOption(buildOption(), { replaceMerge: ['series', 'legend'] });
+ chart.setOption(buildOption(), { replaceMerge: ['series', 'yAxis'] });
+ /* `setOption` can reset the global cursor — re-arm brush so a
+ * subsequent drag still works after a data refresh. */
+ activateBrush();
}
onMounted(() => {
@@ -385,14 +353,28 @@ onBeforeUnmount(() => {
});
watch(
- () => [props.traffic, props.alarms, props.startTime, props.endTime, props.selectedAlarmKey],
+ () => [props.alarms, props.startTime, props.endTime],
() => refresh(),
{ deep: true },
);
+
+/* Parent-driven brush clear: when the operator hits the "clear
+ * selection" button in the list header, the parent sets its
+ * `selectedRange` ref to null. Mirror that into the chart by
+ * wiping the brush rectangle. We only act on the null transition —
+ * a non-null selection on this side is either echoed from a drag we
+ * just did (no-op needed) or from a click on a flag (no rectangle
+ * needed; the row list narrowing is the visual feedback). */
+watch(
+ () => props.selectedRange,
+ (next) => {
+ if (next === null || next === undefined) clearBrush();
+ },
+);
</script>
<template>
- <div ref="container" class="alarms-timeline" :style="{ height: `${height ?? 200}px` }" />
+ <div ref="container" class="alarms-timeline" :style="{ height: `${height ?? 110}px` }" />
</template>
<style scoped>
diff --git a/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue b/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
index d09f188..a21ee33 100644
--- a/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
+++ b/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
@@ -15,36 +15,42 @@
limitations under the License.
-->
<!--
- Admin view for the Alarms page setup. Operators pick which OAP
- layers' RPM traffic to draw as the background series on /alarms and
- set the MQE expression to fire per layer.
-
- Defaults seed agent (`GENERAL`) + mesh (`MESH`) with `service_cpm`.
- Layers absent on the OAP install render as `present: false` on the
- alarms page (dimmed line, no values) — listing them here is
- harmless.
+ Admin view for the Alarms page setup. The operator pins a small set
+ of OAP layers — those layers get a dedicated KPI tile at the top of
+ the Alarms page (TOTAL · pinned-1 · pinned-2 · …); every other
+ layer with at least one firing alarm appears in the overflow chip
+ row underneath. Order here is render order on the page.
+
+ Defaults seed `GENERAL` (agent) + `MESH` (mesh) — the two most
+ common drivers of alarm volume on every install.
-->
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useQuery } from '@tanstack/vue-query';
+import { useLayers } from '@/shell/useLayers';
import {
+ ALARMS_WINDOW_OPTIONS,
+ OVERVIEW_ALARMS_LIMIT_DEFAULT,
+ OVERVIEW_ALARMS_LIMIT_MAX,
+ OVERVIEW_ALARMS_LIMIT_MIN,
bff,
- type AlarmTrafficLayerConfig,
type AlarmsConfig,
} from '@/api/client';
-const KNOWN_LAYERS: ReadonlyArray<{ key: string; label: string; defaultMqe: string }> = [
- { key: 'GENERAL', label: 'Agent (GENERAL)', defaultMqe: 'service_cpm' },
- { key: 'MESH', label: 'Mesh (MESH)', defaultMqe: 'service_cpm' },
- { key: 'MESH_DP', label: 'Mesh data plane', defaultMqe: 'service_cpm' },
- { key: 'MESH_CP', label: 'Mesh control plane', defaultMqe: 'service_cpm' },
- { key: 'K8S_SERVICE', label: 'Kubernetes service', defaultMqe: 'service_cpm' },
- { key: 'BROWSER', label: 'Browser', defaultMqe: 'browser_app_pv' },
- { key: 'VIRTUAL_DATABASE', label: 'Virtual database', defaultMqe: 'database_access_cpm' },
- { key: 'VIRTUAL_CACHE', label: 'Virtual cache', defaultMqe: 'cache_access_cpm' },
- { key: 'VIRTUAL_MQ', label: 'Virtual MQ', defaultMqe: 'mq_service_consume_cpm' },
- { key: 'VIRTUAL_GENAI', label: 'Virtual GenAI', defaultMqe: 'gen_ai_provider_cpm' },
-];
+/* Cap matches the BFF's `configSaveSchema.max(8)` — keeps the header
+ * row from wrapping into a second line at typical widths. */
+const MAX_PINNED = 8;
+
+const WINDOW_LABELS: Record<number, string> = {
+ [20 * 60_000]: '20 minutes',
+ [2 * 60 * 60_000]: '2 hours',
+ [4 * 60 * 60_000]: '4 hours',
+};
+
+const layersList = useLayers();
+const knownLayerKeys = computed<string[]>(() =>
+ (layersList.availableLayers.value ?? []).map((l) => l.key.toUpperCase()),
+);
const q = useQuery({
queryKey: ['alarms/config'],
@@ -52,16 +58,37 @@ const q = useQuery({
staleTime: Infinity,
});
-const draft = ref<AlarmTrafficLayerConfig[]>([]);
+const draft = ref<string[]>([]);
+const draftWindowMs = ref<number>(ALARMS_WINDOW_OPTIONS[0]);
+const draftLimit = ref<number>(OVERVIEW_ALARMS_LIMIT_DEFAULT);
+const limitError = ref<string | null>(null);
watch(
() => q.data.value,
(cfg) => {
- if (cfg) draft.value = cfg.trafficLayers.map((l) => ({ ...l }));
+ if (cfg) {
+ draft.value = [...cfg.pinnedLayers];
+ draftWindowMs.value = cfg.defaultWindowMs;
+ draftLimit.value = cfg.overviewAlarmsLimit;
+ }
},
{ immediate: true },
);
+function validateLimit(): boolean {
+ const v = Number(draftLimit.value);
+ if (!Number.isInteger(v)) {
+ limitError.value = 'must be an integer';
+ return false;
+ }
+ if (v < OVERVIEW_ALARMS_LIMIT_MIN || v > OVERVIEW_ALARMS_LIMIT_MAX) {
+ limitError.value = `must be between ${OVERVIEW_ALARMS_LIMIT_MIN} and ${OVERVIEW_ALARMS_LIMIT_MAX}`;
+ return false;
+ }
+ limitError.value = null;
+ return true;
+}
+
const flash = ref<string | null>(null);
const saving = ref(false);
@@ -72,46 +99,49 @@ function setFlash(msg: string): void {
}, 4000);
}
-const usedLayerKeys = computed<Set<string>>(() => new Set(draft.value.map((l) => l.layerKey)));
-const addableLayers = computed(() =>
- KNOWN_LAYERS.filter((L) => !usedLayerKeys.value.has(L.key)),
-);
+/* Layers the operator can still add — known to OAP AND not already
+ * pinned. Pinned-but-unknown layers (e.g. an older install removed a
+ * layer that's still in the saved config) stay rendered as pinned
+ * chips so the operator can remove them; they just don't appear in
+ * the "add" palette. */
+const addableLayers = computed<string[]>(() => {
+ const used = new Set(draft.value);
+ return knownLayerKeys.value.filter((k) => !used.has(k)).sort();
+});
-function addLayer(layerKey: string): void {
- const def = KNOWN_LAYERS.find((L) => L.key === layerKey);
- draft.value.push({ layerKey, mqe: def?.defaultMqe ?? 'service_cpm', label: def?.label });
+function addLayer(key: string): void {
+ if (draft.value.includes(key)) return;
+ if (draft.value.length >= MAX_PINNED) return;
+ draft.value = [...draft.value, key];
}
-function addCustom(): void {
- draft.value.push({ layerKey: '', mqe: 'service_cpm' });
+function removeLayer(i: number): void {
+ draft.value = draft.value.filter((_, j) => j !== i);
}
-function removeRow(i: number): void {
- draft.value.splice(i, 1);
+function moveLayer(i: number, dir: -1 | 1): void {
+ const next = [...draft.value];
+ const j = i + dir;
+ if (j < 0 || j >= next.length) return;
+ [next[i], next[j]] = [next[j], next[i]];
+ draft.value = next;
}
async function onSave(): Promise<void> {
- // Drop empty rows + duplicates (keep the first occurrence) so the
- // BFF never sees a half-typed entry.
- const seen = new Set<string>();
- const clean: AlarmTrafficLayerConfig[] = [];
- for (const row of draft.value) {
- const key = row.layerKey.trim();
- if (!key) continue;
- if (!row.mqe.trim()) continue;
- if (seen.has(key)) continue;
- seen.add(key);
- clean.push({
- layerKey: key.toUpperCase(),
- mqe: row.mqe.trim(),
- label: row.label?.trim() || undefined,
- });
- }
+ if (!validateLimit()) return;
saving.value = true;
try {
- const saved = await bff.alarms.saveConfig({ trafficLayers: clean });
- draft.value = saved.trafficLayers.map((l) => ({ ...l }));
- setFlash(`saved · ${saved.trafficLayers.length} layer${saved.trafficLayers.length === 1 ? '' : 's'}`);
+ const saved = await bff.alarms.saveConfig({
+ pinnedLayers: draft.value,
+ defaultWindowMs: draftWindowMs.value,
+ overviewAlarmsLimit: Number(draftLimit.value),
+ });
+ draft.value = [...saved.pinnedLayers];
+ draftWindowMs.value = saved.defaultWindowMs;
+ draftLimit.value = saved.overviewAlarmsLimit;
+ setFlash(
+ `saved · ${saved.pinnedLayers.length} pinned · ${WINDOW_LABELS[saved.defaultWindowMs] ?? '—'} · limit ${saved.overviewAlarmsLimit}`,
+ );
} catch (err) {
setFlash(err instanceof Error ? `error: ${err.message}` : 'save failed');
} finally {
@@ -120,19 +150,34 @@ async function onSave(): Promise<void> {
}
function onReset(): void {
- if (q.data.value) draft.value = q.data.value.trafficLayers.map((l) => ({ ...l }));
+ if (q.data.value) {
+ draft.value = [...q.data.value.pinnedLayers];
+ draftWindowMs.value = q.data.value.defaultWindowMs;
+ draftLimit.value = q.data.value.overviewAlarmsLimit;
+ limitError.value = null;
+ }
}
const isDirty = computed<boolean>(() => {
- const saved = q.data.value?.trafficLayers ?? [];
+ const saved = q.data.value?.pinnedLayers ?? [];
if (saved.length !== draft.value.length) return true;
for (let i = 0; i < saved.length; i++) {
- if (saved[i].layerKey !== draft.value[i].layerKey) return true;
- if (saved[i].mqe !== draft.value[i].mqe) return true;
- if ((saved[i].label ?? '') !== (draft.value[i].label ?? '')) return true;
+ if (saved[i] !== draft.value[i]) return true;
+ }
+ if (q.data.value) {
+ if (draftWindowMs.value !== q.data.value.defaultWindowMs) return true;
+ if (Number(draftLimit.value) !== q.data.value.overviewAlarmsLimit) return true;
}
return false;
});
+
+function prettyLayer(k: string): string {
+ return k
+ .toLowerCase()
+ .split('_')
+ .map((w) => (w.length > 0 ? w[0]!.toUpperCase() + w.slice(1) : ''))
+ .join(' ');
+}
</script>
<template>
@@ -142,78 +187,135 @@ const isDirty = computed<boolean>(() => {
<div class="aps__kicker">Dashboard setup · Alert page</div>
<h1>Alert page setup</h1>
<p class="aps__lede">
- Pick which OAP layers' RPM traffic gets rendered as the background series on
- <RouterLink to="/alarms">Alarms</RouterLink>, and set the MQE expression to fire per
- layer. Layers that aren't active on the OAP install are skipped silently — listing them
- here is harmless. Up to 8 layers.
+ Pin the OAP layers that get their own KPI tile at the top of
+ <RouterLink to="/alarms">Alarms</RouterLink>. Defaults are <code>GENERAL</code> + <code>MESH</code>.
+ Every other layer with at least one firing alarm appears
+ in the overflow chip row underneath. Reorder with the
+ arrows — left-to-right matches the page header. Up to {{ MAX_PINNED }} layers.
</p>
</div>
</header>
<div v-if="q.isPending.value" class="aps__empty">loading…</div>
- <table v-else class="aps__table">
- <thead>
- <tr>
- <th>Layer key</th>
- <th>Display label</th>
- <th>MQE expression</th>
- <th style="width: 60px"></th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="(row, i) in draft" :key="i">
- <td>
- <input
- v-model="row.layerKey"
- type="text"
- placeholder="GENERAL"
- class="aps__in"
- />
- </td>
- <td>
- <input
- v-model="row.label"
- type="text"
- placeholder="(auto)"
- class="aps__in"
- />
- </td>
- <td>
- <input
- v-model="row.mqe"
- type="text"
- placeholder="service_cpm"
- class="aps__in aps__in--mono"
- />
- </td>
- <td>
- <button type="button" class="aps__del" @click="removeRow(i)">remove</button>
- </td>
- </tr>
- <tr v-if="draft.length === 0">
- <td colspan="4" class="aps__empty-row">No layers configured. Add one below.</td>
- </tr>
- </tbody>
- </table>
-
- <div class="aps__add">
- <span class="aps__add-label">Add layer:</span>
- <button
- v-for="L in addableLayers"
- :key="L.key"
- type="button"
- class="aps__add-chip"
- :disabled="draft.length >= 8"
- @click="addLayer(L.key)"
- >+ {{ L.label }}</button>
- <button
- type="button"
- class="aps__add-chip aps__add-chip--custom"
- :disabled="draft.length >= 8"
- @click="addCustom"
- >+ custom…</button>
- </div>
+ <template v-else>
+ <section class="aps__panel">
+ <header class="aps__panel-head">
+ <h3>Pinned ({{ draft.length }} / {{ MAX_PINNED }})</h3>
+ </header>
+ <div v-if="draft.length === 0" class="aps__empty-row">
+ No pinned layers. Add one from the palette below.
+ </div>
+ <ol v-else class="aps__pinned">
+ <li v-for="(key, i) in draft" :key="key" class="aps__pin">
+ <button
+ type="button"
+ class="aps__pin-arrow"
+ :disabled="i === 0"
+ :title="'Move left'"
+ @click="moveLayer(i, -1)"
+ >‹</button>
+ <span class="aps__pin-pos mono">{{ i + 1 }}</span>
+ <span class="aps__pin-label">{{ prettyLayer(key) }}</span>
+ <code class="aps__pin-key">{{ key }}</code>
+ <button
+ type="button"
+ class="aps__pin-arrow"
+ :disabled="i === draft.length - 1"
+ :title="'Move right'"
+ @click="moveLayer(i, 1)"
+ >›</button>
+ <button
+ type="button"
+ class="aps__pin-del"
+ :title="'Unpin'"
+ @click="removeLayer(i)"
+ >×</button>
+ </li>
+ </ol>
+ </section>
+
+ <section class="aps__panel">
+ <header class="aps__panel-head">
+ <h3>Default time window</h3>
+ </header>
+ <div class="aps__win">
+ <p class="aps__win-lede">
+ Initial time range for the topbar alarm badge AND the alarms page's first load.
+ The overview "Active alarms" widget keeps its own fixed 60-minute window.
+ </p>
+ <div class="aps__win-options">
+ <label
+ v-for="opt in ALARMS_WINDOW_OPTIONS"
+ :key="opt"
+ class="aps__win-opt"
+ :class="{ active: draftWindowMs === opt }"
+ >
+ <input
+ type="radio"
+ name="defaultWindow"
+ :value="opt"
+ v-model="draftWindowMs"
+ />
+ <span>{{ WINDOW_LABELS[opt] }}</span>
+ </label>
+ </div>
+ </div>
+ </section>
+
+ <section class="aps__panel">
+ <header class="aps__panel-head">
+ <h3>Overview alarms widget</h3>
+ </header>
+ <div class="aps__win">
+ <p class="aps__win-lede">
+ Per-poll fetch cap for the "Active alarms" widget on overview dashboards.
+ The widget merges the fetched events into incidents client-side and surfaces
+ the top N by recency. Higher caps catch more variety in noisy installs;
+ smaller caps cut the per-poll payload. Range
+ <code>{{ OVERVIEW_ALARMS_LIMIT_MIN }}</code>–<code>{{ OVERVIEW_ALARMS_LIMIT_MAX }}</code>,
+ default <code>{{ OVERVIEW_ALARMS_LIMIT_DEFAULT }}</code>.
+ </p>
+ <div class="aps__limit">
+ <label>
+ <span>Fetch cap</span>
+ <input
+ v-model.number="draftLimit"
+ type="number"
+ :min="OVERVIEW_ALARMS_LIMIT_MIN"
+ :max="OVERVIEW_ALARMS_LIMIT_MAX"
+ step="10"
+ class="aps__in aps__in--num"
+ @blur="validateLimit"
+ />
+ </label>
+ <span v-if="limitError" class="aps__limit-err">{{ limitError }}</span>
+ </div>
+ </div>
+ </section>
+
+ <section class="aps__panel">
+ <header class="aps__panel-head">
+ <h3>Add layer</h3>
+ </header>
+ <div v-if="addableLayers.length === 0" class="aps__empty-row">
+ Every OAP-known layer is already pinned.
+ </div>
+ <div v-else class="aps__add">
+ <button
+ v-for="key in addableLayers"
+ :key="key"
+ type="button"
+ class="aps__add-chip"
+ :disabled="draft.length >= MAX_PINNED"
+ @click="addLayer(key)"
+ >
+ <span>+ {{ prettyLayer(key) }}</span>
+ <code>{{ key }}</code>
+ </button>
+ </div>
+ </section>
+ </template>
<div class="aps__actions">
<span v-if="flash" class="aps__flash">{{ flash }}</span>
@@ -272,89 +374,182 @@ const isDirty = computed<boolean>(() => {
.aps__lede a:hover {
text-decoration: underline;
}
+.aps__lede code {
+ font-family: var(--sw-mono);
+ font-size: 11.5px;
+ color: var(--sw-fg-0);
+ background: var(--sw-bg-2);
+ padding: 1px 5px;
+ border-radius: 3px;
+}
.aps__empty {
padding: 24px;
text-align: center;
color: var(--sw-fg-3);
font-size: 12px;
}
-.aps__table {
- width: 100%;
- border-collapse: collapse;
+
+.aps__panel {
background: var(--sw-bg-1);
border: 1px solid var(--sw-line);
border-radius: 8px;
- overflow: hidden;
margin-bottom: 14px;
+ overflow: hidden;
}
-.aps__table thead th {
- text-align: left;
- font-size: 10.5px;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--sw-fg-3);
- padding: 8px 12px;
- border-bottom: 1px solid var(--sw-line);
+.aps__panel-head {
+ padding: 8px 14px;
background: var(--sw-bg-2);
- font-weight: 600;
-}
-.aps__table tbody td {
- padding: 8px 12px;
border-bottom: 1px solid var(--sw-line);
}
-.aps__table tbody tr:last-child td {
- border-bottom: none;
+.aps__panel-head h3 {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--sw-fg-1);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ margin: 0;
}
.aps__empty-row {
+ padding: 20px 14px;
text-align: center;
color: var(--sw-fg-3);
font-size: 12px;
- padding: 24px !important;
}
-.aps__in {
- width: 100%;
+
+.aps__pinned {
+ list-style: none;
+ margin: 0;
+ padding: 10px 12px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.aps__pin {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 4px 4px 8px;
background: var(--sw-bg-2);
- border: 1px solid var(--sw-line);
- color: var(--sw-fg-0);
- font: inherit;
+ border: 1px solid var(--sw-line-2);
+ border-radius: 6px;
+}
+.aps__pin-pos {
+ font-size: 10px;
+ color: var(--sw-accent);
+ font-weight: 600;
+}
+.aps__pin-label {
font-size: 12px;
- padding: 5px 8px;
- border-radius: 4px;
+ font-weight: 500;
+ color: var(--sw-fg-0);
}
-.aps__in--mono {
+.aps__pin-key {
font-family: var(--sw-mono);
+ font-size: 10.5px;
+ color: var(--sw-fg-2);
+ background: var(--sw-bg-1);
+ padding: 1px 5px;
+ border-radius: 3px;
}
-.aps__del {
+.aps__pin-arrow,
+.aps__pin-del {
background: transparent;
- border: 1px solid var(--sw-line-2);
+ border: 0;
color: var(--sw-fg-2);
font: inherit;
- font-size: 11px;
- padding: 4px 8px;
- border-radius: 4px;
+ font-size: 14px;
+ line-height: 1;
+ width: 20px;
+ height: 20px;
+ border-radius: 3px;
cursor: pointer;
}
-.aps__del:hover {
- border-color: rgba(239, 68, 68, 0.4);
+.aps__pin-arrow:not(:disabled):hover {
+ background: var(--sw-bg-3);
+ color: var(--sw-fg-0);
+}
+.aps__pin-arrow:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
+.aps__pin-del:hover {
+ background: var(--sw-err-soft);
color: var(--sw-err);
}
-.aps__add {
+
+.aps__win { padding: 10px 14px 12px; }
+.aps__win-lede {
+ margin: 0 0 8px;
+ font-size: 11.5px;
+ color: var(--sw-fg-2);
+ line-height: 1.5;
+}
+.aps__win-options {
display: flex;
+ gap: 6px;
flex-wrap: wrap;
+}
+.aps__win-opt {
+ display: inline-flex;
align-items: center;
gap: 6px;
- padding: 10px 0;
- margin-bottom: 16px;
+ padding: 5px 12px;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line);
+ border-radius: 5px;
+ font-size: 12px;
+ color: var(--sw-fg-1);
+ cursor: pointer;
}
-.aps__add-label {
- font-size: 10.5px;
+.aps__win-opt input { margin: 0; cursor: pointer; }
+.aps__win-opt:hover { border-color: var(--sw-line-2); color: var(--sw-fg-0); }
+.aps__win-opt.active {
+ border-color: var(--sw-accent);
+ color: var(--sw-fg-0);
+ background: var(--sw-bg-3);
+}
+.aps__limit {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.aps__limit label {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--sw-fg-3);
- margin-right: 4px;
+ font-weight: 600;
+}
+.aps__in--num {
+ width: 100px;
+ font-variant-numeric: tabular-nums;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line);
+ color: var(--sw-fg-0);
+ font: inherit;
+ font-size: 12px;
+ padding: 5px 8px;
+ border-radius: 4px;
+}
+.aps__limit-err {
+ color: var(--sw-err);
+ font-size: 11px;
+}
+
+.aps__add {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 10px 12px;
}
.aps__add-chip {
- background: var(--sw-bg-1);
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: var(--sw-bg-2);
border: 1px solid var(--sw-line);
color: var(--sw-fg-1);
font: inherit;
@@ -364,7 +559,7 @@ const isDirty = computed<boolean>(() => {
cursor: pointer;
}
.aps__add-chip:not(:disabled):hover {
- background: var(--sw-bg-2);
+ background: var(--sw-bg-3);
color: var(--sw-fg-0);
border-color: var(--sw-line-2);
}
@@ -372,9 +567,12 @@ const isDirty = computed<boolean>(() => {
opacity: 0.4;
cursor: not-allowed;
}
-.aps__add-chip--custom {
+.aps__add-chip code {
font-family: var(--sw-mono);
+ font-size: 10px;
+ color: var(--sw-fg-3);
}
+
.aps__actions {
display: flex;
align-items: center;
diff --git a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
new file mode 100644
index 0000000..3a742bf
--- /dev/null
+++ b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
@@ -0,0 +1,348 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<!--
+ Admin: overview-dashboard templates. Lists every bundled dashboard,
+ lets the operator edit per-widget fields that don't need code
+ changes — layer / limit / title / tip / span / rowSpan. Type,
+ MQE, KPIs, and the widget set stay frozen at the bundled value;
+ changing those is still a code edit (the bundled JSON is the
+ source of truth, this admin is an in-place tweak layer).
+-->
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import type { OverviewDashboard, OverviewWidget } from '@skywalking-horizon-ui/api-client';
+import { bff } from '@/api/client';
+import { useLayers } from '@/shell/useLayers';
+
+const listQuery = useQuery({
+ queryKey: ['admin/overview-templates'],
+ queryFn: () => bff.overview.adminList(),
+ staleTime: 60_000,
+});
+const dashboards = computed(() => listQuery.data.value?.dashboards ?? []);
+
+const selectedId = ref<string>('');
+watch(
+ () => dashboards.value,
+ (list) => {
+ if (!selectedId.value && list.length > 0) selectedId.value = list[0]!.id;
+ },
+);
+
+const detailQuery = useQuery({
+ queryKey: computed(() => ['admin/overview-templates', selectedId.value]),
+ queryFn: () => bff.overview.adminGet(selectedId.value),
+ enabled: computed(() => selectedId.value.length > 0),
+ staleTime: 60_000,
+});
+
+const { availableLayers } = useLayers();
+/** Layer dropdown options. Starts from the layers the connected OAP
+ * is actively reporting on (so the operator sees real data sources
+ * first) but ALSO includes any layer key already referenced by a
+ * widget in the current draft — even if that layer has no live
+ * data. Without this fallback, configuring a widget for a layer
+ * that's "configured but quiet" (e.g. VIRTUAL_GENAI on a deployment
+ * with no AI traffic) would silently blank the dropdown and lose
+ * the operator's prior choice. */
+const layerOptions = computed<string[]>(() => {
+ const live = new Set((availableLayers.value ?? []).map((l) => l.key.toUpperCase()));
+ for (const w of draft.value?.widgets ?? []) {
+ if (w.layer) live.add(w.layer.toUpperCase());
+ }
+ return Array.from(live).sort();
+});
+
+/* Local working copy — deep-cloned so edits don't mutate the
+ * react-query cache. Reset on dashboard switch + on save. */
+const draft = ref<OverviewDashboard | null>(null);
+watch(
+ () => detailQuery.data.value,
+ (resp) => {
+ draft.value = resp ? (JSON.parse(JSON.stringify(resp.dashboard)) as OverviewDashboard) : null;
+ },
+);
+
+const flash = ref<string | null>(null);
+const saving = ref(false);
+function setFlash(msg: string): void {
+ flash.value = msg;
+ setTimeout(() => {
+ if (flash.value === msg) flash.value = null;
+ }, 4000);
+}
+
+const isDirty = computed<boolean>(() => {
+ const cur = draft.value;
+ const orig = detailQuery.data.value?.dashboard;
+ if (!cur || !orig) return false;
+ return JSON.stringify(cur) !== JSON.stringify(orig);
+});
+
+async function onSave(): Promise<void> {
+ if (!draft.value || !selectedId.value) return;
+ saving.value = true;
+ try {
+ await bff.overview.adminSave(selectedId.value, draft.value);
+ await detailQuery.refetch();
+ setFlash('saved · reload the overview to see widget changes');
+ } catch (err) {
+ setFlash(err instanceof Error ? `error: ${err.message}` : 'save failed');
+ } finally {
+ saving.value = false;
+ }
+}
+
+function onReset(): void {
+ const orig = detailQuery.data.value?.dashboard;
+ if (orig) draft.value = JSON.parse(JSON.stringify(orig)) as OverviewDashboard;
+}
+
+/** Which widget fields are editable per-type. Type / MQE / KPIs stay
+ * frozen — they're code-shape decisions, not config tweaks. */
+function showsLayer(w: OverviewWidget): boolean {
+ return w.type !== 'section-break' && w.type !== 'alarms' ? true : w.type === 'alarms';
+}
+function showsLimit(w: OverviewWidget): boolean {
+ return w.type === 'alarms';
+}
+function showsLayout(w: OverviewWidget): boolean {
+ return w.type !== 'section-break';
+}
+</script>
+
+<template>
+ <div class="ot">
+ <header class="ot__head">
+ <div>
+ <div class="ot__kicker">Dashboard setup · Overviews</div>
+ <h1>Overview templates</h1>
+ <p class="ot__lede">
+ Per-widget tweaks for the bundled overview dashboards. Edits write back to
+ <code>bundled_templates/overviews/*.json</code> and invalidate the BFF cache
+ so the next dashboard fetch picks them up. Widget type / MQE / KPI definitions
+ stay code-managed — only layout + scope fields are editable here.
+ </p>
+ </div>
+ </header>
+
+ <div v-if="listQuery.isPending.value" class="ot__empty">loading…</div>
+
+ <div v-else class="ot__split">
+ <ul class="ot__list">
+ <li
+ v-for="d in dashboards"
+ :key="d.id"
+ class="ot__list-item"
+ :class="{ active: d.id === selectedId, readonly: !d.editable }"
+ @click="selectedId = d.id"
+ >
+ <div class="ot__list-title">{{ d.title }}</div>
+ <div class="ot__list-meta">
+ <code>{{ d.id }}</code>
+ <span>{{ d.widgetCount }} widget{{ d.widgetCount === 1 ? '' : 's' }}</span>
+ <span v-if="!d.editable" class="ot__readonly-tag">no source file</span>
+ </div>
+ </li>
+ <li v-if="dashboards.length === 0" class="ot__list-empty">No overview templates loaded.</li>
+ </ul>
+
+ <section class="ot__detail">
+ <div v-if="detailQuery.isPending.value && !draft" class="ot__empty">loading…</div>
+ <template v-else-if="draft">
+ <header class="ot__detail-head">
+ <h2><code>{{ draft.id }}</code></h2>
+ <span class="ot__count mono">{{ draft.widgets.length }} widget{{ draft.widgets.length === 1 ? '' : 's' }}</span>
+ </header>
+
+ <table class="ot__table">
+ <thead>
+ <tr>
+ <th>Widget</th>
+ <th>Type</th>
+ <th>Layer</th>
+ <th>Limit</th>
+ <th>Span / Row</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="(w, i) in draft.widgets" :key="w.id">
+ <td>
+ <input v-model="w.title" type="text" class="ot__in" :placeholder="w.id" />
+ <input v-model="w.tip" type="text" class="ot__in ot__in--tip" placeholder="tip (optional)" />
+ <code class="ot__wid-id">{{ w.id }}</code>
+ </td>
+ <td><span class="ot__type">{{ w.type }}</span></td>
+ <td>
+ <select
+ v-if="showsLayer(w)"
+ v-model="w.layer"
+ class="ot__in ot__in--narrow"
+ >
+ <option :value="undefined">— any —</option>
+ <option v-for="k in layerOptions" :key="k" :value="k">{{ k }}</option>
+ </select>
+ <span v-else class="ot__none">—</span>
+ </td>
+ <td>
+ <input
+ v-if="showsLimit(w)"
+ v-model.number="w.limit"
+ type="number"
+ min="1"
+ max="100"
+ class="ot__in ot__in--num"
+ />
+ <span v-else class="ot__none">—</span>
+ </td>
+ <td>
+ <template v-if="showsLayout(w)">
+ <input
+ v-model.number="w.span"
+ type="number"
+ min="1"
+ max="12"
+ class="ot__in ot__in--xnum"
+ :title="`Widget ${i + 1} grid span (1..12)`"
+ />
+ <input
+ v-model.number="w.rowSpan"
+ type="number"
+ min="1"
+ max="12"
+ class="ot__in ot__in--xnum"
+ :title="`Widget ${i + 1} row span (1..12)`"
+ />
+ </template>
+ <span v-else class="ot__none">— / —</span>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <div class="ot__actions">
+ <span v-if="flash" class="ot__flash">{{ flash }}</span>
+ <span v-else-if="isDirty" class="ot__dirty">unsaved changes</span>
+ <span v-else class="ot__clean">saved</span>
+ <button type="button" class="ot__btn" :disabled="!isDirty || saving" @click="onReset">
+ reset
+ </button>
+ <button
+ type="button"
+ class="ot__btn ot__btn--primary"
+ :disabled="!isDirty || saving"
+ @click="onSave"
+ >
+ {{ saving ? 'saving…' : 'save' }}
+ </button>
+ </div>
+ </template>
+ </section>
+ </div>
+ </div>
+</template>
+
+<style scoped>
+.ot { padding: 20px 20px 60px; max-width: 1400px; margin: 0 auto; }
+.ot__head { margin-bottom: 18px; }
+.ot__kicker {
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em;
+ color: var(--sw-accent); margin-bottom: 4px;
+}
+.ot h1 { font-size: 22px; font-weight: 600; color: var(--sw-fg-0); margin: 0 0 8px; }
+.ot__lede { font-size: 12.5px; color: var(--sw-fg-1); line-height: 1.5; margin: 0; max-width: 820px; }
+.ot__lede code {
+ font-family: var(--sw-mono); font-size: 11.5px;
+ color: var(--sw-fg-0); background: var(--sw-bg-2); padding: 1px 5px; border-radius: 3px;
+}
+.ot__empty { padding: 32px; text-align: center; color: var(--sw-fg-3); font-size: 12px; }
+
+.ot__split { display: grid; grid-template-columns: 280px 1fr; gap: 16px; align-items: start; }
+.ot__list {
+ list-style: none; margin: 0; padding: 0;
+ background: var(--sw-bg-1); border: 1px solid var(--sw-line); border-radius: 8px; overflow: hidden;
+}
+.ot__list-item { padding: 10px 14px; border-bottom: 1px solid var(--sw-line); cursor: pointer; }
+.ot__list-item:last-child { border-bottom: none; }
+.ot__list-item:hover { background: var(--sw-bg-2); }
+.ot__list-item.active { background: var(--sw-bg-3); box-shadow: inset 2px 0 0 var(--sw-accent); }
+.ot__list-item.readonly { opacity: 0.6; cursor: not-allowed; }
+.ot__list-title { font-size: 12.5px; font-weight: 500; color: var(--sw-fg-0); }
+.ot__list-meta { margin-top: 4px; display: flex; gap: 8px; font-size: 10.5px; color: var(--sw-fg-3); }
+.ot__list-meta code { font-family: var(--sw-mono); color: var(--sw-fg-2); }
+.ot__readonly-tag { color: var(--sw-warn); font-style: italic; }
+.ot__list-empty { padding: 24px; text-align: center; font-size: 12px; color: var(--sw-fg-3); }
+
+.ot__detail { background: var(--sw-bg-1); border: 1px solid var(--sw-line); border-radius: 8px; padding: 16px; }
+.ot__detail-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; }
+.ot__detail-head h2 { margin: 0; font-size: 13px; font-weight: 600; }
+.ot__detail-head h2 code { font-family: var(--sw-mono); color: var(--sw-fg-0); }
+.ot__count { font-size: 11px; color: var(--sw-fg-3); margin-left: auto; }
+
+.ot__table { width: 100%; border-collapse: collapse; font-size: 11.5px; }
+.ot__table thead th {
+ text-align: left; font-size: 10px;
+ text-transform: uppercase; letter-spacing: 0.08em;
+ color: var(--sw-fg-3); padding: 6px 8px;
+ border-bottom: 1px solid var(--sw-line); font-weight: 600;
+}
+.ot__table tbody td {
+ padding: 8px; vertical-align: top;
+ border-bottom: 1px solid var(--sw-line);
+}
+.ot__table tbody tr:last-child td { border-bottom: none; }
+.ot__in {
+ background: var(--sw-bg-2); border: 1px solid var(--sw-line);
+ color: var(--sw-fg-0); font: inherit; font-size: 11.5px;
+ padding: 4px 6px; border-radius: 4px;
+}
+.ot__in--narrow { width: 140px; }
+.ot__in--num { width: 70px; font-variant-numeric: tabular-nums; }
+.ot__in--xnum { width: 48px; font-variant-numeric: tabular-nums; margin-right: 4px; }
+.ot__in--tip {
+ display: block; margin-top: 4px;
+ font-size: 10.5px; color: var(--sw-fg-2); width: 100%;
+}
+.ot__wid-id {
+ display: block; margin-top: 4px;
+ font-family: var(--sw-mono); font-size: 10px; color: var(--sw-fg-3);
+}
+.ot__type {
+ font-family: var(--sw-mono); font-size: 10.5px;
+ color: var(--sw-fg-1); background: var(--sw-bg-2);
+ padding: 2px 6px; border-radius: 3px;
+}
+.ot__none { color: var(--sw-fg-3); }
+
+.ot__actions { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
+.ot__flash { font-size: 11px; color: var(--sw-ok); margin-right: auto; }
+.ot__dirty { font-size: 11px; color: var(--sw-warn); margin-right: auto; }
+.ot__clean { font-size: 11px; color: var(--sw-fg-3); margin-right: auto; }
+.ot__btn {
+ background: var(--sw-bg-1); border: 1px solid var(--sw-line-2);
+ color: var(--sw-fg-0); font: inherit; font-size: 12px;
+ padding: 6px 14px; border-radius: 4px; cursor: pointer;
+}
+.ot__btn:not(:disabled):hover { background: var(--sw-bg-2); }
+.ot__btn:disabled { opacity: 0.4; cursor: not-allowed; }
+.ot__btn--primary {
+ background: var(--sw-accent); border-color: var(--sw-accent);
+ color: #0a0d12; font-weight: 600;
+}
+.ot__btn--primary:not(:disabled):hover { background: var(--sw-accent-light, #fb923c); }
+</style>
diff --git a/apps/ui/src/features/alarms/AlarmDetailPanel.vue b/apps/ui/src/features/alarms/AlarmDetailPanel.vue
index f123468..ed09bd3 100644
--- a/apps/ui/src/features/alarms/AlarmDetailPanel.vue
+++ b/apps/ui/src/features/alarms/AlarmDetailPanel.vue
@@ -28,13 +28,93 @@
-->
<script setup lang="ts">
import { computed } from 'vue';
-import type { AlarmMessage } from '@/api/client';
-import TimeChart from '@/components/charts/TimeChart.vue';
+import { useQuery } from '@tanstack/vue-query';
+import { bff, type AlarmMessage, type AlertingRuleDetailResponse } from '@/api/client';
+import AlarmSnapshotChart from './AlarmSnapshotChart.vue';
+import { formatAlarmEntity } from '@/utils/alarmEntity';
const props = defineProps<{ alarm: AlarmMessage | null }>();
+const entityLabel = computed(() =>
+ props.alarm ? formatAlarmEntity(props.alarm.scope, props.alarm.name) : null,
+);
+
const firing = computed<boolean>(() => props.alarm?.recoveryTime === null);
+/* OAP packs `<entityBase64>.<ruleNumber>` into the AlarmMessage.id.
+ * The rule's actual name lives in the `message` text via the
+ * formatter template — we don't have a clean way to extract it from
+ * the wire shape alone. Best signal: scan the message for the rule
+ * IDs that admin-server knows about and pick the first match.
+ *
+ * The query is intentionally minimal — fires only when there's an
+ * alarm selected AND the admin-server `/status/alarm/rules` endpoint
+ * is reachable. Failures are silent (no panic-banner): the rule
+ * section just collapses to "rule definition unavailable".
+ */
+const rulesIndex = useQuery({
+ queryKey: ['alarms/admin-rules-index'],
+ queryFn: () => bff.alarms.adminRules(),
+ enabled: computed(() => props.alarm !== null),
+ staleTime: 60_000,
+ retry: false,
+});
+
+/** Best-effort rule-id pick: longest-prefix match of any known rule
+ * ID in the alarm message. Longest wins because rule names share
+ * prefixes (`service_resp_time_rule` vs `service_resp_time_p99_rule`)
+ * and we want the more specific match. */
+const matchedRuleId = computed<string | null>(() => {
+ const a = props.alarm;
+ const list = rulesIndex.data.value?.rules ?? [];
+ if (!a || list.length === 0) return null;
+ const msg = a.message;
+ let best: string | null = null;
+ for (const r of list) {
+ if (msg.includes(r.ruleId)) {
+ if (!best || r.ruleId.length > best.length) best = r.ruleId;
+ }
+ }
+ return best;
+});
+
+const ruleDetailQuery = useQuery({
+ queryKey: computed(() => ['alarms/admin-rule', matchedRuleId.value]),
+ queryFn: (): Promise<AlertingRuleDetailResponse> =>
+ bff.alarms.adminRule(matchedRuleId.value!),
+ enabled: computed(() => matchedRuleId.value !== null),
+ staleTime: 60_000,
+ retry: false,
+});
+
+const ruleDetail = computed(() => ruleDetailQuery.data.value?.detail ?? null);
+
+/** Inferred snapshot window — one value per MINUTE bucket. Read from
+ * the first metric's first result's value-array length. */
+const snapshotBuckets = computed<number>(() => {
+ const m = props.alarm?.snapshot.metrics[0];
+ const r = m?.results[0];
+ return r?.values.length ?? 0;
+});
+
+/** Bucket-range label: "HH:MM → HH:MM" computed the same way the
+ * snapshot chart anchors its x-axis (latest bucket = trigger minute,
+ * earlier buckets step back). Empty when no snapshot. */
+const snapshotRangeLabel = computed<string>(() => {
+ if (!props.alarm || snapshotBuckets.value === 0) return '';
+ const MINUTE = 60_000;
+ const T = Math.floor(props.alarm.startTime / MINUTE) * MINUTE;
+ const start = T - (snapshotBuckets.value - 1) * MINUTE;
+ return `${fmtMinute(start)} → ${fmtMinute(T)}`;
+});
+
+function fmtMinute(ts: number): string {
+ const d = new Date(ts);
+ const h = String(d.getHours()).padStart(2, '0');
+ const m = String(d.getMinutes()).padStart(2, '0');
+ return `${h}:${m}`;
+}
+
const startedRelative = computed<string>(() => {
if (!props.alarm) return '';
return formatRelative(props.alarm.startTime);
@@ -57,68 +137,54 @@ function formatRelative(ts: number): string {
return `${d}d ${h % 24}h ago`;
}
-interface ChartSpec {
- title: string;
- labels: string[];
- series: Array<{ label: string; data: Array<number | null> }>;
-}
-
-const charts = computed<ChartSpec[]>(() => {
+/** Metrics with at least one series of values, in wire order.
+ * Drives the one-snapshot-chart-per-metric render below. */
+const snapshotMetrics = computed(() => {
if (!props.alarm) return [];
- const out: ChartSpec[] = [];
- for (const metric of props.alarm.snapshot.metrics) {
- if (metric.results.length === 0) continue;
- const maxLen = Math.max(...metric.results.map((r) => r.values.length), 0);
- const series = metric.results.map((r, i) => {
- const labels = (r.metric?.labels ?? []).map((l) => `${l.key}=${l.value}`).join(',');
- return {
- label: labels.length > 0 ? labels : `series ${i + 1}`,
- data: r.values.map((v) => {
- if (v.value === null) return null;
- const n = Number(v.value);
- return Number.isFinite(n) ? n : null;
- }),
- };
- });
- // Bucket indices on the x-axis — OAP doesn't return per-bucket
- // timestamps with the snapshot; using indices keeps it stable.
- const labels: string[] = [];
- for (let i = 0; i < maxLen; i++) labels.push(String(i));
- out.push({ title: metric.name, labels, series });
- }
- return out;
+ return props.alarm.snapshot.metrics.filter((m) => m.results.length > 0);
});
+
+/** Rule's `period` (MINUTE buckets) when admin-server reported it,
+ * for the snapshot chart's window-band overlay. Null when admin is
+ * unreachable or the rule isn't matched. */
+const rulePeriod = computed<number | null>(() => ruleDetail.value?.period ?? null);
</script>
<template>
<aside class="ad" v-if="alarm">
+ <!-- Headline: entity (scope prefix + OAP's pre-formatted name).
+ OAP's `NotifyHandler` already joins relations + instance-
+ of-service into the `name` field, so we just decorate with
+ the scope-as-noun prefix. Status pill sits to the right. -->
<header class="ad__head">
- <h2 class="ad__id">{{ alarm.id }}</h2>
+ <div class="ad__head-main">
+ <div v-if="entityLabel?.prefix" class="ad__entity-kind">{{ entityLabel.prefix }}</div>
+ <h2 class="ad__entity-name">
+ <template v-for="(s, i) in entityLabel?.segments ?? []" :key="i">
+ <template v-if="s.kind === 'group'">
+ <span class="ad__entity-group">{{ s.group }}</span><span class="ad__entity-base">{{ s.base }}</span>
+ </template>
+ <span v-else>{{ s.text }}</span>
+ </template>
+ </h2>
+ </div>
<span class="sw-badge" :class="firing ? 'is-err' : 'is-ok'">
<span class="state-dot" />{{ firing ? 'firing' : 'recovered' }}
</span>
</header>
-
- <p class="ad__rule">{{ alarm.message }}</p>
<div class="ad__sub">
<span>started {{ startedRelative }}</span>
<template v-if="!firing"> · recovered {{ recoveredRelative }}</template>
+ <template v-if="alarm.layerKey"> · {{ alarm.layerKey }}</template>
</div>
+ <!-- 1. Message -->
<section class="ad__sec">
- <div class="ad__kicker">Trigger expression</div>
- <pre class="ad__expr">{{ alarm.snapshot.expression || '— no MQE recorded —' }}</pre>
- </section>
-
- <section class="ad__sec">
- <div class="ad__kicker">Scope</div>
- <div class="ad__scope">
- <span class="ad__tag">{{ alarm.scope ?? 'unknown' }}</span>
- <code class="ad__entity">{{ alarm.name }}</code>
- <span v-if="alarm.layerKey" class="ad__tag">{{ alarm.layerKey }}</span>
- </div>
+ <div class="ad__kicker">Message</div>
+ <p class="ad__rule">{{ alarm.message }}</p>
</section>
+ <!-- 2. Tags -->
<section v-if="alarm.tags.length > 0" class="ad__sec">
<div class="ad__kicker">Tags</div>
<div class="ad__tags">
@@ -128,12 +194,71 @@ const charts = computed<ChartSpec[]>(() => {
</div>
</section>
- <section v-for="c in charts" :key="c.title" class="ad__sec">
- <div class="ad__kicker">{{ c.title }}</div>
- <TimeChart :series="c.series" :height="100" format="decimal" />
+ <!-- 3. Expression -->
+ <section class="ad__sec">
+ <div class="ad__kicker">Trigger expression</div>
+ <pre class="ad__expr">{{ alarm.snapshot.expression || '— no MQE recorded —' }}</pre>
+ <div v-if="snapshotBuckets > 0" class="ad__hint">
+ snapshot covers {{ snapshotBuckets }} × 1m bucket{{ snapshotBuckets === 1 ? '' : 's' }}
+ · {{ snapshotRangeLabel }}
+ </div>
</section>
- <section v-if="charts.length === 0" class="ad__sec ad__empty">
+ <!-- Rule body — lazy fetch via admin-server. Sits between the
+ expression and the snapshot charts so the metadata
+ (period / silence / hooks) contextualises the bucket count
+ shown above and the per-metric chart shown below. Hidden when
+ admin is unreachable or the message doesn't match a known
+ rule. -->
+ <section v-if="matchedRuleId" class="ad__sec">
+ <div class="ad__kicker ad__kicker--with-link">
+ <span>Rule</span>
+ <RouterLink
+ :to="{ path: '/operate/alerting-rules', query: { id: matchedRuleId } }"
+ class="ad__rule-link"
+ >view in catalog →</RouterLink>
+ </div>
+ <div v-if="ruleDetailQuery.isPending.value" class="ad__rule-loading">
+ loading rule…
+ </div>
+ <div v-else-if="ruleDetailQuery.isError.value || !ruleDetail" class="ad__rule-loading">
+ rule definition unavailable
+ </div>
+ <div v-else class="ad__rule-grid">
+ <div class="ad__rule-cell"><span class="ad__rule-label">id</span><code>{{ ruleDetail.ruleId }}</code></div>
+ <div class="ad__rule-cell"><span class="ad__rule-label">period</span>{{ ruleDetail.period }}m</div>
+ <div class="ad__rule-cell"><span class="ad__rule-label">silence</span>{{ ruleDetail.silencePeriod }}m</div>
+ <div class="ad__rule-cell">
+ <span class="ad__rule-label">recovery-obs</span>{{ ruleDetail.recoveryObservationPeriod }}m
+ </div>
+ <div v-if="ruleDetail.hooks.length > 0" class="ad__rule-cell ad__rule-cell--wide">
+ <span class="ad__rule-label">hooks</span>
+ <span v-for="h in ruleDetail.hooks" :key="h" class="ad__tag">{{ h }}</span>
+ </div>
+ <div v-if="ruleDetail.includeMetrics.length > 0" class="ad__rule-cell ad__rule-cell--wide">
+ <span class="ad__rule-label">metrics</span>
+ <code v-for="m in ruleDetail.includeMetrics" :key="m">{{ m }}</code>
+ </div>
+ </div>
+ </section>
+
+ <!-- 4. Snapshots — per-metric chart of values captured at firing.
+ X-axis is real time (reconstructed from the trigger minute
+ + bucket count). Trigger time is marked with a vertical
+ orange line; the rule's evaluation window is shaded when
+ admin-server gave us the rule's `period`. -->
+ <section v-for="m in snapshotMetrics" :key="m.name" class="ad__sec">
+ <div class="ad__kicker">{{ m.name }}</div>
+ <AlarmSnapshotChart
+ :metric="m"
+ :trigger-time="alarm.startTime"
+ :recovery-time="alarm.recoveryTime"
+ :rule-period="rulePeriod"
+ :height="120"
+ />
+ </section>
+
+ <section v-if="snapshotMetrics.length === 0" class="ad__sec ad__empty">
No MQE snapshot was recorded with this alarm. Upgrade OAP or
enable the snapshot capture in the alarm rule to see the trigger
values here.
@@ -183,22 +308,53 @@ const charts = computed<ChartSpec[]>(() => {
}
.ad__head {
display: flex;
- align-items: center;
+ align-items: flex-start;
gap: 8px;
}
-.ad__id {
+.ad__head-main {
+ flex: 1;
+ min-width: 0;
+}
+.ad__entity-kind {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sw-accent);
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+.ad__entity-name {
font-family: var(--sw-mono);
- font-size: 12px;
- color: var(--sw-fg-2);
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--sw-fg-0);
margin: 0;
- flex: 1;
+ line-height: 1.4;
+ word-break: break-all;
}
+/* Probe-source classifier (`agent`, `mesh-svr`, `mesh-dp`, `mesh-cp`).
+ * Just operational metadata — muted, not accent. Render as a quiet
+ * grey tag adjacent to the service base name. */
+.ad__entity-group {
+ display: inline-block;
+ font-family: var(--sw-mono);
+ font-size: 10px;
+ font-weight: 500;
+ text-transform: lowercase;
+ color: var(--sw-fg-2);
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line);
+ padding: 0 5px;
+ border-radius: 3px;
+ margin-right: 5px;
+ vertical-align: 2px;
+}
+.ad__entity-base { /* default — inherits parent mono font */ }
.ad__rule {
font-size: 13px;
- font-weight: 600;
color: var(--sw-fg-0);
margin: 0;
- line-height: 1.4;
+ line-height: 1.5;
}
.ad__sub {
font-size: 11px;
@@ -228,18 +384,62 @@ const charts = computed<ChartSpec[]>(() => {
word-break: break-all;
line-height: 1.5;
}
-.ad__scope {
+.ad__hint {
+ margin-top: 6px;
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+}
+.ad__kicker--with-link {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+.ad__rule-link {
+ font-size: 10.5px;
+ color: var(--sw-accent);
+ text-decoration: none;
+ text-transform: none;
+ letter-spacing: 0;
+}
+.ad__rule-link:hover { text-decoration: underline; }
+.ad__rule-loading {
+ font-size: 11px;
+ color: var(--sw-fg-3);
+ font-style: italic;
+}
+.ad__rule-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+}
+.ad__rule-cell {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
-}
-.ad__entity {
- font-family: var(--sw-mono);
font-size: 11.5px;
color: var(--sw-fg-0);
+ padding: 4px 8px;
background: var(--sw-bg-2);
- padding: 2px 6px;
+ border: 1px solid var(--sw-line);
+ border-radius: 4px;
+ font-variant-numeric: tabular-nums;
+}
+.ad__rule-cell--wide { grid-column: 1 / -1; }
+.ad__rule-label {
+ font-size: 9.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sw-fg-3);
+ font-weight: 600;
+}
+.ad__rule-cell code {
+ font-family: var(--sw-mono);
+ font-size: 10.5px;
+ color: var(--sw-fg-1);
+ background: var(--sw-bg-1);
+ padding: 1px 5px;
border-radius: 3px;
}
.ad__tag {
diff --git a/apps/ui/src/features/alarms/AlarmSnapshotChart.vue b/apps/ui/src/features/alarms/AlarmSnapshotChart.vue
new file mode 100644
index 0000000..2091a30
--- /dev/null
+++ b/apps/ui/src/features/alarms/AlarmSnapshotChart.vue
@@ -0,0 +1,313 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<!--
+ Per-metric snapshot chart for the alarm detail panel.
+
+ OAP's `AlarmSnapshot` carries the metric values from the rule's
+ evaluation window at firing time — one value per MINUTE bucket. The
+ snapshot DOES NOT include per-bucket timestamps, so we reconstruct
+ them: the last bucket aligns to the trigger minute, prior buckets
+ step back one minute each.
+
+ Two reference markers ride on top of the line:
+ - **Trigger time** — vertical solid line at `alarm.startTime`. The
+ moment the rule's evaluation crossed threshold.
+ - **Rule window** — when `rulePeriod` is supplied (from the admin
+ `/status/alarm/{ruleId}` fetch), shade the trailing `period`
+ buckets. Anything earlier is the rule's `additionalPeriod`
+ padding — context only, not in the threshold check.
+-->
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import * as echarts from 'echarts/core';
+import { LineChart } from 'echarts/charts';
+import {
+ GridComponent,
+ MarkAreaComponent,
+ MarkLineComponent,
+ TooltipComponent,
+} from 'echarts/components';
+import { CanvasRenderer } from 'echarts/renderers';
+import type { EChartsType } from 'echarts/core';
+import type { AlarmMqeMetric } from '@/api/client';
+
+echarts.use([
+ LineChart,
+ GridComponent,
+ MarkLineComponent,
+ MarkAreaComponent,
+ TooltipComponent,
+ CanvasRenderer,
+]);
+
+const props = withDefaults(
+ defineProps<{
+ metric: AlarmMqeMetric;
+ /** Alarm fire time, ms epoch. Reconstructs bucket timestamps and
+ * anchors the trigger marker. */
+ triggerTime: number;
+ /** Alarm recovery time, ms epoch, or null when the alarm is
+ * still firing. When present, a green vertical line marks the
+ * moment OAP cleared the alarm (after `recoveryObservationPeriod`
+ * of clean buckets). */
+ recoveryTime?: number | null;
+ /** Rule's `period` (MINUTE buckets in the evaluation window).
+ * When known, the trailing `period` buckets get a soft band so
+ * the operator sees which slice was actually compared against
+ * the threshold. */
+ rulePeriod?: number | null;
+ height?: number;
+ }>(),
+ {
+ height: 120,
+ recoveryTime: null,
+ rulePeriod: null,
+ },
+);
+
+const MINUTE_MS = 60_000;
+const PALETTE = [
+ '#f97316',
+ '#60a5fa',
+ '#a78bfa',
+ '#22d3ee',
+ '#34d399',
+ '#f472b6',
+];
+
+interface SeriesIn {
+ label: string;
+ data: Array<[number, number | null]>;
+}
+
+function buildSeries(): { series: SeriesIn[]; bucketCount: number; latestMinute: number } {
+ const results = props.metric.results;
+ const bucketCount = Math.max(...results.map((r) => r.values.length), 0);
+ /* `T` — the latest value's minute. The trigger's own minute IS the
+ * last bucket in the snapshot (OAP records the latest bucket value
+ * as part of the evaluation that fires the alarm). Earlier
+ * buckets step back one minute each from T. */
+ const latestMinute = Math.floor(props.triggerTime / MINUTE_MS) * MINUTE_MS;
+ const startMin = latestMinute - (bucketCount - 1) * MINUTE_MS;
+ const series: SeriesIn[] = results.map((r, i) => {
+ const labels = (r.metric?.labels ?? []).map((l) => `${l.key}=${l.value}`).join(',');
+ const data: Array<[number, number | null]> = r.values.map((v, j) => {
+ const ts = startMin + j * MINUTE_MS;
+ if (v.value === null) return [ts, null];
+ const n = Number(v.value);
+ return [ts, Number.isFinite(n) ? n : null];
+ });
+ return { label: labels.length > 0 ? labels : `series ${i + 1}`, data };
+ });
+ return { series, bucketCount, latestMinute };
+}
+
+function fmtMinute(ts: number): string {
+ const d = new Date(ts);
+ const h = String(d.getHours()).padStart(2, '0');
+ const m = String(d.getMinutes()).padStart(2, '0');
+ return `${h}:${m}`;
+}
+
+function buildOption(): echarts.EChartsCoreOption {
+ const { series, bucketCount, latestMinute } = buildSeries();
+ const startMin = latestMinute - (bucketCount - 1) * MINUTE_MS;
+ /* X-axis padding — half a minute on each side so the first / last
+ * dots aren't flush against the axis edges. Right side extends
+ * past whichever event sits latest (trigger or recovery), so
+ * neither marker spills off the plot. */
+ const xMin = startMin - MINUTE_MS / 2;
+ const rightAnchor = Math.max(
+ latestMinute,
+ props.triggerTime,
+ props.recoveryTime ?? 0,
+ );
+ const xMax = rightAnchor + MINUTE_MS;
+
+ /* Always shade the evaluated window so the operator sees the time
+ * range that informed the alarm. When the admin-server fetch gave
+ * us the rule's `period`, shade exactly that many trailing buckets
+ * (the slice the rule compared against its threshold). When it
+ * didn't (admin unreachable / legacy OAP / rule not matched), fall
+ * back to the full snapshot — every bucket OAP captured. The
+ * label reflects which mode is in effect.
+ *
+ * Span: `[T - window, T]` — anchored to the latest-bucket minute
+ * `T` and walking back `window` minutes. The band ends at T
+ * exactly so the trigger marker (at triggerTime, slightly past T)
+ * sits visibly OUTSIDE the band — the operator reads "data inside
+ * the band, fire moment right after". */
+ const haveRulePeriod = !!(
+ props.rulePeriod && props.rulePeriod > 0 && props.rulePeriod <= bucketCount
+ );
+ const windowMinutes = haveRulePeriod ? props.rulePeriod! : bucketCount;
+ const winStart = latestMinute - windowMinutes * MINUTE_MS;
+ const winEnd = latestMinute;
+ const winLabel = haveRulePeriod
+ ? `rule window (${windowMinutes}m)`
+ : `snapshot window (${windowMinutes}m)`;
+ const markArea = {
+ silent: true,
+ itemStyle: {
+ color: 'rgba(249,115,22,0.14)',
+ borderColor: 'rgba(249,115,22,0.5)',
+ borderWidth: 1,
+ borderType: 'dashed',
+ },
+ label: {
+ show: true,
+ position: 'insideTop',
+ color: 'rgba(249,115,22,0.95)',
+ fontSize: 10,
+ fontWeight: 600,
+ formatter: winLabel,
+ },
+ data: [[{ xAxis: winStart }, { xAxis: winEnd }]],
+ };
+
+ return {
+ animation: false,
+ backgroundColor: 'transparent',
+ grid: { left: 36, right: 8, top: 18, bottom: 22 },
+ tooltip: {
+ trigger: 'axis',
+ backgroundColor: 'rgba(20,20,24,0.92)',
+ borderColor: 'rgba(255,255,255,0.08)',
+ textStyle: { color: '#e5e7eb', fontSize: 11 },
+ valueFormatter: (v: unknown) =>
+ typeof v === 'number' && Number.isFinite(v) ? v.toFixed(2) : '—',
+ },
+ xAxis: {
+ type: 'time',
+ min: xMin,
+ max: xMax,
+ axisLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } },
+ axisLabel: {
+ color: '#64748b',
+ fontSize: 9,
+ hideOverlap: true,
+ formatter: (ts: number) => fmtMinute(ts),
+ },
+ splitLine: { show: false },
+ },
+ yAxis: {
+ type: 'value',
+ axisLine: { show: false },
+ axisLabel: { color: '#64748b', fontSize: 9 },
+ splitLine: { lineStyle: { color: 'rgba(255,255,255,0.06)' } },
+ },
+ series: series.map((s, i) => {
+ const color = PALETTE[i % PALETTE.length];
+ const isOnly = series.length === 1;
+ return {
+ name: s.label,
+ type: 'line',
+ smooth: true,
+ symbol: 'circle',
+ symbolSize: 4,
+ showSymbol: true,
+ lineStyle: { width: 1.5, color },
+ itemStyle: { color, borderColor: color, borderWidth: 1 },
+ data: s.data.map(([ts, v]) => [ts, v === null ? '-' : v]),
+ areaStyle: isOnly ? { color, opacity: 0.1 } : undefined,
+ /* Trigger marker + window band ride on the first series only.
+ * ECharts collapses markLines / markAreas across series, so
+ * declaring them once on series[0] avoids duplicate rendering.
+ * The recovery marker (green) is added as a second markLine
+ * entry only when the alarm has actually recovered. */
+ ...(i === 0
+ ? {
+ markLine: {
+ silent: true,
+ symbol: 'none',
+ /* Per-line style — orange for trigger, green for
+ * recovered. ECharts reads `lineStyle` on each datum
+ * when present; the series-level lineStyle is the
+ * fallback for the trigger line. */
+ lineStyle: { color: 'rgba(249,115,22,0.95)', width: 1.5, type: 'solid' },
+ label: {
+ show: true,
+ position: 'insideEndTop',
+ fontSize: 10,
+ fontWeight: 600,
+ },
+ data: [
+ {
+ xAxis: props.triggerTime,
+ label: {
+ color: '#f97316',
+ formatter: `trigger ${fmtMinute(props.triggerTime)}`,
+ },
+ },
+ ...(props.recoveryTime !== null && props.recoveryTime !== undefined
+ ? [
+ {
+ xAxis: props.recoveryTime,
+ lineStyle: { color: 'rgba(34,197,94,0.95)', width: 1.5, type: 'solid' },
+ label: {
+ color: '#22c55e',
+ position: 'insideStartTop',
+ formatter: `recovered ${fmtMinute(props.recoveryTime)}`,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ markArea,
+ }
+ : {}),
+ };
+ }),
+ };
+}
+
+const container = ref<HTMLDivElement | null>(null);
+let chart: EChartsType | null = null;
+
+onMounted(() => {
+ if (!container.value) return;
+ chart = echarts.init(container.value, null, { renderer: 'canvas' });
+ chart.setOption(buildOption());
+ const ro = new ResizeObserver(() => chart?.resize());
+ ro.observe(container.value);
+ onBeforeUnmount(() => {
+ ro.disconnect();
+ chart?.dispose();
+ chart = null;
+ });
+});
+
+watch(
+ () => [props.metric, props.triggerTime, props.recoveryTime, props.rulePeriod],
+ () => {
+ if (!chart) return;
+ chart.setOption(buildOption(), { replaceMerge: ['series', 'xAxis', 'yAxis'] });
+ },
+ { deep: true },
+);
+</script>
+
+<template>
+ <div ref="container" class="alarm-snapshot" :style="{ height: `${height}px` }" />
+</template>
+
+<style scoped>
+.alarm-snapshot {
+ width: 100%;
+}
+</style>
diff --git a/apps/ui/src/features/alarms/AlarmsView.vue b/apps/ui/src/features/alarms/AlarmsView.vue
index c8301b9..3990aac 100644
--- a/apps/ui/src/features/alarms/AlarmsView.vue
+++ b/apps/ui/src/features/alarms/AlarmsView.vue
@@ -15,86 +15,176 @@
limitations under the License.
-->
<!--
- Alarms page. Independent — not bound to a layer template, not an
- overview dashboard. Layout:
+ Alarms triage page. Layout:
- ┌── header ─────────────────────────────────────────────────┐
- │ Alarms [1h] [6h] [24h] │
- │ filters: service / instance / endpoint │
- ├── KPI strip ──────────────────────────────────────────────┤
- │ Firing · Window total · Layers · Selection │
- ├── timeline (background traffic + alarm flags) ───────────┤
- │ click flag = select alarm │
- │ drag region = select time range │
- ├── grouped list ─────────────┬── alarm detail ─────────────┤
- │ by layer (one section each) │ expression + trigger metrics│
- └─────────────────────────────┴─────────────────────────────┘
+ ┌── header ─────────────────────────────────────────────────────┐
+ │ Alarms [20m] [2h] [4h] [custom] [↻] │
+ ├── KPI strip ──────────────────────────────────────────────────┤
+ │ TOTAL · GENERAL · MESH · …pinned… · k8s 4 vm 2 …overflow │
+ ├── filter row (conditional on capabilities.queryAlarms) ──────┤
+ │ layer · service · instance · endpoint · keyword · [apply] │
+ ├── timeline (alarm flags per layer lane) ─────────────────────┤
+ │ click flag = select alarm; brush = select time range │
+ ├── grouped list ─────────────────────┬── detail panel ────────┤
+ │ rows + frontend pager │ expression + snapshot │
+ └─────────────────────────────────────┴────────────────────────┘
+
+ Dual-mode contract — driven by `useOapInfo().capabilities.queryAlarms`:
+ - New: filter row shows layer + cascade + keyword. Filters apply
+ server-side via queryAlarms `entities` / `layers`.
+ - Legacy: filter row shows keyword only. Server-side filters
+ gracefully no-op; chips in the header still filter the
+ fetched response client-side.
-->
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
import { useQuery } from '@tanstack/vue-query';
import {
bff,
bffClient,
type AlarmMessage,
+ type AlarmsConfig,
type AlarmsResponse,
- type AlarmTrafficPoint,
- type AlarmTrafficSeries,
} from '@/api/client';
import { useLayers } from '@/shell/useLayers';
+import { useOapInfo } from '@/shell/useOapInfo';
import AlarmsTimeline from '@/components/charts/AlarmsTimeline.vue';
import AlarmDetailPanel from './AlarmDetailPanel.vue';
+import { formatAlarmEntity } from '@/utils/alarmEntity';
+import { mergeIncidents, splitForList, type AlarmIncident } from '@/utils/alarmIncidents';
// ── Time window ──────────────────────────────────────────────────────
-// Pinned end-of-window. Only changes on: page entry, window-size pick,
-// manual refresh. No global auto-refresh ticker here — alarms is a
-// triage view, not a wall-board, and a constantly-shifting `now`
-// invalidates any time-range selection the operator is making.
-type WindowKey = '1h' | '6h' | '24h';
-const WINDOW_MS: Record<WindowKey, number> = {
- '1h': 60 * 60_000,
- '6h': 6 * 60 * 60_000,
- '24h': 24 * 60 * 60_000,
+/* Per the alerting redesign: 20m / 2h / 4h presets + custom up to 4h.
+ * Alarms are second-precision events; longer windows pull thousands
+ * of rows and starve the page on some storage backends. The BFF
+ * enforces the same 4h cap server-side. */
+type PresetKey = '20m' | '2h' | '4h';
+const PRESET_MS: Record<PresetKey, number> = {
+ '20m': 20 * 60_000,
+ '2h': 2 * 60 * 60_000,
+ '4h': 4 * 60 * 60_000,
};
-/* Per-call MQE bucket cap. OAP's MINUTE-step `execExpression`
- * returns a fixed-size value array (~100 buckets) regardless of the
- * requested duration — beyond that it pads with nulls anchored at
- * the START of the requested window, leaving the recent portion
- * empty. To paint a continuous traffic line across longer windows
- * we slice the window into ≤90-minute chunks and merge the per-
- * chunk responses into the cache. 90 leaves headroom under the cap;
- * 100 ran right at the edge on demo OAP. */
-const TRAFFIC_CHUNK_MS = 90 * 60_000;
-const TRAFFIC_DELTA_MS = 60 * 60_000;
-/* Cap concurrent chunk requests so OAP doesn't see a fan-out of
- * (chunks × services) MQE calls in one breath. 3 is a balance:
- * 6h = 4 chunks → finishes in two batches; 24h = 16 chunks → six
- * batches. Higher would shave wall-time but pressure demo OAP. */
-const TRAFFIC_CHUNK_CONCURRENCY = 3;
+const PRESETS: readonly PresetKey[] = ['20m', '2h', '4h'] as const;
+const MAX_CUSTOM_MS = 4 * 60 * 60_000;
+
+/** Reverse-lookup: configured ms → preset key. Returns `'20m'` for
+ * any value that doesn't match a preset (defensive — the BFF rejects
+ * non-preset values, but the page should still render with a sane
+ * default if it ever sees one). */
+function presetFromMs(ms: number | undefined): PresetKey {
+ if (ms === PRESET_MS['4h']) return '4h';
+ if (ms === PRESET_MS['2h']) return '2h';
+ return '20m';
+}
-const windowChoice = ref<WindowKey>('6h');
+type WindowMode = PresetKey | 'custom';
+const windowMode = ref<WindowMode>('20m');
const windowEndAt = ref<number>(Date.now());
-const startTime = computed<number>(() => windowEndAt.value - WINDOW_MS[windowChoice.value]);
-const endTime = computed<number>(() => windowEndAt.value);
+/** Custom range — only consulted when `windowMode === 'custom'`. */
+const customStart = ref<number>(Date.now() - PRESET_MS['4h']);
+const customEnd = ref<number>(Date.now());
+const customError = ref<string | null>(null);
+const customOpen = ref<boolean>(false);
+
+const startTime = computed<number>(() => {
+ if (windowMode.value === 'custom') return customStart.value;
+ return windowEndAt.value - PRESET_MS[windowMode.value];
+});
+const endTime = computed<number>(() => {
+ if (windowMode.value === 'custom') return customEnd.value;
+ return windowEndAt.value;
+});
+
+function pickPreset(p: PresetKey): void {
+ windowMode.value = p;
+ windowEndAt.value = Date.now();
+ customOpen.value = false;
+ customError.value = null;
+}
-// ── Filters (cascading: layer → service → {instance, endpoint}) ─────
-// Two layers of state:
-// - `draft*` — what the operator is currently picking. Drives
-// the cascade dropdowns AND the dependent picker
-// queries (services for the picked layer, etc.)
-// but NOT the alarms query.
-// - `applied*` — what the alarms query actually filters by.
-// Updated only by the Apply button (or Clear).
-// This split keeps the alarms list stable while the operator is
-// composing a multi-step filter; nothing fires until they hit Apply.
+/* Format `epochMs → 'YYYY-MM-DDTHH:mm'` (datetime-local). Uses browser
+ * TZ — display is browser-local; the BFF converts to OAP TZ on send. */
+function toLocalInput(ms: number): string {
+ const d = new Date(ms);
+ const y = d.getFullYear();
+ const mo = String(d.getMonth() + 1).padStart(2, '0');
+ const dd = String(d.getDate()).padStart(2, '0');
+ const h = String(d.getHours()).padStart(2, '0');
+ const mi = String(d.getMinutes()).padStart(2, '0');
+ return `${y}-${mo}-${dd}T${h}:${mi}`;
+}
+const customStartInput = ref<string>(toLocalInput(customStart.value));
+const customEndInput = ref<string>(toLocalInput(customEnd.value));
+
+function openCustom(): void {
+ windowMode.value = 'custom';
+ customOpen.value = true;
+ customStartInput.value = toLocalInput(customStart.value);
+ customEndInput.value = toLocalInput(customEnd.value);
+ customError.value = null;
+}
+function applyCustom(): void {
+ const s = new Date(customStartInput.value).getTime();
+ const e = new Date(customEndInput.value).getTime();
+ if (!Number.isFinite(s) || !Number.isFinite(e)) {
+ customError.value = 'Invalid date';
+ return;
+ }
+ if (e <= s) {
+ customError.value = 'End must be after start';
+ return;
+ }
+ if (e - s > MAX_CUSTOM_MS) {
+ customError.value = `Window exceeds ${MAX_CUSTOM_MS / 60_000 / 60}h cap`;
+ return;
+ }
+ customStart.value = s;
+ customEnd.value = e;
+ customError.value = null;
+ customOpen.value = false;
+}
+
+// ── Capability + page config ────────────────────────────────────────
+const { capabilities } = useOapInfo();
+const hasQueryAlarms = computed<boolean>(() => capabilities.value.queryAlarms);
+
+const pageConfig = useQuery({
+ queryKey: ['alarms/config'],
+ queryFn: (): Promise<AlarmsConfig> => bff.alarms.config(),
+ staleTime: Infinity,
+});
+const pinnedLayers = computed<string[]>(() => pageConfig.data.value?.pinnedLayers ?? []);
+
+/* Initial picker selection is admin-configured. We only apply the
+ * config'd default once — after that the operator's manual picker
+ * choices win (they shouldn't snap back on a refetch). The flag
+ * survives across pageConfig refetches because it's outside the
+ * `data` reactive. */
+let didApplyDefault = false;
+watch(
+ () => pageConfig.data.value?.defaultWindowMs,
+ (ms) => {
+ if (didApplyDefault || ms === undefined) return;
+ windowMode.value = presetFromMs(ms);
+ didApplyDefault = true;
+ },
+ { immediate: true },
+);
+
+// ── Filters (new-mode cascade) ──────────────────────────────────────
+/* Two layers of state: `draft` is what the operator is composing;
+ * `applied` is what the alarms query actually filters by. Nothing
+ * fires until `applyFilters()` (or pickPreset(), or onRefresh()). */
interface FilterValues {
layer: string;
service: string;
instance: string;
endpoint: string;
+ keyword: string;
}
function emptyFilters(): FilterValues {
- return { layer: '', service: '', instance: '', endpoint: '' };
+ return { layer: '', service: '', instance: '', endpoint: '', keyword: '' };
}
const draft = ref<FilterValues>(emptyFilters());
const applied = ref<FilterValues>(emptyFilters());
@@ -104,21 +194,19 @@ const { availableLayers } = useLayers();
const servicesQuery = useQuery({
queryKey: computed(() => ['alarms/services', draft.value.layer]),
queryFn: () => bff.alarms.services(draft.value.layer),
- enabled: computed(() => draft.value.layer.length > 0),
+ enabled: computed(() => hasQueryAlarms.value && draft.value.layer.length > 0),
staleTime: 30_000,
});
const serviceOptions = computed<string[]>(
() => (servicesQuery.data.value?.services ?? []).map((s) => s.name),
);
-/* The /api/layer/:key/instances + /endpoints routes both want the
- * service NAME (they accept either id or name); we pass the picked
- * service through directly. Bound to draft, not applied — the user
- * needs the cascade to populate as they pick. */
const instancesQuery = useQuery({
queryKey: computed(() => ['alarms/instances', draft.value.layer, draft.value.service]),
queryFn: () => bffClient.layer.instances(draft.value.layer, draft.value.service),
- enabled: computed(() => draft.value.layer.length > 0 && draft.value.service.length > 0),
+ enabled: computed(
+ () => hasQueryAlarms.value && draft.value.layer.length > 0 && draft.value.service.length > 0,
+ ),
staleTime: 30_000,
});
const instanceOptions = computed<string[]>(
@@ -127,10 +215,10 @@ const instanceOptions = computed<string[]>(
const endpointsQuery = useQuery({
queryKey: computed(() => ['alarms/endpoints', draft.value.layer, draft.value.service]),
- // Empty query string returns the top-N most-trafficked endpoints —
- // good enough as a starter list; operator can type to filter.
queryFn: () => bffClient.layer.endpoints(draft.value.layer, draft.value.service, '', 50),
- enabled: computed(() => draft.value.layer.length > 0 && draft.value.service.length > 0),
+ enabled: computed(
+ () => hasQueryAlarms.value && draft.value.layer.length > 0 && draft.value.service.length > 0,
+ ),
staleTime: 30_000,
});
const endpointOptions = computed<string[]>(
@@ -153,33 +241,44 @@ function clearFilters(): void {
draft.value = emptyFilters();
applied.value = emptyFilters();
}
-const hasFilter = computed<boolean>(
- () => !!draft.value.layer || !!applied.value.layer,
-);
-/* Apply is disabled when the draft already matches what's applied —
- * no point firing the same query twice. Also lets the button serve
- * as a "saved/dirty" indicator at a glance. */
const isDirty = computed<boolean>(() => {
const d = draft.value;
const a = applied.value;
- return d.layer !== a.layer || d.service !== a.service || d.instance !== a.instance || d.endpoint !== a.endpoint;
+ return (
+ d.layer !== a.layer ||
+ d.service !== a.service ||
+ d.instance !== a.instance ||
+ d.endpoint !== a.endpoint ||
+ d.keyword !== a.keyword
+ );
+});
+
+// ── URL-reflected chip filter ───────────────────────────────────────
+/* The header layer chips narrow the rendered list to that layer
+ * (client-side filter on top of the fetched response). The selection
+ * lives in the URL `?layer=GENERAL` so refresh / share preserves it
+ * AND so the chip state survives a navigation. Empty / 'all' means
+ * no chip filter active. */
+const route = useRoute();
+const router = useRouter();
+const chipLayer = computed<string>({
+ get: () => {
+ const raw = route.query.layer;
+ if (typeof raw === 'string') return raw.toUpperCase();
+ return '';
+ },
+ set: (v: string) => {
+ router.replace({ query: { ...route.query, layer: v ? v : undefined } });
+ },
});
+function selectChip(layerKey: string): void {
+ chipLayer.value = layerKey === chipLayer.value ? '' : layerKey;
+}
-// ── Selection ────────────────────────────────────────────────────────
-// One alarm OR a time range — mutually exclusive. Clicking a flag on
-// the timeline picks an alarm; brushing picks a range and clears any
-// selected alarm; clicking outside the timeline clears both.
-//
-// Selection is keyed by `<id>::<startTime>` not `id` alone — OAP's
-// AlarmMessage.id is `<entityBase64>.<ruleNumber>`, which collapses
-// every firing of the same rule on the same entity to one identifier.
-// A single row's id can therefore appear N times in the same window
-// (one per firing). Using id alone would light up every sibling row
-// when the operator clicks one.
+// ── Selection (alarm OR time range, mutually exclusive) ────────────
function keyFor(a: AlarmMessage): string {
return `${a.id}::${a.startTime}`;
}
-
const selectedAlarmKey = ref<string | null>(null);
const selectedRange = ref<{ startTime: number; endTime: number } | null>(null);
@@ -196,214 +295,116 @@ function clearSelection(): void {
selectedRange.value = null;
}
-// ── Queries ──────────────────────────────────────────────────────────
+// ── Alarms query ───────────────────────────────────────────────────
const alarmsQuery = useQuery({
- // queryKey reads `applied`, not `draft` — typing/picking in the
- // filter row doesn't fire a query; only Apply does (or the window
- // picker, or the Refresh button).
queryKey: computed(() => [
'alarms',
startTime.value,
endTime.value,
+ applied.value.layer,
applied.value.service,
applied.value.instance,
applied.value.endpoint,
+ applied.value.keyword,
]),
queryFn: (): Promise<AlarmsResponse> =>
bff.alarms.list({
startTime: startTime.value,
endTime: endTime.value,
- // Conservative pageSize — some OAP storage backends throw on
- // larger pages (`fail to query stream`). 30 is comfortably under
- // every cap I've seen and still gives a screenful of triage rows.
- // Operator-paged "load more" can come later.
- pageSize: 30,
+ layer: applied.value.layer || undefined,
service: applied.value.service || undefined,
instance: applied.value.instance || undefined,
endpoint: applied.value.endpoint || undefined,
+ keyword: applied.value.keyword || undefined,
}),
- // No refetchInterval — alarms is a triage view. Refresh happens on
- // entry, on Apply, on window-picker change, and on the Refresh
- // button. A constantly-shifting list would invalidate any selection
- // the operator is making.
staleTime: Infinity,
refetchOnWindowFocus: false,
});
-// ── Traffic background — chunked + delta-update ─────────────────────
-// OAP's MQE has a per-step bucket cap (`MINUTE` step = 360 buckets =
-// 6h). To cover longer windows (24h) we fire one BFF request per
-// 6-hour chunk and stitch the results into a per-layer
-// `Map<bucketMs, value>` cache. The chart series derive from the
-// cache filtered to the current visible window.
-//
-// On manual refresh we re-fetch only the trailing 1h chunk and merge
-// the new points into the cache (delta). That keeps the chart fresh
-// without re-firing every chunk against OAP — important for the 24h
-// view which would otherwise issue four MQE blasts on every refresh.
-interface CachedLayer {
- layerKey: string;
- label: string;
- present: boolean;
- error?: string;
- /** ms-bucket → value. Single source of truth for the chart. */
- points: Map<number, number | null>;
-}
-
-const trafficCache = ref<Map<string, CachedLayer>>(new Map());
-const trafficLoading = ref(false);
-const trafficError = ref<string | null>(null);
-
-function splitIntoChunks(start: number, end: number, chunkMs: number): Array<{ start: number; end: number }> {
- const chunks: Array<{ start: number; end: number }> = [];
- let s = start;
- while (s < end) {
- const e = Math.min(s + chunkMs, end);
- chunks.push({ start: s, end: e });
- s = e;
- }
- return chunks;
-}
-
-async function fetchTrafficChunk(s: number, e: number): Promise<void> {
- const resp = await bff.alarms.traffic(s, e);
- for (const series of resp.series) {
- let cached = trafficCache.value.get(series.layerKey);
- if (!cached) {
- cached = {
- layerKey: series.layerKey,
- label: series.label,
- present: series.present,
- error: series.error,
- points: new Map(),
- };
- trafficCache.value.set(series.layerKey, cached);
- } else {
- // Refresh the metadata in case it changed (e.g. layer was empty
- // earlier in the window but is now present).
- cached.label = series.label;
- cached.present = cached.present || series.present;
- cached.error = series.error;
- }
- for (const p of series.points) {
- cached.points.set(p.ts, p.value);
- }
- }
- // Trigger reactivity — `ref<Map>` mutations don't auto-notify Vue.
- trafficCache.value = new Map(trafficCache.value);
-}
+// ── Derived data ────────────────────────────────────────────────────
+const alarms = computed<AlarmMessage[]>(() => alarmsQuery.data.value?.msgs ?? []);
+const truncated = computed<boolean>(() => alarmsQuery.data.value?.truncated ?? false);
-/** Drop everything and fire every chunk for the current window.
- * Bounded-parallel via a tiny worker pool so a 24h window doesn't
- * blast ~16 graphql calls at OAP simultaneously. */
-async function runWithLimit<T>(
- items: T[],
- limit: number,
- fn: (item: T) => Promise<void>,
-): Promise<void> {
- let cursor = 0;
- const next = async (): Promise<void> => {
- while (true) {
- const idx = cursor++;
- if (idx >= items.length) return;
- await fn(items[idx]);
- }
- };
- const workers: Promise<void>[] = [];
- for (let w = 0; w < Math.min(limit, items.length); w++) workers.push(next());
- await Promise.all(workers);
-}
+/** Events narrowed by the brushed time range. Drives the KPI + tab
+ * counts after incident merging. The chip / tab layer filter is
+ * applied LATER (on incidents), not here — otherwise every chip
+ * except the active one would zero out. */
+const rangeScopedAlarms = computed<AlarmMessage[]>(() => {
+ if (!selectedRange.value) return alarms.value;
+ const { startTime: s, endTime: e } = selectedRange.value;
+ return alarms.value.filter((a) => a.startTime >= s && a.startTime <= e);
+});
-async function loadTrafficFull(): Promise<void> {
- trafficCache.value = new Map();
- trafficError.value = null;
- trafficLoading.value = true;
- try {
- const chunks = splitIntoChunks(startTime.value, endTime.value, TRAFFIC_CHUNK_MS);
- await runWithLimit(chunks, TRAFFIC_CHUNK_CONCURRENCY, (c) =>
- fetchTrafficChunk(c.start, c.end),
- );
- } catch (err) {
- trafficError.value = err instanceof Error ? err.message : String(err);
- } finally {
- trafficLoading.value = false;
- }
-}
+/* ── Incident-merged view ────────────────────────────────────────
+ * (entity, rule) → one incident. The page's counts + list operate
+ * on incidents; the Timeline operates on raw events (so the
+ * fire-then-recovered pattern stays visible).
+ *
+ * Per spec: an incident whose LATEST event has recoveryTime !== null
+ * is "recovered" and does NOT count in totals / tabs / badges. Only
+ * `state === 'firing'` incidents are counted. */
+const rangeIncidents = computed<AlarmIncident[]>(() =>
+ mergeIncidents(rangeScopedAlarms.value),
+);
-/** Fetch the trailing TRAFFIC_DELTA_MS window and merge — used by
- * the Refresh button. Cache for older buckets is reused. */
-async function loadTrafficDelta(): Promise<void> {
- trafficError.value = null;
- trafficLoading.value = true;
- try {
- const start = Math.max(startTime.value, endTime.value - TRAFFIC_DELTA_MS);
- await fetchTrafficChunk(start, endTime.value);
- // Trim cache points that fell out of the visible window so the
- // map doesn't grow unbounded across many refreshes.
- for (const cached of trafficCache.value.values()) {
- for (const ts of Array.from(cached.points.keys())) {
- if (ts < startTime.value) cached.points.delete(ts);
- }
- }
- } catch (err) {
- trafficError.value = err instanceof Error ? err.message : String(err);
- } finally {
- trafficLoading.value = false;
+const countsByLayer = computed<Map<string, number>>(() => {
+ const m = new Map<string, number>();
+ for (const inc of rangeIncidents.value) {
+ if (inc.state !== 'firing') continue;
+ const k = inc.layerKey ?? 'OTHER';
+ m.set(k, (m.get(k) ?? 0) + 1);
}
-}
-
-// ── Derived data ─────────────────────────────────────────────────────
-const alarms = computed<AlarmMessage[]>(() => alarmsQuery.data.value?.msgs ?? []);
-
-const firingCount = computed<number>(
- () => alarms.value.filter((a) => a.recoveryTime === null).length,
-);
-const totalCount = computed<number>(() => alarms.value.length);
-const layersWithAlarms = computed<number>(
- () => new Set(alarms.value.map((a) => a.layerKey ?? 'other')).size,
+ return m;
+});
+const totalCount = computed<number>(
+ () => rangeIncidents.value.filter((i) => i.state === 'firing').length,
);
-
-/* Derive chart-shaped series from the per-layer cache. Sorted by ts
- * within each layer; filtered to the current visible window so old
- * cache buckets don't bleed into the chart after a window-size
- * change. */
-const trafficSeries = computed<AlarmTrafficSeries[]>(() => {
- const out: AlarmTrafficSeries[] = [];
- const s = startTime.value;
- const e = endTime.value;
- for (const cached of trafficCache.value.values()) {
- const tsList = Array.from(cached.points.keys()).sort((a, b) => a - b);
- const points: AlarmTrafficPoint[] = [];
- for (const ts of tsList) {
- if (ts >= s && ts <= e) {
- points.push({ ts, value: cached.points.get(ts) ?? null });
- }
- }
- out.push({
- layerKey: cached.layerKey,
- label: cached.label,
- present: cached.present,
- error: cached.error,
- points,
- });
+/** Pinned layers always render in the header even when count = 0. */
+const pinnedKpis = computed<Array<{ key: string; label: string; count: number }>>(() => {
+ return pinnedLayers.value.map((k) => ({
+ key: k,
+ label: prettyLayer(k),
+ count: countsByLayer.value.get(k) ?? 0,
+ }));
+});
+/** Non-pinned layers with at least one active incident, descending
+ * by count. Recovered-only layers drop out — "no alarm as number". */
+const overflowChips = computed<Array<{ key: string; label: string; count: number }>>(() => {
+ const pinned = new Set(pinnedLayers.value);
+ const out: Array<{ key: string; label: string; count: number }> = [];
+ for (const [k, n] of countsByLayer.value.entries()) {
+ if (pinned.has(k)) continue;
+ if (n === 0) continue;
+ out.push({ key: k, label: prettyLayer(k), count: n });
}
+ out.sort((a, b) => b.count - a.count);
return out;
});
-/* Chart x-axis spans the un-rounded request window — not the BFF's
- * minute-floored echo. Using the user-requested bounds means the
- * axis always reads as the picker (1h / 6h / 24h) even if OAP only
- * has partial data, so the operator's mental model of "where in
- * time am I looking?" stays stable. */
-const trafficStart = computed<number>(() => startTime.value);
-const trafficEnd = computed<number>(() => endTime.value);
-/** Filtered list — narrows by the current selection (range or alarm). */
-const filteredAlarms = computed<AlarmMessage[]>(() => {
- if (selectedRange.value) {
- const { startTime: s, endTime: e } = selectedRange.value;
- return alarms.value.filter((a) => a.startTime >= s && a.startTime <= e);
+/** Row list uses the LIST-flavored merge: each firing event is its
+ * own row (every active firing matters for triage), recovered
+ * events for the same (entity, rule) collapse to a single
+ * "triggered N× · recovered" row (just noise — one summary line
+ * per group is enough). Counts above still use `mergeIncidents`
+ * so the chip totals stay incident-shaped. */
+const listEntries = computed<AlarmIncident[]>(() => splitForList(rangeScopedAlarms.value));
+const filteredIncidents = computed<AlarmIncident[]>(() => {
+ let rows = listEntries.value;
+ if (chipLayer.value) {
+ rows = rows.filter((i) => (i.layerKey ?? 'OTHER') === chipLayer.value);
}
- return alarms.value;
+ return rows;
+});
+
+/** Data feed for the timeline chart — chip-aware, range-IGNORANT.
+ * Brushing must not hide data outside the brush, otherwise the
+ * operator can't see other peaks to rebrush onto. The brush
+ * rectangle is the only visual marker for the selection. The
+ * timeline keeps every raw event (NOT incidents) so the firing /
+ * recovered pattern is fully visible. */
+const timelineAlarms = computed<AlarmMessage[]>(() => {
+ if (!chipLayer.value) return alarms.value;
+ return alarms.value.filter((a) => (a.layerKey ?? 'OTHER') === chipLayer.value);
});
const selectedAlarm = computed<AlarmMessage | null>(() => {
@@ -412,40 +413,50 @@ const selectedAlarm = computed<AlarmMessage | null>(() => {
return alarms.value.find((a) => keyFor(a) === k) ?? null;
});
-/** Group filtered alarms by layer (or "Other" when the BFF couldn't
- * resolve the entity's layer). Sorted descending by firing count
- * then total count so the noisiest layer floats to the top. */
-interface LayerGroup {
+// ── List tabs (by layer) ───────────────────────────────────────────
+/* Tab strip above the row list: "All · pinned-1 · pinned-2 · …
+ * overflow chips". The "All" tab maps to no layer filter; every
+ * other tab maps to its layer key via `chipLayer`. We render the
+ * same set of layers that the header KPIs + overflow chips show, so
+ * the two surfaces stay consistent — different entry points to the
+ * same `chipLayer` URL state. */
+interface ListTab {
key: string;
label: string;
- firing: number;
- alarms: AlarmMessage[];
-}
-const grouped = computed<LayerGroup[]>(() => {
- const map = new Map<string, AlarmMessage[]>();
- for (const a of filteredAlarms.value) {
- const k = a.layerKey ?? 'OTHER';
- if (!map.has(k)) map.set(k, []);
- map.get(k)!.push(a);
+ count: number;
+}
+const listTabs = computed<ListTab[]>(() => {
+ /* "All" uses `totalCount` (range-scoped), not `alarms.value.length`,
+ * so it stays in sync with the per-layer counts after a brush. */
+ const tabs: ListTab[] = [{ key: '', label: 'All', count: totalCount.value }];
+ for (const k of pinnedLayers.value) {
+ tabs.push({ key: k, label: prettyLayer(k), count: countsByLayer.value.get(k) ?? 0 });
}
- const groups: LayerGroup[] = [];
- for (const [key, list] of map.entries()) {
- const firing = list.filter((a) => a.recoveryTime === null).length;
- list.sort((a, b) => b.startTime - a.startTime);
- groups.push({
- key,
- label: prettyLayer(key),
- firing,
- alarms: list,
- });
+ for (const c of overflowChips.value) {
+ tabs.push({ key: c.key, label: c.label, count: c.count });
}
- groups.sort((a, b) => b.firing - a.firing || b.alarms.length - a.alarms.length);
- return groups;
+ return tabs;
+});
+
+// ── Frontend paging ────────────────────────────────────────────────
+const PAGE_SIZE = 10;
+const page = ref<number>(1);
+const totalPages = computed<number>(
+ () => Math.max(1, Math.ceil(filteredIncidents.value.length / PAGE_SIZE)),
+);
+const pagedIncidents = computed<AlarmIncident[]>(() => {
+ const start = (page.value - 1) * PAGE_SIZE;
+ return filteredIncidents.value.slice(start, start + PAGE_SIZE);
+});
+
+watch([filteredIncidents, chipLayer, startTime, endTime], () => {
+ page.value = 1;
});
-function prettyLayer(key: string): string {
- if (key === 'OTHER') return 'Other';
- return key
+// ── Misc utils ─────────────────────────────────────────────────────
+function prettyLayer(k: string): string {
+ if (k === 'OTHER') return 'Other';
+ return k
.toLowerCase()
.split('_')
.map((w) => (w.length > 0 ? w[0]!.toUpperCase() + w.slice(1) : ''))
@@ -465,38 +476,32 @@ function formatRelative(ts: number): string {
return `${d}d ${h % 24}h ago`;
}
+function formatWindowLabel(): string {
+ if (windowMode.value === 'custom') {
+ return `${new Date(customStart.value).toLocaleString()} → ${new Date(customEnd.value).toLocaleString()}`;
+ }
+ return `last ${windowMode.value}`;
+}
+
watch([startTime, endTime], () => {
- // Window change clears any time-range brush — the brush is bound to
- // the previous window's coordinates so it'd be meaningless after a
- // resize. Alarm selection survives.
selectedRange.value = null;
});
-// ── Lifecycle / refresh wiring ──────────────────────────────────────
-// Auto-fetch on mount; reload traffic in full whenever the operator
-// changes the window size (1h/6h/24h). The Refresh button below
-// advances `windowEndAt` to NOW and fires the trailing-1h delta.
-onMounted(() => {
- void loadTrafficFull();
-});
-
-watch(windowChoice, () => {
- windowEndAt.value = Date.now();
- void loadTrafficFull();
-});
-
const refreshing = ref(false);
async function onRefresh(): Promise<void> {
if (refreshing.value) return;
refreshing.value = true;
try {
- windowEndAt.value = Date.now();
- // Traffic: delta-merge last TRAFFIC_DELTA_MS only.
- await Promise.all([loadTrafficDelta(), alarmsQuery.refetch()]);
+ if (windowMode.value !== 'custom') windowEndAt.value = Date.now();
+ await alarmsQuery.refetch();
} finally {
refreshing.value = false;
}
}
+
+onMounted(() => {
+ /* Nothing to do here — vue-query fires on mount. */
+});
</script>
<template>
@@ -506,46 +511,101 @@ async function onRefresh(): Promise<void> {
<div class="ax__kicker">Alarms</div>
<h1 class="ax__h1">Active alarms</h1>
<p class="ax__lede">
- Background lines show RPM per configured layer. Markers anchor at each alarm's start
- time. Click a marker to inspect a single alarm; brush a region on the timeline to list
- alarms in that window. Configure the layers + MQEs in
+ {{ formatWindowLabel() }}. Pinned layers come from
<RouterLink to="/admin/alert-page-setup">Alert page setup</RouterLink>.
+ Click a layer chip to narrow the list; click a flag on the timeline to inspect one
+ alarm; brush a region to slice the list to that window.
</p>
</div>
<div class="ax__header-actions">
<div class="ax__window">
<button
- v-for="w in (['1h', '6h', '24h'] as WindowKey[])"
- :key="w"
+ v-for="p in PRESETS"
+ :key="p"
type="button"
class="ax__window-btn"
- :class="{ active: windowChoice === w }"
- @click="windowChoice = w"
- >{{ w }}</button>
+ :class="{ active: windowMode === p }"
+ @click="pickPreset(p)"
+ >{{ p }}</button>
+ <button
+ type="button"
+ class="ax__window-btn"
+ :class="{ active: windowMode === 'custom' }"
+ @click="openCustom"
+ >custom</button>
</div>
<button
type="button"
class="ax__refresh"
- :disabled="refreshing || trafficLoading"
+ :disabled="refreshing"
@click="onRefresh"
- >{{ refreshing || trafficLoading ? 'refreshing…' : 'refresh' }}</button>
+ >{{ refreshing ? 'refreshing…' : 'refresh' }}</button>
</div>
</header>
- <!-- ── Filters ──────────────────────────────────────────────────── -->
- <!-- Cascading: layer → service → {instance, endpoint}. Each tier
- is disabled until its parent is set; changing a parent clears
- all descendants so a stale child can't survive. Nothing
- actually fires the alarms query until the operator hits Apply
- — typing / picking just composes the draft. -->
- <div class="ax__filters">
+ <!-- Custom range editor — sits under the picker; closes on apply. -->
+ <div v-if="customOpen" class="ax__custom">
+ <label class="ax__custom-field">
+ <span>Start</span>
+ <input v-model="customStartInput" type="datetime-local" step="60" />
+ </label>
+ <label class="ax__custom-field">
+ <span>End</span>
+ <input v-model="customEndInput" type="datetime-local" step="60" />
+ </label>
+ <div v-if="customError" class="ax__custom-err">{{ customError }}</div>
+ <div class="ax__custom-actions">
+ <span class="ax__custom-hint">max {{ MAX_CUSTOM_MS / 60_000 / 60 }}h</span>
+ <button type="button" class="ax__custom-btn" @click="customOpen = false">cancel</button>
+ <button type="button" class="ax__custom-btn ax__custom-btn--primary" @click="applyCustom">apply</button>
+ </div>
+ </div>
+
+ <!-- ── KPI strip: total + pinned + overflow chips ─────────────── -->
+ <div class="ax__kpis">
+ <button
+ type="button"
+ class="ax__kpi ax__kpi--total"
+ :class="{ active: !chipLayer }"
+ @click="chipLayer = ''"
+ >
+ <div class="ax__kpi-label">Active</div>
+ <div class="ax__kpi-val" :class="{ 'ax__kpi-val--err': totalCount > 0 }">{{ totalCount }}</div>
+ <div class="ax__kpi-sub">{{ rangeIncidents.length }} incident{{ rangeIncidents.length === 1 ? '' : 's' }}</div>
+ </button>
+ <button
+ v-for="k in pinnedKpis"
+ :key="k.key"
+ type="button"
+ class="ax__kpi"
+ :class="{ active: chipLayer === k.key }"
+ @click="selectChip(k.key)"
+ >
+ <div class="ax__kpi-label">{{ k.label }}</div>
+ <div class="ax__kpi-val" :class="{ 'ax__kpi-val--err': k.count > 0 }">{{ k.count }}</div>
+ </button>
+ <div v-if="overflowChips.length > 0" class="ax__chips">
+ <button
+ v-for="c in overflowChips"
+ :key="c.key"
+ type="button"
+ class="ax__chip"
+ :class="{ active: chipLayer === c.key }"
+ @click="selectChip(c.key)"
+ >
+ <span class="ax__chip-label">{{ c.label }}</span>
+ <span class="ax__chip-count mono">{{ c.count }}</span>
+ </button>
+ </div>
+ </div>
+
+ <!-- ── Filter row — conditional on capabilities.queryAlarms ──── -->
+ <div v-if="hasQueryAlarms" class="ax__filters">
<label class="ax__filter">
<span>Layer</span>
<select v-model="draft.layer" @change="onLayerChange">
<option value="">any layer</option>
- <option v-for="L in availableLayers" :key="L.key" :value="L.key.toUpperCase()">
- {{ L.name }}
- </option>
+ <option v-for="L in availableLayers" :key="L.key" :value="L.key.toUpperCase()">{{ L.name }}</option>
</select>
</label>
<label class="ax__filter" :class="{ 'is-disabled': !draft.layer }">
@@ -575,6 +635,10 @@ async function onRefresh(): Promise<void> {
<option v-for="name in endpointOptions" :key="name" :value="name">{{ name }}</option>
</select>
</label>
+ <label class="ax__filter ax__filter--wide">
+ <span>Keyword</span>
+ <input v-model="draft.keyword" type="text" placeholder="match alarm message" />
+ </label>
<button
type="button"
class="ax__filter-apply"
@@ -583,126 +647,137 @@ async function onRefresh(): Promise<void> {
@click="applyFilters"
>{{ isDirty ? 'apply' : 'applied' }}</button>
<button
- v-if="hasFilter"
+ v-if="applied.layer || applied.service || applied.instance || applied.endpoint || applied.keyword"
type="button"
class="ax__filter-clear"
@click="clearFilters"
>clear</button>
</div>
-
- <!-- ── KPI strip ────────────────────────────────────────────────── -->
- <div class="ax__kpis">
- <div class="ax__kpi">
- <div class="ax__kpi-label">Firing</div>
- <div class="ax__kpi-val ax__kpi-val--err">{{ firingCount }}</div>
- </div>
- <div class="ax__kpi">
- <div class="ax__kpi-label">Window total</div>
- <div class="ax__kpi-val">{{ totalCount }}</div>
- </div>
- <div class="ax__kpi">
- <div class="ax__kpi-label">Layers affected</div>
- <div class="ax__kpi-val">{{ layersWithAlarms }}</div>
- </div>
- <div class="ax__kpi">
- <div class="ax__kpi-label">Selection</div>
- <div class="ax__kpi-val ax__kpi-val--small">
- <template v-if="selectedAlarm">{{ selectedAlarm.id }}</template>
- <template v-else-if="selectedRange">
- {{ Math.round((selectedRange.endTime - selectedRange.startTime) / 60_000) }}m window
- </template>
- <template v-else>—</template>
- </div>
- </div>
+ <div v-else class="ax__filters ax__filters--legacy">
+ <label class="ax__filter ax__filter--wide">
+ <span>Keyword</span>
+ <input v-model="draft.keyword" type="text" placeholder="match alarm message" />
+ </label>
+ <button
+ type="button"
+ class="ax__filter-apply"
+ :class="{ 'is-dirty': isDirty }"
+ :disabled="!isDirty"
+ @click="applyFilters"
+ >{{ isDirty ? 'apply' : 'applied' }}</button>
+ <span class="ax__legacy-note">
+ This OAP version supports keyword + tag filters only. Upgrade for layer + entity filters.
+ </span>
</div>
- <!-- ── Timeline ─────────────────────────────────────────────────── -->
+ <!-- ── Timeline (flags only) ─────────────────────────────────── -->
<section class="ax__panel">
<header class="ax__panel-head">
<h3>Timeline</h3>
- <span v-if="trafficLoading || alarmsQuery.isFetching.value" class="ax__refreshing">
- {{ trafficLoading ? 'loading traffic…' : 'loading alarms…' }}
+ <button
+ type="button"
+ class="ax__panel-reset"
+ :disabled="!selectedRange && !selectedAlarmKey"
+ title="Clear the selected time range / alarm"
+ @click="clearSelection"
+ >reset</button>
+ <span v-if="alarmsQuery.isFetching.value" class="ax__refreshing">loading…</span>
+ <span v-else-if="truncated" class="ax__panel-warn">
+ showing {{ totalCount }} rows (page may be truncated — tighten the window)
</span>
- <span v-else-if="trafficError" class="ax__panel-err">{{ trafficError }}</span>
</header>
<AlarmsTimeline
- :traffic="trafficSeries"
- :alarms="alarms"
- :start-time="trafficStart"
- :end-time="trafficEnd"
- :selected-alarm-key="selectedAlarmKey"
- :height="220"
- @select-alarm="selectAlarm"
+ :alarms="timelineAlarms"
+ :start-time="startTime"
+ :end-time="endTime"
+ :selected-range="selectedRange"
+ :height="110"
@select-time-range="selectRange"
@clear-selection="clearSelection"
/>
</section>
- <!-- ── Grouped list + detail ───────────────────────────────────── -->
+ <!-- ── List + detail ─────────────────────────────────────────── -->
<section class="ax__split">
<div class="ax__list">
- <header class="ax__list-head">
- <h3>
- {{ filteredAlarms.length }} alarm{{ filteredAlarms.length === 1 ? '' : 's' }}
- <template v-if="selectedRange">
- in selection
- </template>
- <template v-else>
- in window
- </template>
- </h3>
+ <!-- Layer tabs — same `chipLayer` state as the header KPIs, so
+ clicking a tab and clicking a header chip do the same
+ thing. URL `?layer=…` persists either selection. -->
+ <div v-if="listTabs.length > 1" class="ax__tabs" role="tablist">
<button
- v-if="selectedRange || selectedAlarmKey"
+ v-for="t in listTabs"
+ :key="t.key || '_all'"
type="button"
- class="ax__clear-sel"
- @click="clearSelection"
- >clear selection</button>
- </header>
+ role="tab"
+ class="ax__tab"
+ :class="{ active: chipLayer === t.key }"
+ :aria-selected="chipLayer === t.key"
+ @click="chipLayer = t.key"
+ >
+ <span class="ax__tab-label">{{ t.label }}</span>
+ <span class="ax__tab-count mono">{{ t.count }}</span>
+ </button>
+ </div>
<div v-if="alarmsQuery.isPending.value" class="ax__empty">loading…</div>
- <div v-else-if="grouped.length === 0" class="ax__empty">No alarms in the current window.</div>
+ <div v-else-if="filteredIncidents.length === 0" class="ax__empty">
+ No alarms in the current window.
+ </div>
- <section
- v-for="g in grouped"
- :key="g.key"
- class="ax__group"
- >
- <header class="ax__group-head">
- <span class="ax__group-label">{{ g.label }}</span>
- <span v-if="g.firing > 0" class="sw-badge is-err">
- <span class="state-dot" />{{ g.firing }} firing
- </span>
- <span class="ax__group-count">{{ g.alarms.length }}</span>
- </header>
- <ul class="ax__rows">
- <li
- v-for="a in g.alarms"
- :key="keyFor(a)"
- class="ax__row"
- :class="{ active: keyFor(a) === selectedAlarmKey, resolved: a.recoveryTime !== null }"
- @click="selectAlarm(keyFor(a))"
- >
- <span
- class="ax__sev"
- :class="a.recoveryTime === null ? 'is-err' : 'is-ok'"
- />
- <div class="ax__row-main">
- <div class="ax__row-msg">{{ a.message }}</div>
- <div class="ax__row-meta">
- <code>{{ a.name }}</code>
- <span v-if="a.scope" class="ax__row-tag">{{ a.scope }}</span>
- <span class="ax__row-time">{{ formatRelative(a.startTime) }}</span>
- </div>
+ <!-- One row per (entity, rule) incident. Click selects the
+ incident's LATEST event for the detail panel — that's the
+ one with the freshest snapshot. Incidents with N>1
+ firings show a "triggered N times" subnote. -->
+ <ul v-else class="ax__rows">
+ <li
+ v-for="inc in pagedIncidents"
+ :key="inc.id"
+ class="ax__row"
+ :class="{ active: keyFor(inc.latest) === selectedAlarmKey, resolved: inc.state === 'recovered' }"
+ @click="selectAlarm(keyFor(inc.latest))"
+ >
+ <span class="ax__sev" :class="inc.state === 'firing' ? 'is-err' : 'is-ok'" />
+ <div class="ax__row-main">
+ <div class="ax__row-entity">
+ <span class="ax__row-kind">{{ formatAlarmEntity(inc.latest.scope, inc.latest.name).prefix }}</span>
+ <code class="ax__row-entity-name">
+ <template v-for="(s, i) in formatAlarmEntity(inc.latest.scope, inc.latest.name).segments" :key="i">
+ <template v-if="s.kind === 'group'">
+ <span class="ax__row-entity-group">{{ s.group }}</span><span>{{ s.base }}</span>
+ </template>
+ <span v-else>{{ s.text }}</span>
+ </template>
+ </code>
+ </div>
+ <div class="ax__row-msg">{{ inc.latest.message }}</div>
+ <div class="ax__row-meta">
+ <span v-if="inc.latest.layerKey" class="ax__row-tag">{{ prettyLayer(inc.latest.layerKey) }}</span>
+ <span v-else class="ax__row-tag ax__row-tag--other">Other</span>
+ <span class="ax__row-time">{{ formatRelative(inc.latest.startTime) }}</span>
</div>
- <span
- class="sw-badge"
- :class="a.recoveryTime === null ? 'is-err' : 'is-ok'"
- >
- <span class="state-dot" />{{ a.recoveryTime === null ? 'firing' : 'recovered' }}
- </span>
- </li>
- </ul>
- </section>
+ </div>
+ <span class="sw-badge" :class="inc.state === 'firing' ? 'is-err' : 'is-ok'">
+ <span class="state-dot" />{{ inc.state }}
+ </span>
+ </li>
+ </ul>
+
+ <!-- Frontend pager — only when there's more than one page. -->
+ <nav v-if="totalPages > 1" class="ax__pager">
+ <button
+ type="button"
+ class="ax__pager-btn"
+ :disabled="page <= 1"
+ @click="page = page - 1"
+ >‹ prev</button>
+ <span class="ax__pager-pos mono">page {{ page }} / {{ totalPages }}</span>
+ <button
+ type="button"
+ class="ax__pager-btn"
+ :disabled="page >= totalPages"
+ @click="page = page + 1"
+ >next ›</button>
+ </nav>
</div>
<AlarmDetailPanel :alarm="selectedAlarm" />
@@ -722,9 +797,7 @@ async function onRefresh(): Promise<void> {
gap: 16px;
margin-bottom: 16px;
}
-.ax__head > div:first-child {
- flex: 1;
-}
+.ax__head > div:first-child { flex: 1; }
.ax__kicker {
font-size: 10px;
text-transform: uppercase;
@@ -746,13 +819,9 @@ async function onRefresh(): Promise<void> {
margin: 0;
max-width: 820px;
}
-.ax__lede a {
- color: var(--sw-accent);
- text-decoration: none;
-}
-.ax__lede a:hover {
- text-decoration: underline;
-}
+.ax__lede a { color: var(--sw-accent); text-decoration: none; }
+.ax__lede a:hover { text-decoration: underline; }
+
.ax__header-actions {
display: flex;
gap: 8px;
@@ -776,9 +845,7 @@ async function onRefresh(): Promise<void> {
border-radius: 4px;
cursor: pointer;
}
-.ax__window-btn:hover {
- color: var(--sw-fg-0);
-}
+.ax__window-btn:hover { color: var(--sw-fg-0); }
.ax__window-btn.active {
background: var(--sw-bg-3);
color: var(--sw-fg-0);
@@ -798,16 +865,161 @@ async function onRefresh(): Promise<void> {
background: var(--sw-bg-2);
border-color: var(--sw-accent);
}
-.ax__refresh:disabled {
- opacity: 0.55;
- cursor: not-allowed;
+.ax__refresh:disabled { opacity: 0.55; cursor: not-allowed; }
+
+/* Custom date range editor */
+.ax__custom {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 8px;
+ margin-bottom: 12px;
+}
+.ax__custom-field {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+.ax__custom-field input {
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line);
+ color: var(--sw-fg-0);
+ font: inherit;
+ font-size: 12px;
+ padding: 4px 6px;
+ border-radius: 4px;
+}
+.ax__custom-err {
+ color: var(--sw-err);
+ font-size: 11px;
+}
+.ax__custom-actions {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.ax__custom-hint {
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+}
+.ax__custom-btn {
+ background: transparent;
+ border: 1px solid var(--sw-line-2);
+ color: var(--sw-fg-1);
+ font: inherit;
+ font-size: 11.5px;
+ padding: 4px 12px;
+ border-radius: 4px;
+ cursor: pointer;
+}
+.ax__custom-btn--primary {
+ background: var(--sw-accent);
+ border-color: var(--sw-accent);
+ color: #0a0d12;
+ font-weight: 600;
}
-/* Filter row — chip-style: dark surface, compact label inside the
- * chip, no native select chevron. Each chip is a fixed-width tile
- * with the label as a quiet kicker above the value. Disabled state
- * dims the chip and hides the caret so operators can see at a glance
- * which selectors are gated. */
+/* KPI strip — total + pinned + overflow */
+.ax__kpis {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: stretch;
+ gap: 10px;
+ margin-bottom: 14px;
+}
+.ax__kpi {
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 8px;
+ padding: 10px 16px;
+ min-width: 120px;
+ cursor: pointer;
+ font: inherit;
+ text-align: left;
+ transition: border-color 0.1s ease;
+}
+.ax__kpi:hover { border-color: var(--sw-line-2); }
+.ax__kpi.active {
+ border-color: var(--sw-accent);
+ box-shadow: inset 0 0 0 1px var(--sw-accent);
+}
+.ax__kpi--total {
+ border-color: var(--sw-line-2);
+}
+.ax__kpi-label {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sw-fg-3);
+ font-weight: 600;
+}
+.ax__kpi-val {
+ font-size: 24px;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ color: var(--sw-fg-0);
+ margin-top: 2px;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.1;
+}
+.ax__kpi-val--err { color: var(--sw-err); }
+.ax__kpi-sub {
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+ margin-top: 2px;
+}
+.ax__chips {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ padding: 10px 4px;
+ margin-left: auto;
+ max-width: 600px;
+}
+.ax__chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ color: var(--sw-fg-1);
+ font: inherit;
+ font-size: 11.5px;
+ padding: 4px 10px;
+ border-radius: 12px;
+ cursor: pointer;
+}
+.ax__chip:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.ax__chip.active {
+ background: var(--sw-accent-soft);
+ border-color: var(--sw-accent);
+ color: var(--sw-accent-2);
+}
+.ax__chip-label { font-weight: 500; }
+.ax__chip-count {
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+ background: var(--sw-bg-2);
+ padding: 0 5px;
+ border-radius: 8px;
+ font-variant-numeric: tabular-nums;
+}
+.ax__chip.active .ax__chip-count {
+ background: var(--sw-bg-1);
+ color: var(--sw-accent-2);
+}
+
+/* Filter row (re-used styles from the prior view) */
.ax__filters {
display: flex;
flex-wrap: wrap;
@@ -823,24 +1035,17 @@ async function onRefresh(): Promise<void> {
display: flex;
flex-direction: column;
gap: 2px;
- min-width: 180px;
+ min-width: 170px;
padding: 6px 10px;
background: var(--sw-bg-2);
border: 1px solid var(--sw-line);
border-radius: 5px;
- transition: border-color 0.1s ease;
cursor: pointer;
}
-.ax__filter:hover:not(.is-disabled) {
- border-color: var(--sw-line-3, var(--sw-line-2));
-}
-.ax__filter:focus-within:not(.is-disabled) {
- border-color: var(--sw-accent);
-}
-.ax__filter.is-disabled {
- opacity: 0.45;
- cursor: not-allowed;
-}
+.ax__filter--wide { flex: 1; min-width: 220px; }
+.ax__filter:hover:not(.is-disabled) { border-color: var(--sw-line-2); }
+.ax__filter:focus-within:not(.is-disabled) { border-color: var(--sw-accent); }
+.ax__filter.is-disabled { opacity: 0.45; cursor: not-allowed; }
.ax__filter > span {
font-size: 9.5px;
text-transform: uppercase;
@@ -848,28 +1053,29 @@ async function onRefresh(): Promise<void> {
color: var(--sw-fg-3);
font-weight: 600;
}
-.ax__filter select {
+.ax__filter select,
+.ax__filter input[type='text'] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
- background: transparent
- url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' width='10' height='6'><path d='M1 1l4 4 4-4' stroke='%23818a9c' stroke-width='1.4' fill='none' stroke-linecap='round'/></svg>")
- right 2px center / 9px no-repeat;
+ background: transparent;
border: 0;
color: var(--sw-fg-0);
font: inherit;
font-size: 12px;
- padding: 1px 16px 1px 0;
+ padding: 1px 0;
margin: 0;
width: 100%;
- cursor: pointer;
outline: none;
}
-.ax__filter select:disabled {
- cursor: not-allowed;
- background-image: none;
- color: var(--sw-fg-2);
+.ax__filter select {
+ cursor: pointer;
+ padding-right: 16px;
+ background: transparent
+ url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' width='10' height='6'><path d='M1 1l4 4 4-4' stroke='%23818a9c' stroke-width='1.4' fill='none' stroke-linecap='round'/></svg>")
+ right 2px center / 9px no-repeat;
}
+.ax__filter select:disabled { cursor: not-allowed; background-image: none; color: var(--sw-fg-2); }
.ax__filter-apply {
align-self: stretch;
display: inline-flex;
@@ -884,7 +1090,6 @@ async function onRefresh(): Promise<void> {
border-radius: 5px;
cursor: pointer;
margin-left: auto;
- transition: all 0.1s ease;
}
.ax__filter-apply.is-dirty {
background: var(--sw-accent);
@@ -892,12 +1097,7 @@ async function onRefresh(): Promise<void> {
color: #0a0d12;
font-weight: 600;
}
-.ax__filter-apply.is-dirty:hover {
- background: var(--sw-accent-light, #fb923c);
-}
-.ax__filter-apply:disabled {
- cursor: default;
-}
+.ax__filter-apply:disabled { cursor: default; }
.ax__filter-clear {
align-self: stretch;
display: inline-flex;
@@ -911,48 +1111,15 @@ async function onRefresh(): Promise<void> {
border-radius: 5px;
cursor: pointer;
}
-.ax__filter-clear:hover {
- background: var(--sw-bg-2);
- color: var(--sw-fg-0);
- border-style: solid;
- border-color: var(--sw-line-2);
-}
-
-.ax__kpis {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 10px;
- margin-bottom: 14px;
-}
-.ax__kpi {
- background: var(--sw-bg-1);
- border: 1px solid var(--sw-line);
- border-radius: 8px;
- padding: 10px 14px;
-}
-.ax__kpi-label {
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.08em;
+.ax__filter-clear:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); border-style: solid; }
+.ax__filters--legacy { align-items: center; gap: 12px; }
+.ax__legacy-note {
+ font-size: 11px;
color: var(--sw-fg-3);
-}
-.ax__kpi-val {
- font-size: 22px;
- font-weight: 600;
- letter-spacing: -0.02em;
- color: var(--sw-fg-0);
- margin-top: 2px;
- font-variant-numeric: tabular-nums;
-}
-.ax__kpi-val--err {
- color: var(--sw-err);
-}
-.ax__kpi-val--small {
- font-size: 12.5px;
- font-family: var(--sw-mono);
- font-weight: 500;
+ font-style: italic;
}
+/* Panel + timeline */
.ax__panel {
background: var(--sw-bg-1);
border: 1px solid var(--sw-line);
@@ -975,60 +1142,35 @@ async function onRefresh(): Promise<void> {
letter-spacing: 0.08em;
margin: 0;
}
-.ax__refreshing {
- margin-left: auto;
- font-size: 11px;
- color: var(--sw-fg-3);
- font-style: italic;
-}
-.ax__panel-err {
- margin-left: auto;
- font-size: 11px;
- color: var(--sw-err);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 60%;
-}
-
-.ax__split {
- display: grid;
- grid-template-columns: 1fr 360px;
- gap: 16px;
-}
-.ax__list {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-.ax__list-head {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-.ax__list-head h3 {
- font-size: 12px;
- font-weight: 600;
- color: var(--sw-fg-1);
- text-transform: uppercase;
- letter-spacing: 0.08em;
- margin: 0;
-}
-.ax__clear-sel {
+.ax__refreshing { margin-left: auto; font-size: 11px; color: var(--sw-fg-3); font-style: italic; }
+.ax__panel-warn { margin-left: auto; font-size: 11px; color: var(--sw-warn); }
+.ax__panel-reset {
background: transparent;
border: 1px solid var(--sw-line-2);
color: var(--sw-fg-2);
font: inherit;
- font-size: 11px;
- padding: 3px 10px;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
- margin-left: auto;
}
-.ax__clear-sel:hover {
+.ax__panel-reset:not(:disabled):hover {
+ border-color: rgba(239, 68, 68, 0.4);
+ color: var(--sw-err);
background: var(--sw-bg-2);
- color: var(--sw-fg-0);
}
+.ax__panel-reset:disabled { opacity: 0.35; cursor: not-allowed; }
+
+/* Split list / detail */
+.ax__split {
+ display: grid;
+ grid-template-columns: 1fr 360px;
+ gap: 16px;
+}
+.ax__list { display: flex; flex-direction: column; gap: 12px; }
.ax__empty {
padding: 24px;
text-align: center;
@@ -1038,37 +1180,60 @@ async function onRefresh(): Promise<void> {
border: 1px dashed var(--sw-line);
border-radius: 8px;
}
-.ax__group {
+/* Layer tab strip — sits between the list header and the rows.
+ * Tabs share state with the header KPI chips (both write `chipLayer`),
+ * so visual sync is automatic. */
+.ax__tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px;
+ padding: 4px;
background: var(--sw-bg-1);
border: 1px solid var(--sw-line);
border-radius: 8px;
- overflow: hidden;
+ margin-bottom: -4px; /* visually attach to the rows below */
}
-.ax__group-head {
- display: flex;
+.ax__tab {
+ display: inline-flex;
align-items: center;
- gap: 8px;
- padding: 8px 14px;
- background: var(--sw-bg-2);
- border-bottom: 1px solid var(--sw-line);
+ gap: 6px;
+ background: transparent;
+ border: 0;
+ color: var(--sw-fg-2);
+ font: inherit;
+ font-size: 11.5px;
+ padding: 5px 10px;
+ border-radius: 5px;
+ cursor: pointer;
}
-.ax__group-label {
- font-size: 12px;
+.ax__tab:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.ax__tab.active {
+ background: var(--sw-accent-soft);
+ color: var(--sw-accent-2);
font-weight: 600;
- color: var(--sw-fg-0);
- flex: 1;
}
-.ax__group-count {
- font-size: 11px;
+.ax__tab-label { line-height: 1; }
+.ax__tab-count {
+ font-size: 10px;
color: var(--sw-fg-3);
+ background: var(--sw-bg-2);
+ padding: 1px 6px;
+ border-radius: 8px;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.4;
+}
+.ax__tab.active .ax__tab-count {
background: var(--sw-bg-1);
- border-radius: 10px;
- padding: 1px 8px;
+ color: var(--sw-accent-2);
}
.ax__rows {
list-style: none;
margin: 0;
padding: 0;
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 8px;
+ overflow: hidden;
}
.ax__row {
display: flex;
@@ -1079,39 +1244,64 @@ async function onRefresh(): Promise<void> {
cursor: pointer;
transition: background 0.1s ease;
}
-.ax__row:last-child {
- border-bottom: none;
-}
-.ax__row:hover {
- background: var(--sw-bg-2);
-}
-.ax__row.active {
- background: var(--sw-bg-3);
- box-shadow: inset 2px 0 0 var(--sw-accent);
-}
-.ax__row.resolved {
- opacity: 0.65;
-}
+.ax__row:last-child { border-bottom: none; }
+.ax__row:hover { background: var(--sw-bg-2); }
+.ax__row.active { background: var(--sw-bg-3); box-shadow: inset 2px 0 0 var(--sw-accent); }
+.ax__row.resolved { opacity: 0.65; }
.ax__sev {
width: 3px;
height: 26px;
border-radius: 2px;
flex-shrink: 0;
}
-.ax__sev.is-err {
- background: var(--sw-err);
-}
-.ax__sev.is-ok {
- background: var(--sw-ok);
+.ax__sev.is-err { background: var(--sw-err); }
+.ax__sev.is-ok { background: var(--sw-ok); }
+.ax__row-main { flex: 1; min-width: 0; }
+.ax__row-entity {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ margin-bottom: 2px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.ax__row-main {
- flex: 1;
- min-width: 0;
+.ax__row-kind {
+ font-size: 9.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--sw-accent);
+ font-weight: 600;
+ flex-shrink: 0;
}
-.ax__row-msg {
- font-size: 12.5px;
+.ax__row-entity code {
+ font-family: var(--sw-mono);
+ font-size: 11.5px;
color: var(--sw-fg-0);
+ background: transparent;
+ padding: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.ax__row-entity-name { display: inline-flex; align-items: baseline; gap: 0; }
+.ax__row-entity-group {
+ display: inline-block;
+ font-family: var(--sw-mono);
+ font-size: 9px;
font-weight: 500;
+ text-transform: lowercase;
+ color: var(--sw-fg-2);
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line);
+ padding: 0 5px;
+ border-radius: 3px;
+ margin-right: 5px;
+ vertical-align: 1px;
+ line-height: 1.5;
+}
+.ax__row-msg {
+ font-size: 12px;
+ color: var(--sw-fg-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1138,10 +1328,29 @@ async function onRefresh(): Promise<void> {
text-transform: uppercase;
letter-spacing: 0.05em;
}
-.ax__row-time {
- margin-left: auto;
- font-variant-numeric: tabular-nums;
+.ax__row-tag--other { color: var(--sw-fg-3); }
+.ax__row-time { margin-left: auto; font-variant-numeric: tabular-nums; }
+
+.ax__pager {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 10px;
}
+.ax__pager-btn {
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line-2);
+ color: var(--sw-fg-1);
+ font: inherit;
+ font-size: 11.5px;
+ padding: 4px 12px;
+ border-radius: 4px;
+ cursor: pointer;
+}
+.ax__pager-btn:not(:disabled):hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.ax__pager-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+.ax__pager-pos { font-size: 11px; color: var(--sw-fg-2); font-variant-numeric: tabular-nums; }
.sw-badge .state-dot {
width: 6px;
@@ -1152,14 +1361,6 @@ async function onRefresh(): Promise<void> {
display: inline-block;
vertical-align: middle;
}
-.sw-badge.is-ok {
- color: var(--sw-ok);
- background: var(--sw-ok-soft);
- border-color: rgba(34, 197, 94, 0.3);
-}
-.sw-badge.is-err {
- color: var(--sw-err);
- background: var(--sw-err-soft);
- border-color: rgba(239, 68, 68, 0.3);
-}
+.sw-badge.is-ok { color: var(--sw-ok); background: var(--sw-ok-soft); border-color: rgba(34,197,94,0.3); }
+.sw-badge.is-err { color: var(--sw-err); background: var(--sw-err-soft); border-color: rgba(239,68,68,0.3); }
</style>
diff --git a/apps/ui/src/features/operate/alerting-rules/AlertingRulesView.vue b/apps/ui/src/features/operate/alerting-rules/AlertingRulesView.vue
new file mode 100644
index 0000000..054f12b
--- /dev/null
+++ b/apps/ui/src/features/operate/alerting-rules/AlertingRulesView.vue
@@ -0,0 +1,603 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<!--
+ Operate › Alerting Rules. Read-only catalog of every alarm rule
+ loaded into the OAP cluster, with per-rule body + per-OAP-node
+ load state.
+
+ Layout:
+ ┌── filter ─────────────────────────────────────────────────┐
+ │ search: [_______________] │
+ ├── list ─────────────────┬── detail ──────────────────────┤
+ │ service_resp_time_rule │ rule body (period, silence, │
+ │ bundled · loaded 3/3 │ recovery, hooks, metrics) │
+ │ jvm_old_gen_rule │ trigger expression │
+ │ bundled · loaded 3/3 │ per-OAP-node load state │
+ │ … │ running entities │
+ └─────────────────────────┴────────────────────────────────┘
+
+ Read-only by design — alarm-rule edits go through the YAML file +
+ OAP restart (or watcher reload). No buttons to add / change / delete
+ rules here.
+-->
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { useQuery } from '@tanstack/vue-query';
+import { bff, type AlertingRuleSummary } from '@/api/client';
+
+const route = useRoute();
+const router = useRouter();
+
+const listQuery = useQuery({
+ queryKey: ['operate/alerting-rules'],
+ queryFn: () => bff.alarms.adminRules(),
+ staleTime: 30_000,
+ refetchOnWindowFocus: false,
+});
+
+const search = ref<string>('');
+
+/* Selected rule is URL-driven via `?id=…` so deep-links from the
+ * detail panel (`view in catalog →`) land on a specific rule. */
+const selectedId = computed<string>({
+ get: () => (typeof route.query.id === 'string' ? route.query.id : ''),
+ set: (v: string) => {
+ router.replace({ query: { ...route.query, id: v || undefined } });
+ },
+});
+
+const rules = computed<AlertingRuleSummary[]>(() => listQuery.data.value?.rules ?? []);
+const filteredRules = computed<AlertingRuleSummary[]>(() => {
+ const q = search.value.trim().toLowerCase();
+ if (!q) return rules.value;
+ return rules.value.filter((r) => {
+ if (r.ruleId.toLowerCase().includes(q)) return true;
+ const expr = r.detail?.expression ?? '';
+ return expr.toLowerCase().includes(q);
+ });
+});
+
+/* Auto-select the first rule on load when nothing is URL-pinned, so
+ * the detail pane never sits empty after the list arrives. */
+watch(
+ () => filteredRules.value,
+ (list) => {
+ if (!selectedId.value && list.length > 0) {
+ selectedId.value = list[0]!.ruleId;
+ }
+ },
+);
+
+const selectedSummary = computed<AlertingRuleSummary | null>(() => {
+ if (!selectedId.value) return null;
+ return rules.value.find((r) => r.ruleId === selectedId.value) ?? null;
+});
+
+const detailQuery = useQuery({
+ queryKey: computed(() => ['operate/alerting-rule', selectedId.value]),
+ queryFn: () => bff.alarms.adminRule(selectedId.value),
+ enabled: computed(() => selectedId.value.length > 0),
+ staleTime: 30_000,
+});
+
+const detail = computed(() => detailQuery.data.value?.detail ?? selectedSummary.value?.detail ?? null);
+const detailNodes = computed(() => detailQuery.data.value?.nodes ?? []);
+</script>
+
+<template>
+ <div class="ar">
+ <header class="ar__head">
+ <div>
+ <div class="ar__kicker">Operate · Alerting</div>
+ <h1>Alerting rules</h1>
+ <p class="ar__lede">
+ Read-only catalog of every alarm rule loaded by OAP, with its body and per-node load
+ state. Rules edit through OAP's <code>alarm-settings.yml</code> + watcher reload —
+ there's no mutation surface here by design.
+ </p>
+ </div>
+ <div class="ar__head-actions">
+ <button
+ type="button"
+ class="ar__refresh"
+ :disabled="listQuery.isFetching.value"
+ @click="listQuery.refetch()"
+ >{{ listQuery.isFetching.value ? 'refreshing…' : 'refresh' }}</button>
+ </div>
+ </header>
+
+ <div v-if="listQuery.isPending.value" class="ar__empty">loading…</div>
+
+ <div v-else-if="listQuery.data.value && !listQuery.data.value.reachable" class="ar__empty ar__empty--err">
+ Admin server unreachable —
+ <code>{{ listQuery.data.value.error ?? 'no response' }}</code>.
+ Check the <code>SW_ADMIN_SERVER</code> selector and the BFF's <code>oap.adminUrls</code>.
+ </div>
+
+ <template v-else>
+ <div class="ar__filter">
+ <input
+ v-model="search"
+ type="text"
+ placeholder="search rule id or expression…"
+ class="ar__search"
+ />
+ <span class="ar__count">{{ filteredRules.length }} rule{{ filteredRules.length === 1 ? '' : 's' }}</span>
+ </div>
+
+ <div class="ar__split">
+ <ul class="ar__list">
+ <li
+ v-for="r in filteredRules"
+ :key="r.ruleId"
+ class="ar__list-item"
+ :class="{ active: r.ruleId === selectedId }"
+ @click="selectedId = r.ruleId"
+ >
+ <div class="ar__list-name">
+ <code>{{ r.ruleId }}</code>
+ </div>
+ <div class="ar__list-meta">
+ <span class="ar__load" :class="{ partial: r.loadedOn < r.totalNodes }">
+ loaded {{ r.loadedOn }}/{{ r.totalNodes }}
+ </span>
+ <span v-if="r.detail?.period" class="ar__period">
+ {{ r.detail.period }}m window
+ </span>
+ </div>
+ </li>
+ <li v-if="filteredRules.length === 0" class="ar__list-empty">
+ No rules match.
+ </li>
+ </ul>
+
+ <aside class="ar__detail">
+ <div v-if="!selectedSummary && !detail" class="ar__placeholder">
+ Select a rule to see its body.
+ </div>
+ <template v-else>
+ <header class="ar__detail-head">
+ <h2><code>{{ selectedId }}</code></h2>
+ </header>
+
+ <div v-if="detailQuery.isPending.value && !detail" class="ar__placeholder">loading…</div>
+ <div v-else-if="!detail" class="ar__placeholder">
+ Rule body unavailable on every node.
+ </div>
+ <template v-else>
+ <section class="ar__sec">
+ <div class="ar__kicker-s">Expression</div>
+ <pre class="ar__expr">{{ detail.expression }}</pre>
+ </section>
+
+ <section class="ar__sec">
+ <div class="ar__kicker-s">Window</div>
+ <div class="ar__meta-grid">
+ <div><span class="ar__lbl">period</span><span>{{ detail.period }}m</span></div>
+ <div><span class="ar__lbl">silence</span><span>{{ detail.silencePeriod }}m</span></div>
+ <div><span class="ar__lbl">recovery-obs</span><span>{{ detail.recoveryObservationPeriod }}m</span></div>
+ <div v-if="detail.additionalPeriod > 0">
+ <span class="ar__lbl">additional</span><span>{{ detail.additionalPeriod }}m</span>
+ </div>
+ </div>
+ </section>
+
+ <section v-if="detail.includeMetrics.length > 0" class="ar__sec">
+ <div class="ar__kicker-s">Metrics referenced</div>
+ <div class="ar__chips">
+ <code v-for="m in detail.includeMetrics" :key="m">{{ m }}</code>
+ </div>
+ </section>
+
+ <section v-if="detail.hooks.length > 0" class="ar__sec">
+ <div class="ar__kicker-s">Hooks</div>
+ <div class="ar__chips">
+ <span v-for="h in detail.hooks" :key="h" class="ar__tag">{{ h }}</span>
+ </div>
+ </section>
+
+ <section v-if="detail.tags.length > 0" class="ar__sec">
+ <div class="ar__kicker-s">Tags</div>
+ <div class="ar__chips">
+ <span v-for="t in detail.tags" :key="`${t.key}=${t.value}`" class="ar__tag">
+ {{ t.key }}={{ t.value }}
+ </span>
+ </div>
+ </section>
+
+ <section
+ v-if="detail.includeEntityNames.length > 0 || detail.includeEntityNamesRegex"
+ class="ar__sec"
+ >
+ <div class="ar__kicker-s">Include entities</div>
+ <div v-if="detail.includeEntityNamesRegex" class="ar__regex">
+ regex: <code>{{ detail.includeEntityNamesRegex }}</code>
+ </div>
+ <div v-if="detail.includeEntityNames.length > 0" class="ar__chips">
+ <code v-for="n in detail.includeEntityNames" :key="n">{{ n }}</code>
+ </div>
+ </section>
+
+ <section
+ v-if="detail.excludeEntityNames.length > 0 || detail.excludeEntityNamesRegex"
+ class="ar__sec"
+ >
+ <div class="ar__kicker-s">Exclude entities</div>
+ <div v-if="detail.excludeEntityNamesRegex" class="ar__regex">
+ regex: <code>{{ detail.excludeEntityNamesRegex }}</code>
+ </div>
+ <div v-if="detail.excludeEntityNames.length > 0" class="ar__chips">
+ <code v-for="n in detail.excludeEntityNames" :key="n">{{ n }}</code>
+ </div>
+ </section>
+
+ <section v-if="detail.runningEntities.length > 0" class="ar__sec">
+ <div class="ar__kicker-s">
+ Currently watching ({{ detail.runningEntities.length }})
+ </div>
+ <ul class="ar__entity-list">
+ <li v-for="re in detail.runningEntities" :key="`${re.scope}/${re.name}`">
+ <span class="ar__tag">{{ re.scope }}</span>
+ <code>{{ re.name }}</code>
+ </li>
+ </ul>
+ </section>
+
+ <section v-if="detailNodes.length > 1" class="ar__sec">
+ <div class="ar__kicker-s">Per-node state</div>
+ <table class="ar__node-table">
+ <thead>
+ <tr>
+ <th>node</th>
+ <th>ok</th>
+ <th>note</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="n in detailNodes" :key="n.address">
+ <td><code>{{ n.address }}</code></td>
+ <td>
+ <span class="ar__dot" :class="n.ok ? 'is-ok' : 'is-err'" />
+ {{ n.ok ? 'ok' : 'err' }}
+ </td>
+ <td class="ar__node-note">{{ n.error ?? (n.detail ? '—' : 'no body returned') }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </section>
+ </template>
+ </template>
+ </aside>
+ </div>
+ </template>
+ </div>
+</template>
+
+<style scoped>
+.ar {
+ padding: 20px 20px 60px;
+ max-width: 1500px;
+ margin: 0 auto;
+}
+.ar__head {
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+ margin-bottom: 16px;
+}
+.ar__head > div:first-child { flex: 1; }
+.ar__kicker {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--sw-accent);
+ margin-bottom: 4px;
+}
+.ar h1 {
+ font-size: 22px;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ color: var(--sw-fg-0);
+ margin: 0 0 8px;
+}
+.ar__lede {
+ font-size: 12.5px;
+ color: var(--sw-fg-1);
+ line-height: 1.5;
+ margin: 0;
+ max-width: 820px;
+}
+.ar__lede code {
+ font-family: var(--sw-mono);
+ font-size: 11.5px;
+ color: var(--sw-fg-0);
+ background: var(--sw-bg-2);
+ padding: 1px 5px;
+ border-radius: 3px;
+}
+.ar__refresh {
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line-2);
+ color: var(--sw-fg-0);
+ font: inherit;
+ font-size: 11.5px;
+ padding: 6px 14px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-weight: 500;
+}
+.ar__refresh:not(:disabled):hover { background: var(--sw-bg-2); border-color: var(--sw-accent); }
+.ar__refresh:disabled { opacity: 0.55; cursor: not-allowed; }
+
+.ar__empty {
+ padding: 32px;
+ text-align: center;
+ font-size: 12px;
+ color: var(--sw-fg-3);
+ background: var(--sw-bg-1);
+ border: 1px dashed var(--sw-line);
+ border-radius: 8px;
+}
+.ar__empty--err {
+ color: var(--sw-err);
+ border-color: rgba(239,68,68,0.4);
+}
+.ar__empty code {
+ font-family: var(--sw-mono);
+ font-size: 11px;
+ color: var(--sw-fg-0);
+ background: var(--sw-bg-2);
+ padding: 1px 5px;
+ border-radius: 3px;
+}
+
+.ar__filter {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 12px;
+ padding: 8px 10px;
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 8px;
+}
+.ar__search {
+ flex: 1;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line);
+ color: var(--sw-fg-0);
+ font: inherit;
+ font-size: 12px;
+ padding: 5px 8px;
+ border-radius: 5px;
+ outline: none;
+}
+.ar__search:focus { border-color: var(--sw-accent); }
+.ar__count {
+ font-size: 11px;
+ color: var(--sw-fg-3);
+ font-variant-numeric: tabular-nums;
+}
+
+.ar__split {
+ display: grid;
+ grid-template-columns: 380px 1fr;
+ gap: 16px;
+ align-items: start;
+}
+.ar__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 8px;
+ overflow: hidden;
+ max-height: calc(100vh - 220px);
+ overflow-y: auto;
+}
+.ar__list-item {
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--sw-line);
+ cursor: pointer;
+}
+.ar__list-item:last-child { border-bottom: none; }
+.ar__list-item:hover { background: var(--sw-bg-2); }
+.ar__list-item.active {
+ background: var(--sw-bg-3);
+ box-shadow: inset 2px 0 0 var(--sw-accent);
+}
+.ar__list-name code {
+ font-family: var(--sw-mono);
+ font-size: 12px;
+ color: var(--sw-fg-0);
+ background: transparent;
+ padding: 0;
+}
+.ar__list-meta {
+ margin-top: 4px;
+ display: flex;
+ gap: 10px;
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+}
+.ar__load.partial { color: var(--sw-warn); }
+.ar__period { font-variant-numeric: tabular-nums; }
+.ar__list-empty {
+ padding: 24px;
+ text-align: center;
+ font-size: 12px;
+ color: var(--sw-fg-3);
+}
+
+.ar__detail {
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 8px;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ max-height: calc(100vh - 220px);
+ overflow-y: auto;
+}
+.ar__placeholder {
+ padding: 32px;
+ text-align: center;
+ font-size: 12px;
+ color: var(--sw-fg-3);
+}
+.ar__detail-head h2 {
+ margin: 0;
+ font-size: 13px;
+ font-weight: 600;
+}
+.ar__detail-head h2 code {
+ font-family: var(--sw-mono);
+ color: var(--sw-fg-0);
+ background: transparent;
+ font-size: 13px;
+}
+.ar__sec { border-top: 1px solid var(--sw-line); padding-top: 12px; }
+.ar__sec:first-of-type { border-top: 0; padding-top: 0; }
+.ar__kicker-s {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sw-fg-3);
+ font-weight: 600;
+ margin-bottom: 6px;
+}
+.ar__expr {
+ font-family: var(--sw-mono);
+ font-size: 12px;
+ color: var(--sw-fg-0);
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line);
+ border-radius: 5px;
+ padding: 10px 12px;
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-all;
+ line-height: 1.5;
+}
+.ar__meta-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 6px;
+}
+.ar__meta-grid > div {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 8px;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line);
+ border-radius: 4px;
+ font-size: 11.5px;
+ font-variant-numeric: tabular-nums;
+}
+.ar__lbl {
+ font-size: 9.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sw-fg-3);
+ font-weight: 600;
+}
+.ar__chips { display: flex; flex-wrap: wrap; gap: 4px; }
+.ar__chips code {
+ font-family: var(--sw-mono);
+ font-size: 10.5px;
+ color: var(--sw-fg-1);
+ background: var(--sw-bg-2);
+ padding: 2px 6px;
+ border-radius: 3px;
+ border: 1px solid var(--sw-line);
+}
+.ar__tag {
+ font-size: 10.5px;
+ color: var(--sw-fg-1);
+ background: var(--sw-bg-2);
+ padding: 2px 6px;
+ border-radius: 3px;
+ border: 1px solid var(--sw-line);
+}
+.ar__regex { font-size: 11px; color: var(--sw-fg-2); margin-bottom: 6px; }
+.ar__regex code {
+ font-family: var(--sw-mono);
+ font-size: 11px;
+ color: var(--sw-fg-0);
+ background: var(--sw-bg-2);
+ padding: 1px 5px;
+ border-radius: 3px;
+}
+.ar__entity-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.ar__entity-list li {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.ar__entity-list code {
+ font-family: var(--sw-mono);
+ font-size: 11px;
+ color: var(--sw-fg-0);
+ background: var(--sw-bg-2);
+ padding: 1px 5px;
+ border-radius: 3px;
+}
+.ar__node-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 11.5px;
+}
+.ar__node-table th, .ar__node-table td {
+ text-align: left;
+ padding: 5px 8px;
+ border-bottom: 1px solid var(--sw-line);
+}
+.ar__node-table th {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sw-fg-3);
+ font-weight: 600;
+}
+.ar__node-table code {
+ font-family: var(--sw-mono);
+ font-size: 11px;
+ color: var(--sw-fg-1);
+}
+.ar__node-note { color: var(--sw-fg-3); font-style: italic; max-width: 320px; overflow-wrap: anywhere; }
+.ar__dot {
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ margin-right: 4px;
+ vertical-align: middle;
+}
+.ar__dot.is-ok { background: var(--sw-ok); }
+.ar__dot.is-err { background: var(--sw-err); }
+</style>
diff --git a/apps/ui/src/features/setup/LayerSetupCard.vue b/apps/ui/src/features/setup/LayerSetupCard.vue
deleted file mode 100644
index 0c0706a..0000000
--- a/apps/ui/src/features/setup/LayerSetupCard.vue
+++ /dev/null
@@ -1,1206 +0,0 @@
-<!--
- Licensed to the Apache Software Foundation (ASF) under one or more
- contributor license agreements. See the NOTICE file distributed with
- this work for additional information regarding copyright ownership.
- The ASF licenses this file to You under the Apache License, Version 2.0
- (the "License"); you may not use this file except in compliance with
- the License. You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<script setup lang="ts">
-import { computed, ref } from 'vue';
-import type { LayerDef } from '@skywalking-horizon-ui/api-client';
-import Icon from '@/components/icons/Icon.vue';
-import { useSetupStore, defaultLandingFor } from '@/state/setup';
-
-const props = defineProps<{ layer: LayerDef; expanded?: boolean }>();
-const emit = defineEmits<{ (e: 'toggle'): void }>();
-
-const store = useSetupStore();
-const cfg = computed(() => store.ensure(props.layer.key, { slots: props.layer.slots, caps: props.layer.caps, metrics: props.layer.metrics, overview: props.layer.overview }));
-
-const open = ref(props.expanded ?? false);
-function toggle(): void {
- open.value = !open.value;
- emit('toggle');
-}
-
-function resetThisLayer(): void {
- store.reset(props.layer.key, { slots: props.layer.slots, caps: props.layer.caps });
-}
-
-// Every form-field input on this card calls onEdit so the store knows the
-// user (not just a default-population) touched the config.
-function onEdit(): void {
- store.markDirty();
-}
-
-const summary = computed<string>(() => {
- const c = cfg.value;
- const cols = c.landing.columns.map((x) => x.metric).join(', ');
- const tile = (c.landing.overviewMetrics ?? []).slice(0, 3).join(' / ') || '—';
- const base = `Overview tile: ${tile} · service list: top ${c.landing.topN} by ${c.landing.orderBy} · cols ${cols} · priority ${c.landing.priority}`;
- if (!props.layer.active) {
- return `${base} · no service reporting yet`;
- }
- return base;
-});
-
-// Setup-card capability toggles — scoped to the two features the
-// Setup page actually configures. Deeper toggles (traces, logs,
-// profiling family, etc.) live on /admin/layer-dashboards under the
-// Components block, which has its own editor.
-const capRows: Array<{ key: keyof typeof cfg.value.caps; label: string }> = [
- // Service-count tile is the first Feature operators see — it's
- // also the first tile rendered on the Overview strip for the layer.
- { key: 'serviceCountTile', label: 'Service count tile' },
- { key: 'dashboards', label: 'Metrics' },
- { key: 'serviceMap', label: 'Service Map' },
-];
-
-const showAdvanced = ref(false);
-
-/* ---- Overview tile editor (groups → metrics) ----
- *
- * A layer's Overview tile is now a list of groups. Each group becomes
- * one tile on the Overview strip with the group's title + size +
- * metrics. Editable data:
- * - `landing.overviewGroups` — the ordered list (title, size,
- * metricIds[]).
- * - `landing.columns` — full metric data keyed by id. A
- * metric referenced by any group
- * lives as a column entry.
- *
- * Add / remove / reorder operations keep both arrays in sync. A
- * column is removed only when no remaining group references its id,
- * so a metric moved between groups isn't accidentally garbage-
- * collected.
- */
-
-const AUTO_GROUP_MAX = 3;
-
-function groupsArr(): NonNullable<typeof cfg.value.landing.overviewGroups> {
- if (!cfg.value.landing.overviewGroups) cfg.value.landing.overviewGroups = [];
- return cfg.value.landing.overviewGroups;
-}
-
-function groupCells(gIdx: number) {
- const g = groupsArr()[gIdx];
- if (!g) return [];
- return g.metricIds
- .map((id) => cfg.value.landing.columns.find((c) => c.metric === id))
- .filter((c): c is NonNullable<typeof c> => !!c);
-}
-
-function maxMetricsFor(size: 'auto' | 'square'): number {
- return size === 'square' ? 1 : AUTO_GROUP_MAX;
-}
-
-function nextOverviewId(): string {
- const existing = new Set(cfg.value.landing.columns.map((c) => c.metric));
- for (let i = 0; i < 1000; i++) {
- const id = `ov_${i}`;
- if (!existing.has(id)) return id;
- }
- return `ov_${Date.now()}`;
-}
-
-function addGroup(): void {
- groupsArr().push({ title: 'New group', size: 'auto', metricIds: [] });
- onEdit();
-}
-
-function removeGroup(gIdx: number): void {
- const groups = groupsArr();
- const removed = groups[gIdx];
- if (!removed) return;
- // Drop columns that ONLY this group references.
- for (const id of removed.metricIds) {
- if (!groups.some((g, i) => i !== gIdx && g.metricIds.includes(id))) {
- const ci = cfg.value.landing.columns.findIndex((c) => c.metric === id);
- if (ci >= 0) cfg.value.landing.columns.splice(ci, 1);
- }
- }
- groups.splice(gIdx, 1);
- onEdit();
-}
-
-function moveGroup(gIdx: number, dir: -1 | 1): void {
- const groups = groupsArr();
- const j = gIdx + dir;
- if (j < 0 || j >= groups.length) return;
- [groups[gIdx], groups[j]] = [groups[j], groups[gIdx]];
- onEdit();
-}
-
-function setGroupSize(gIdx: number, size: 'auto' | 'square'): void {
- const g = groupsArr()[gIdx];
- if (!g) return;
- g.size = size;
- // Square mode is single-metric by convention — trim extras when
- // switching from auto. The trimmed metrics' columns are removed
- // unless still referenced by another group.
- if (size === 'square' && g.metricIds.length > 1) {
- const orphans = g.metricIds.slice(1);
- g.metricIds = g.metricIds.slice(0, 1);
- for (const id of orphans) {
- if (!groupsArr().some((gg) => gg.metricIds.includes(id))) {
- const ci = cfg.value.landing.columns.findIndex((c) => c.metric === id);
- if (ci >= 0) cfg.value.landing.columns.splice(ci, 1);
- }
- }
- }
- onEdit();
-}
-
-function addMetricInGroup(gIdx: number): void {
- const g = groupsArr()[gIdx];
- if (!g) return;
- if (g.metricIds.length >= maxMetricsFor(g.size)) return;
- const id = nextOverviewId();
- cfg.value.landing.columns.push({
- metric: id,
- label: 'New metric',
- mqe: '',
- aggregation: 'avg',
- });
- g.metricIds.push(id);
- onEdit();
-}
-
-function removeMetricInGroup(gIdx: number, id: string): void {
- const g = groupsArr()[gIdx];
- if (!g) return;
- g.metricIds = g.metricIds.filter((x) => x !== id);
- if (!groupsArr().some((gg) => gg.metricIds.includes(id))) {
- const ci = cfg.value.landing.columns.findIndex((c) => c.metric === id);
- if (ci >= 0) cfg.value.landing.columns.splice(ci, 1);
- }
- onEdit();
-}
-
-function moveMetricInGroup(gIdx: number, mIdx: number, dir: -1 | 1): void {
- const g = groupsArr()[gIdx];
- if (!g) return;
- const j = mIdx + dir;
- if (j < 0 || j >= g.metricIds.length) return;
- [g.metricIds[mIdx], g.metricIds[j]] = [g.metricIds[j], g.metricIds[mIdx]];
- onEdit();
-}
-
-/* ---- Per-user threshold overrides ----
- *
- * The layer template's `topology` / `endpointDependency` block declares
- * default thresholds for the metric that drives the node-ring colour
- * band (typically SLA). Operators can override those defaults
- * per-user without editing the template JSON.
- *
- * Stored under `landing.thresholdOverrides`, keyed by
- * `<scope>.<metricId>` where scope is `topology` or `dependency`.
- * The renderer merges these on top of the template defaults before
- * computing the band colour.
- */
-
-type OverrideScope = 'topology' | 'dependency';
-interface ThresholdOverrideRow {
- scope: OverrideScope;
- metricId: string;
- ok?: number;
- warn?: number;
- danger?: number;
- invertHealth?: boolean;
- invertBase?: number;
-}
-
-function overridesMap(): NonNullable<typeof cfg.value.landing.thresholdOverrides> {
- if (!cfg.value.landing.thresholdOverrides) cfg.value.landing.thresholdOverrides = {};
- return cfg.value.landing.thresholdOverrides;
-}
-const thresholdOverrides = computed<ThresholdOverrideRow[]>(() => {
- const map = cfg.value.landing.thresholdOverrides ?? {};
- return Object.entries(map).map(([key, v]) => {
- const dot = key.indexOf('.');
- const scope = (key.slice(0, dot) as OverrideScope) || 'topology';
- const metricId = key.slice(dot + 1);
- return { scope, metricId, ...v };
- });
-});
-function addOverride(): void {
- const map = overridesMap();
- let i = 1;
- while (map[`topology.metric_${i}`]) i++;
- map[`topology.metric_${i}`] = { ok: 0.1, warn: 1, danger: 5 };
- onEdit();
-}
-function removeOverride(scope: OverrideScope, metricId: string): void {
- const map = overridesMap();
- delete map[`${scope}.${metricId}`];
- onEdit();
-}
-function updateOverrideKey(oldScope: OverrideScope, oldId: string, newScope: OverrideScope, newId: string): void {
- if (oldScope === newScope && oldId === newId) return;
- const map = overridesMap();
- const oldKey = `${oldScope}.${oldId}`;
- const newKey = `${newScope}.${newId}`;
- if (!map[oldKey]) return;
- map[newKey] = map[oldKey];
- delete map[oldKey];
- onEdit();
-}
-function getOverride(scope: OverrideScope, metricId: string): NonNullable<typeof cfg.value.landing.thresholdOverrides>[string] {
- const map = overridesMap();
- const k = `${scope}.${metricId}`;
- if (!map[k]) map[k] = {};
- return map[k]!;
-}
-
-const headerColor = computed(() => props.layer.color);
-const isDefaultLanding = computed(() => {
- const d = defaultLandingFor(props.layer.key);
- return (
- cfg.value.landing.priority === d.priority &&
- cfg.value.landing.topN === d.topN &&
- cfg.value.landing.orderBy === d.orderBy &&
- cfg.value.landing.columns.length === d.columns.length
- );
-});
-</script>
-
-<template>
- <div class="sw-card layer-card" :class="{ 'is-open': open, 'is-inactive': !layer.active }">
- <div class="head" @click="toggle">
- <span class="dot" :style="{ background: headerColor }" />
- <span class="name">{{ cfg.displayName || layer.name }}</span>
- <span v-if="layer.active" class="sw-badge ok dot-mark">{{ layer.serviceCount >= 0 ? `${layer.serviceCount} services` : 'active' }}</span>
- <span v-else class="sw-badge">no data</span>
- <span class="sw-badge info" style="margin-left: auto" title="Priority on the Overview">
- ↑ {{ cfg.landing.priority }}
- </span>
- <span v-if="!isDefaultLanding" class="sw-badge">customized</span>
- <span class="caret" :class="{ open }"><Icon name="caret" :size="12" /></span>
- </div>
- <div class="summary">{{ summary }}</div>
-
- <div v-if="open" class="body">
- <section>
- <h4>Term aliases</h4>
- <div class="field-grid">
- <label>
- <span>Display name</span>
- <input v-model="cfg.displayName" :placeholder="layer.name" @input="onEdit" />
- </label>
- <label v-if="layer.slots.services !== undefined">
- <span>Services</span>
- <input v-model="cfg.slots.services" :placeholder="layer.slots.services" @input="onEdit" />
- </label>
- <label v-if="layer.slots.instances !== undefined">
- <span>Instances</span>
- <input v-model="cfg.slots.instances" :placeholder="layer.slots.instances" @input="onEdit" />
- </label>
- <label v-if="layer.slots.endpoints !== undefined">
- <span>Endpoints</span>
- <input v-model="cfg.slots.endpoints" :placeholder="layer.slots.endpoints" @input="onEdit" />
- </label>
- <label v-if="cfg.caps.endpointDependency">
- <span>Endpoint dependency</span>
- <input v-model="cfg.slots.endpointDependency" :placeholder="layer.slots.endpointDependency ?? `${cfg.slots.endpoints ?? 'Endpoint'} dependency`" @input="onEdit" />
- </label>
- </div>
- </section>
-
- <section>
- <h4>Features</h4>
- <div class="caps-grid">
- <label v-for="row in capRows" :key="row.key" class="cap-toggle">
- <input type="checkbox" v-model="cfg.caps[row.key]" @change="onEdit" />
- <span>{{ row.label }}</span>
- </label>
- </div>
- </section>
-
- <section>
- <div class="row-with-toggle">
- <h4>Overview tile groups</h4>
- <button
- class="sw-btn small"
- type="button"
- title="Add a new tile group"
- @click="addGroup"
- >+ Add group</button>
- </div>
- <p class="hint subtle">
- Each group becomes one tile on the Overview strip. Auto groups carry 1 – 3 metric
- cells (with sparklines); square groups carry exactly 1 metric (the layer's
- headline) and render as compact squares in dense fleet views. Change a group's
- size to flip its tile.
- </p>
- <div class="row-with-toggle compact">
- <span class="hint subtle">
- Tip: keep one auto group for the headline metrics, add square groups for at-a-glance KPIs.
- </span>
- <button class="sw-btn ghost small" type="button" @click="showAdvanced = !showAdvanced">
- {{ showAdvanced ? 'Hide advanced' : 'Show advanced (scale / precision)' }}
- </button>
- </div>
-
- <div v-if="groupsArr().length === 0" class="hint subtle group-empty">
- No groups yet. Click "+ Add group" to create the first tile.
- </div>
-
- <div
- v-for="(g, gIdx) in groupsArr()"
- :key="gIdx"
- class="group-block"
- :class="{ 'is-square': g.size === 'square' }"
- >
- <header class="group-head">
- <span class="group-idx">{{ gIdx + 1 }}</span>
- <input
- class="ctl group-title-input"
- v-model="g.title"
- placeholder="Group title (e.g. Throughput / Health)"
- @input="onEdit"
- />
- <div class="size-seg compact">
- <button
- class="seg-btn"
- :class="{ on: g.size === 'auto' }"
- type="button"
- title="3-metric tile, full-width slot"
- @click="setGroupSize(gIdx, 'auto')"
- >Auto</button>
- <button
- class="seg-btn"
- :class="{ on: g.size === 'square' }"
- type="button"
- title="1-metric square tile, compact slot"
- @click="setGroupSize(gIdx, 'square')"
- >Square</button>
- </div>
- <div class="group-actions">
- <button
- class="sw-btn ghost small"
- type="button"
- :disabled="gIdx === 0"
- title="Move group up"
- @click="moveGroup(gIdx, -1)"
- >↑</button>
- <button
- class="sw-btn ghost small"
- type="button"
- :disabled="gIdx === groupsArr().length - 1"
- title="Move group down"
- @click="moveGroup(gIdx, 1)"
- >↓</button>
- <button
- class="sw-btn ghost small danger"
- type="button"
- title="Remove group"
- @click="removeGroup(gIdx)"
- >✕</button>
- </div>
- </header>
-
- <table v-if="groupCells(gIdx).length > 0" class="col-editor">
- <thead>
- <tr>
- <th>#</th>
- <th>Label</th>
- <th>MQE</th>
- <th>Unit</th>
- <th>Aggregate</th>
- <th>Tip</th>
- <template v-if="showAdvanced">
- <th>Scale</th>
- <th>Precision</th>
- </template>
- <th class="row-actions"></th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="(col, mIdx) in groupCells(gIdx)" :key="col.metric">
- <td class="metric-key">{{ mIdx + 1 }}</td>
- <td>
- <input class="ctl" v-model="col.label" placeholder="e.g. RPM" @input="onEdit" />
- </td>
- <td>
- <input
- class="ctl mono"
- v-model="col.mqe"
- placeholder="e.g. service_cpm"
- title="MQE expression — passed verbatim to OAP. Catalog short keys (e.g. cpm) also work."
- @input="onEdit"
- />
- </td>
- <td>
- <input class="ctl narrow" v-model="col.unit" placeholder="—" @input="onEdit" />
- </td>
- <td>
- <select class="ctl narrow" v-model="col.aggregation" @change="onEdit">
- <option value="avg">avg</option>
- <option value="sum">sum</option>
- </select>
- </td>
- <td>
- <input class="ctl" v-model="col.tip" placeholder="hover hint" @input="onEdit" />
- </td>
- <template v-if="showAdvanced">
- <td>
- <input
- class="ctl narrow"
- type="number"
- step="any"
- v-model.number="col.scale"
- placeholder="1"
- title="Multiplier applied to the raw MQE value (e.g. 0.01 to scale 9923 → 99.23)."
- @input="onEdit"
- />
- </td>
- <td>
- <input
- class="ctl narrow"
- type="number"
- min="0"
- max="6"
- v-model.number="col.precision"
- placeholder="auto"
- title="Decimal places to round to."
- @input="onEdit"
- />
- </td>
- </template>
- <td class="row-actions">
- <button
- class="sw-btn ghost small"
- type="button"
- :disabled="mIdx === 0"
- title="Move up"
- @click="moveMetricInGroup(gIdx, mIdx, -1)"
- >↑</button>
- <button
- class="sw-btn ghost small"
- type="button"
- :disabled="mIdx === groupCells(gIdx).length - 1"
- title="Move down"
- @click="moveMetricInGroup(gIdx, mIdx, 1)"
- >↓</button>
- <button
- class="sw-btn ghost small danger"
- type="button"
- title="Remove from group"
- @click="removeMetricInGroup(gIdx, col.metric)"
- >✕</button>
- </td>
- </tr>
- </tbody>
- </table>
- <div v-else class="hint subtle group-empty-row">
- No metrics in this group yet.
- </div>
-
- <div class="row-with-toggle compact">
- <span class="hint subtle">
- {{ g.size === 'square'
- ? 'Square groups carry exactly 1 metric (the headline).'
- : `Auto groups carry up to ${AUTO_GROUP_MAX} metrics.` }}
- </span>
- <button
- class="sw-btn ghost small"
- type="button"
- :disabled="g.metricIds.length >= maxMetricsFor(g.size)"
- :title="g.metricIds.length >= maxMetricsFor(g.size)
- ? 'Group is at its metric cap'
- : 'Add a new MQE-driven metric to this group'"
- @click="addMetricInGroup(gIdx)"
- >+ Add metric</button>
- </div>
- </div>
- </section>
-
- <section>
- <div class="row-with-toggle">
- <h4>Topology / API-dep threshold overrides</h4>
- <button
- class="sw-btn small"
- type="button"
- title="Add a per-user override row"
- @click="addOverride"
- >+ Add override</button>
- </div>
- <p class="hint subtle">
- Per-user overrides take precedence over the layer template's default
- thresholds when computing the node-ring colour band (OK / warn / danger).
- Scope picks which view — service map (<code>topology</code>) or
- API dependency (<code>dependency</code>). Metric id matches the layer
- template (e.g. <code>sla</code>). Toggle "invert" for higher-is-better
- metrics like SLA / apdex.
- </p>
- <div v-if="thresholdOverrides.length === 0" class="hint subtle group-empty">
- No overrides yet. Click "+ Add override" to insert a row.
- </div>
- <table v-else class="col-editor threshold-table">
- <thead>
- <tr>
- <th>Scope</th>
- <th>Metric id</th>
- <th>ok ≤</th>
- <th>warn ≤</th>
- <th>danger ≤</th>
- <th>invert</th>
- <th>base</th>
- <th class="row-actions"></th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="row in thresholdOverrides" :key="`${row.scope}.${row.metricId}`">
- <td>
- <select
- class="ctl narrow"
- :value="row.scope"
- @change="updateOverrideKey(row.scope, row.metricId, ($event.target as HTMLSelectElement).value as OverrideScope, row.metricId)"
- >
- <option value="topology">topology</option>
- <option value="dependency">dependency</option>
- </select>
- </td>
- <td>
- <input
- class="ctl mono"
- :value="row.metricId"
- placeholder="sla"
- @change="updateOverrideKey(row.scope, row.metricId, row.scope, ($event.target as HTMLInputElement).value)"
- />
- </td>
- <td>
- <input
- class="ctl narrow"
- type="number"
- step="0.1"
- :value="getOverride(row.scope, row.metricId).ok"
- @input="getOverride(row.scope, row.metricId).ok = Number(($event.target as HTMLInputElement).value) || undefined; onEdit()"
- />
- </td>
- <td>
- <input
- class="ctl narrow"
- type="number"
- step="0.1"
- :value="getOverride(row.scope, row.metricId).warn"
- @input="getOverride(row.scope, row.metricId).warn = Number(($event.target as HTMLInputElement).value) || undefined; onEdit()"
- />
- </td>
- <td>
- <input
- class="ctl narrow"
- type="number"
- step="0.1"
- :value="getOverride(row.scope, row.metricId).danger"
- @input="getOverride(row.scope, row.metricId).danger = Number(($event.target as HTMLInputElement).value) || undefined; onEdit()"
- />
- </td>
- <td>
- <input
- type="checkbox"
- :checked="getOverride(row.scope, row.metricId).invertHealth ?? false"
- @change="getOverride(row.scope, row.metricId).invertHealth = ($event.target as HTMLInputElement).checked; onEdit()"
- />
- </td>
- <td>
- <input
- class="ctl narrow"
- type="number"
- step="1"
- placeholder="100"
- :value="getOverride(row.scope, row.metricId).invertBase"
- @input="getOverride(row.scope, row.metricId).invertBase = Number(($event.target as HTMLInputElement).value) || undefined; onEdit()"
- />
- </td>
- <td class="row-actions">
- <button
- class="sw-btn ghost small danger"
- type="button"
- title="Remove override"
- @click="removeOverride(row.scope, row.metricId)"
- >✕</button>
- </td>
- </tr>
- </tbody>
- </table>
- </section>
-
- <div class="actions">
- <button class="sw-btn" type="button" @click="resetThisLayer">Reset to defaults</button>
- <span class="hint">Changes are local until persisted via /api/setup (Stage 2.4).</span>
- </div>
- </div>
- </div>
-</template>
-
-<style scoped>
-.layer-card {
- margin-bottom: 10px;
-}
-.layer-card.is-inactive .name {
- color: var(--sw-fg-2);
-}
-.head {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 10px 12px;
- cursor: pointer;
- user-select: none;
-}
-.head .dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- flex: 0 0 8px;
-}
-.head .name {
- font-weight: 600;
- color: var(--sw-fg-0);
-}
-.head .dot-mark::before {
- content: '';
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: currentColor;
- margin-right: 4px;
- display: inline-block;
-}
-.caret {
- color: var(--sw-fg-3);
- transition: transform 0.12s;
- transform: rotate(-90deg);
- display: inline-flex;
- margin-left: 8px;
-}
-.caret.open {
- transform: rotate(0);
-}
-.summary {
- padding: 0 12px 10px;
- font-size: 11px;
- color: var(--sw-fg-2);
-}
-.body {
- border-top: 1px solid var(--sw-line);
- padding: 12px;
- display: flex;
- flex-direction: column;
- gap: 14px;
-}
-.body h4 {
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--sw-fg-2);
- margin: 0 0 8px;
- font-weight: 600;
-}
-.field-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
- gap: 10px;
-}
-.field-grid label {
- display: flex;
- flex-direction: column;
- gap: 4px;
- font-size: 11px;
- color: var(--sw-fg-2);
-}
-.field-grid label.wide {
- grid-column: 1 / -1;
- flex-direction: row;
- align-items: center;
-}
-.field-grid input,
-.field-grid select {
- height: 28px;
- padding: 0 8px;
- background: var(--sw-bg-2);
- border: 1px solid var(--sw-line-2);
- border-radius: 4px;
- color: var(--sw-fg-0);
- font: inherit;
- font-size: 12px;
-}
-.field-grid input[type='checkbox'] {
- height: auto;
- margin-right: 6px;
- padding: 0;
-}
-.caps-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
- gap: 6px;
-}
-.cap-toggle {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 12px;
- color: var(--sw-fg-1);
- padding: 4px 6px;
- border-radius: 4px;
- background: var(--sw-bg-2);
-}
-.cap-toggle input {
- accent-color: var(--sw-accent);
-}
-.cols-row {
- margin-top: 10px;
- display: flex;
- gap: 10px;
- align-items: flex-start;
-}
-.cols-label {
- font-size: 11px;
- color: var(--sw-fg-2);
- padding-top: 4px;
- flex: 0 0 80px;
-}
-.cols-chips {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-}
-.chip {
- height: 24px;
- padding: 0 10px;
- background: var(--sw-bg-2);
- border: 1px solid var(--sw-line-2);
- border-radius: 4px;
- color: var(--sw-fg-1);
- font: inherit;
- font-size: 11px;
- cursor: pointer;
- display: inline-flex;
- align-items: center;
- gap: 4px;
-}
-.chip.on {
- background: var(--sw-accent-soft);
- color: var(--sw-accent-2);
- border-color: var(--sw-accent-line);
-}
-.chip .unit {
- color: var(--sw-fg-3);
- font-size: 10px;
-}
-.chip.more {
- border-style: dashed;
- color: var(--sw-fg-2);
-}
-.chip.disabled {
- opacity: 0.45;
- cursor: not-allowed;
-}
-.chip .agg {
- margin-left: 4px;
- padding: 0 5px;
- border-radius: 2px;
- font-size: 9px;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- background: var(--sw-bg-3);
- color: var(--sw-fg-3);
-}
-.chip.on .agg {
- background: var(--sw-accent-line);
- color: var(--sw-accent-2);
-}
-
-/* Overview tile picker — chip grid + ordered slot rail showing the
- * picked metrics in their on-tile order, with move-left/move-right
- * controls so operators can shuffle without removing + re-picking. */
-.ov-chips {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin-bottom: 10px;
-}
-.ov-order {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 8px;
- padding: 8px 10px;
- background: var(--sw-bg-2);
- border: 1px dashed var(--sw-line-2);
- border-radius: 5px;
-}
-.ov-order-label {
- font-size: 10.5px;
- color: var(--sw-fg-3);
- text-transform: uppercase;
- letter-spacing: 0.06em;
-}
-.ov-slot {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 3px 6px 3px 4px;
- background: var(--sw-bg-1);
- border: 1px solid var(--sw-line-2);
- border-radius: 4px;
- font-size: 11px;
-}
-.ov-slot .slot-idx {
- font-family: var(--sw-mono);
- font-size: 9.5px;
- width: 16px;
- height: 16px;
- display: inline-grid;
- place-items: center;
- background: var(--sw-accent);
- color: #0a0d12;
- font-weight: 700;
- border-radius: 3px;
-}
-.ov-slot .slot-name {
- color: var(--sw-fg-0);
- font-weight: 500;
-}
-.ov-slot .slot-agg {
- font-size: 9px;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- padding: 0 5px;
- border-radius: 2px;
- background: var(--sw-bg-3);
- color: var(--sw-fg-3);
-}
-.ov-slot .sw-btn {
- width: 22px;
- height: 22px;
- padding: 0;
- display: inline-grid;
- place-items: center;
- font-size: 11px;
-}
-.ov-slot .sw-btn[disabled] { opacity: 0.35; pointer-events: none; }
-
-/* Overview tile group editor — each group is a card-within-card
- * with its title / size toggle / metrics table / add-metric button. */
-.group-empty {
- padding: 14px;
- text-align: center;
- border: 1px dashed var(--sw-line-2);
- border-radius: 6px;
- margin: 6px 0;
-}
-.group-empty-row {
- padding: 12px 16px;
- text-align: center;
-}
-.group-block {
- margin-top: 12px;
- border: 1px solid var(--sw-line);
- border-radius: 6px;
- background: var(--sw-bg-1);
- overflow: hidden;
-}
-.group-block.is-square {
- border-color: rgba(96, 165, 250, 0.3);
-}
-.group-head {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 10px;
- background: var(--sw-bg-2);
- border-bottom: 1px solid var(--sw-line);
-}
-.group-idx {
- font-family: var(--sw-mono);
- font-size: 10px;
- width: 18px;
- height: 18px;
- display: inline-grid;
- place-items: center;
- background: var(--sw-accent);
- color: #0a0d12;
- font-weight: 700;
- border-radius: 3px;
-}
-.group-title-input {
- flex: 1;
- min-width: 0;
- height: 26px;
- padding: 0 8px;
- background: var(--sw-bg-1);
- border: 1px solid var(--sw-line-2);
- border-radius: 4px;
- color: var(--sw-fg-0);
- font: inherit;
- font-size: 11.5px;
-}
-.size-seg.compact {
- display: inline-flex;
- background: var(--sw-bg-1);
- border: 1px solid var(--sw-line);
- border-radius: 4px;
- overflow: hidden;
-}
-.size-seg.compact .seg-btn {
- height: 22px;
- padding: 0 8px;
- background: transparent;
- border: 0;
- color: var(--sw-fg-2);
- font: inherit;
- font-size: 10.5px;
- cursor: pointer;
-}
-.size-seg.compact .seg-btn:not(:last-child) {
- border-right: 1px solid var(--sw-line);
-}
-.size-seg.compact .seg-btn.on {
- background: var(--sw-accent-soft);
- color: var(--sw-accent-2);
- font-weight: 600;
-}
-.group-actions {
- display: inline-flex;
- gap: 4px;
-}
-
-/* Live preview tile — mirrors LayerKpiStripCard.vue at a smaller
- * scale. Fed by mock values from `previewCells`; updates live as the
- * operator edits labels / units / aggregation. */
-.preview-wrap {
- margin-top: 14px;
- padding-top: 10px;
- border-top: 1px dashed var(--sw-line);
-}
-.preview-label {
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.1em;
- color: var(--sw-fg-3);
- margin-bottom: 6px;
-}
-.preview-tile {
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding: 10px 12px;
- max-width: 560px;
-}
-.pt-head {
- display: flex;
- align-items: baseline;
- gap: 6px;
- font-size: 10.5px;
-}
-.pt-head .dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- flex: 0 0 6px;
- align-self: center;
-}
-.pt-head .name {
- color: var(--sw-fg-0);
- font-weight: 600;
- letter-spacing: -0.01em;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- flex: 1;
- min-width: 0;
-}
-.pt-head .svc-count {
- display: inline-flex;
- align-items: baseline;
- gap: 3px;
- font-family: var(--sw-mono);
- color: var(--sw-fg-3);
-}
-.pt-head .svc-count .n {
- color: var(--sw-fg-1);
- font-weight: 600;
-}
-.pt-head .svc-count .unit {
- font-size: 9.5px;
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-.pt-cells {
- display: grid;
- grid-template-columns: repeat(var(--cells-n, 3), minmax(0, 1fr));
- gap: 8px;
- padding-top: 8px;
- border-top: 1px dashed var(--sw-line);
-}
-.pt-cell {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 3px;
- padding: 2px 4px 0;
- min-width: 0;
-}
-.pt-cell-label {
- font-size: 10.5px;
- color: var(--sw-fg-3);
- text-transform: uppercase;
- letter-spacing: 0.06em;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- max-width: 100%;
- text-align: center;
-}
-.pt-cell-label .cell-unit {
- margin-left: 4px;
- text-transform: none;
- letter-spacing: 0;
- font-size: 10px;
- color: var(--sw-fg-3);
-}
-.pt-cell-value {
- display: flex;
- align-items: baseline;
- justify-content: center;
- font-variant-numeric: tabular-nums;
-}
-.pt-cell-value .num {
- font-size: 26px;
- font-weight: 600;
- color: var(--cell-color, var(--sw-fg-0));
- letter-spacing: -0.02em;
- line-height: 1.05;
-}
-.pt-cell-trend {
- margin-top: 4px;
- width: 100%;
- max-width: 140px;
- height: 34px;
- display: block;
-}
-.preview-hint {
- margin-top: 6px;
-}
-.group-sep {
- font-size: 9px;
- text-transform: uppercase;
- letter-spacing: 0.1em;
- color: var(--sw-fg-3);
- align-self: center;
- margin: 0 6px;
-}
-.actions {
- display: flex;
- align-items: center;
- gap: 10px;
- padding-top: 6px;
- border-top: 1px dashed var(--sw-line);
-}
-.actions .hint {
- margin-left: auto;
- font-size: 10.5px;
- color: var(--sw-fg-3);
-}
-.row-with-toggle {
- display: flex;
- align-items: baseline;
- gap: 10px;
- margin-bottom: 6px;
-}
-.row-with-toggle h4 {
- margin: 0;
-}
-.row-with-toggle .sw-btn {
- margin-left: auto;
- height: 22px;
- font-size: 10.5px;
- padding: 0 8px;
-}
-.hint.subtle {
- margin: -2px 0 8px;
- font-size: 10.5px;
- color: var(--sw-fg-3);
- line-height: 1.5;
-}
-.hint.subtle code {
- font-family: var(--sw-mono);
- font-size: 10px;
- background: var(--sw-bg-2);
- padding: 1px 4px;
- border-radius: 3px;
-}
-.col-editor {
- width: 100%;
- border-collapse: collapse;
- font-size: 11px;
- /* No `table-layout: fixed` — we want the MQE column to grow with
- * its content so expressions like `service_percentile{p='99'}` are
- * fully visible. Narrow columns (#, Unit, Aggregate, actions) get
- * explicit `width` hints to keep them tight; the wide-content
- * columns (Label, MQE, Tip) absorb the remaining width. */
-}
-.col-editor th:nth-child(1),
-.col-editor td:nth-child(1) { width: 24px; } /* # */
-.col-editor th:nth-child(4),
-.col-editor td:nth-child(4) { width: 60px; } /* Unit */
-.col-editor th:nth-child(5),
-.col-editor td:nth-child(5) { width: 80px; } /* Aggregate */
-.col-editor th.row-actions,
-.col-editor td.row-actions { width: 84px; white-space: nowrap; }
-/* MQE column claims roughly 2x the others — its input grows to
- * fit the cell, so the cell's natural width is what matters. */
-.col-editor th:nth-child(3),
-.col-editor td:nth-child(3) { min-width: 240px; }
-.col-editor th {
- text-align: left;
- font-weight: 500;
- font-size: 10px;
- color: var(--sw-fg-3);
- letter-spacing: 0.04em;
- padding: 4px 6px 6px;
- border-bottom: 1px solid var(--sw-line);
-}
-.col-editor td {
- padding: 4px 6px;
- vertical-align: middle;
-}
-.col-editor .metric-key {
- font-family: var(--sw-mono);
- font-size: 10.5px;
- color: var(--sw-fg-2);
-}
-.col-editor .ctl {
- width: 100%;
- height: 22px;
- padding: 0 6px;
- background: var(--sw-bg-2);
- border: 1px solid var(--sw-line-2);
- border-radius: 3px;
- color: var(--sw-fg-0);
- font: inherit;
- font-size: 11px;
-}
-.col-editor .ctl.narrow {
- max-width: 70px;
-}
-.col-editor .ctl.mono,
-.field-grid .mono {
- font-family: var(--sw-mono);
- font-size: 10.5px;
-}
-.field-grid label.wide-2 {
- grid-column: span 2;
-}
-</style>
diff --git a/apps/ui/src/features/setup/SetupView.vue b/apps/ui/src/features/setup/SetupView.vue
deleted file mode 100644
index 1495fe5..0000000
--- a/apps/ui/src/features/setup/SetupView.vue
+++ /dev/null
@@ -1,416 +0,0 @@
-<!--
- Licensed to the Apache Software Foundation (ASF) under one or more
- contributor license agreements. See the NOTICE file distributed with
- this work for additional information regarding copyright ownership.
- The ASF licenses this file to You under the Apache License, Version 2.0
- (the "License"); you may not use this file except in compliance with
- the License. You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<!--
- Admin · Overview setup. Lists every overview dashboard bundled with
- the BFF (Services / Mesh / OAP / Satellite / Java Agent / Go Agent
- in the default install). Selecting one opens a read-only summary of
- its config: visibility, referenced layers, widget breakdown.
-
- Editing is intentionally read-only in v1 — overview dashboards are
- bundled JSON files in `apps/bff/src/bundled_templates/overviews/`
- and a future iteration will land a JSON editor + write endpoint so
- operators can override them through the UI. For now this page is the
- catalogue + provenance view; edits happen by patching the bundled
- JSON.
--->
-<script setup lang="ts">
-import { computed, ref, watch } from 'vue';
-import { useQuery } from '@tanstack/vue-query';
-import type { OverviewDashboard, OverviewWidget } from '@skywalking-horizon-ui/api-client';
-import { bffClient } from '@/api/client';
-import { useOverviewDashboards } from '@/render/overview/useOverviewDashboards';
-
-const { isLoading, all, publicOverviews, operateOverviews } = useOverviewDashboards();
-
-const selectedId = ref<string>('');
-watch(
- all,
- (rows) => {
- if (rows.length === 0) {
- selectedId.value = '';
- return;
- }
- if (!rows.some((d) => d.id === selectedId.value)) {
- selectedId.value = rows[0].id;
- }
- },
- { immediate: true },
-);
-
-const selectedListEntry = computed(
- () => all.value.find((d) => d.id === selectedId.value) ?? null,
-);
-
-const detailQuery = useQuery({
- queryKey: ['overview-dashboard-detail', selectedId],
- queryFn: () => bffClient.overview.get(selectedId.value),
- enabled: computed(() => selectedId.value.length > 0),
- staleTime: 60_000,
-});
-
-const detail = computed<OverviewDashboard | null>(
- () => detailQuery.data.value?.dashboard ?? null,
-);
-
-const widgetSummary = computed(() => {
- const widgets: OverviewWidget[] = detail.value?.widgets ?? [];
- const counts = new Map<string, number>();
- for (const w of widgets) counts.set(w.type, (counts.get(w.type) ?? 0) + 1);
- return Array.from(counts.entries())
- .map(([type, count]) => ({ type, count }))
- .sort((a, b) => a.type.localeCompare(b.type));
-});
-
-/** Collapsible rail toggle — same pattern as /admin/layer-dashboards. */
-const listOpen = ref(true);
-</script>
-
-<template>
- <div class="setup">
- <header class="page-head">
- <div>
- <div class="kicker">Admin · Overview setup</div>
- <h1>Overview dashboards</h1>
- <p class="lede">
- Cross-layer dashboards that appear in the top of the sidebar (public) or
- under the Admin section (operate-only). Bundled with the BFF — edit the
- source JSON under
- <code>apps/bff/src/bundled_templates/overviews/</code> to change a
- dashboard, then restart for the change to take effect.
- </p>
- </div>
- </header>
-
- <div v-if="all.length === 0 && !isLoading" class="empty">
- <div class="empty-card">
- <h2>No overview dashboards loaded</h2>
- <p>
- The BFF didn't return any overview dashboards. Check that
- <code>apps/bff/src/bundled_templates/overviews/*.json</code> are reachable
- from the running BFF.
- </p>
- </div>
- </div>
-
- <div v-else class="grid" :class="{ 'list-collapsed': !listOpen }">
- <aside class="sw-card list" :class="{ collapsed: !listOpen }">
- <div class="list-head">
- <button
- class="list-toggle"
- type="button"
- :title="listOpen ? 'Collapse the dashboards list' : 'Expand the dashboards list'"
- @click="listOpen = !listOpen"
- >
- <span class="caret" :class="{ open: listOpen }">›</span>
- </button>
- <h4 v-if="listOpen">Dashboards</h4>
- <span v-if="listOpen" class="sub">
- {{ all.length }} bundled
- </span>
- </div>
-
- <template v-if="listOpen">
- <template v-if="publicOverviews.length > 0">
- <div class="kicker-row">Public</div>
- <button
- v-for="d in publicOverviews"
- :key="d.id"
- class="row"
- :class="{ active: selectedId === d.id }"
- @click="selectedId = d.id"
- >
- <span class="dot pub" />
- <span class="name">{{ d.title }}</span>
- <span class="count">{{ d.widgetCount }}</span>
- </button>
- </template>
- <template v-if="operateOverviews.length > 0">
- <div class="kicker-row">Operate</div>
- <button
- v-for="d in operateOverviews"
- :key="d.id"
- class="row"
- :class="{ active: selectedId === d.id }"
- @click="selectedId = d.id"
- >
- <span class="dot op" />
- <span class="name">{{ d.title }}</span>
- <span class="count">{{ d.widgetCount }}</span>
- </button>
- </template>
- </template>
- <template v-else>
- <button
- v-for="d in all"
- :key="d.id"
- class="row collapsed-row"
- :class="{ active: selectedId === d.id }"
- :title="d.title"
- @click="selectedId = d.id"
- >
- <span class="dot" :class="d.visibility === 'operate' ? 'op' : 'pub'" />
- </button>
- </template>
- </aside>
-
- <main class="detail">
- <div v-if="!selectedListEntry" class="placeholder sw-card">
- Select a dashboard on the left to inspect its config.
- </div>
- <template v-else>
- <section class="sw-card meta">
- <header class="meta-head">
- <div>
- <h3>{{ selectedListEntry.title }}</h3>
- <div class="meta-sub">
- <span
- class="sw-badge"
- :class="selectedListEntry.visibility === 'operate' ? 'warn' : 'ok'"
- >
- {{ selectedListEntry.visibility === 'operate' ? 'Operate' : 'Public' }}
- </span>
- <span class="meta-key">id: <code>{{ selectedListEntry.id }}</code></span>
- <span
- v-if="typeof selectedListEntry.order === 'number'"
- class="meta-key"
- >
- order: {{ selectedListEntry.order }}
- </span>
- </div>
- </div>
- <RouterLink class="sw-btn small" :to="`/overview/${selectedListEntry.id}`">
- Open dashboard →
- </RouterLink>
- </header>
- <p v-if="selectedListEntry.description" class="desc">
- {{ selectedListEntry.description }}
- </p>
- <div v-if="(selectedListEntry.layers ?? []).length > 0" class="layers">
- <span class="layers-label">Layers</span>
- <span
- v-for="L in selectedListEntry.layers ?? []"
- :key="L"
- class="layer-chip"
- >
- {{ L }}
- </span>
- </div>
- </section>
-
- <section class="sw-card widgets">
- <header class="sec-head">
- <h4>Widget breakdown</h4>
- <span class="sub">{{ selectedListEntry.widgetCount }} widgets</span>
- </header>
- <div v-if="detailQuery.isLoading.value" class="loading">Loading widget detail…</div>
- <div v-else-if="detail" class="widget-list">
- <div v-if="widgetSummary.length > 0" class="summary-row">
- <span
- v-for="entry in widgetSummary"
- :key="entry.type"
- class="type-chip"
- >
- {{ entry.type }} <b>{{ entry.count }}</b>
- </span>
- </div>
- <table class="widget-table">
- <thead>
- <tr>
- <th>ID</th>
- <th>Title</th>
- <th>Type</th>
- <th>Layer</th>
- <th>Span</th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="w in detail.widgets" :key="w.id">
- <td><code>{{ w.id }}</code></td>
- <td>{{ w.title }}</td>
- <td>{{ w.type }}</td>
- <td>{{ w.layer ?? '—' }}</td>
- <td>
- <span v-if="w.span">{{ w.span }}</span>
- <span v-if="w.rowSpan">×{{ w.rowSpan }}</span>
- <span v-if="!w.span && !w.rowSpan">—</span>
- </td>
- </tr>
- </tbody>
- </table>
- </div>
- </section>
-
- <section class="sw-card note">
- <p>
- <strong>Editing</strong>: overview dashboards are bundled JSON files.
- To customise this dashboard, edit
- <code>apps/bff/src/bundled_templates/overviews/{{ selectedListEntry.id }}.json</code>
- and restart the BFF. A JSON editor in the UI is planned for a later phase.
- </p>
- </section>
- </template>
- </main>
- </div>
- </div>
-</template>
-
-<style scoped>
-.setup { padding: 20px 20px 60px; max-width: 1440px; margin: 0 auto; }
-.page-head { margin-bottom: 18px; }
-.kicker {
- font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em;
- color: var(--sw-accent); margin-bottom: 6px;
-}
-.page-head h1 {
- font-size: 22px; font-weight: 600; letter-spacing: -0.02em; color: var(--sw-fg-0);
- margin: 0 0 6px;
-}
-.lede {
- font-size: 12px; color: var(--sw-fg-2); line-height: 1.5; margin: 0; max-width: 720px;
-}
-.lede code {
- font-family: var(--sw-mono); font-size: 11px; padding: 0 4px; border-radius: 3px;
- background: var(--sw-bg-2); color: var(--sw-fg-1);
-}
-
-.empty { margin-top: 20px; }
-.empty-card {
- background: var(--sw-bg-1); border: 1px dashed var(--sw-line-2); border-radius: 10px;
- padding: 28px; text-align: center; max-width: 600px; margin: 0 auto;
-}
-.empty-card h2 { font-size: 15px; color: var(--sw-fg-0); margin: 0 0 6px; }
-.empty-card p { font-size: 12px; color: var(--sw-fg-2); margin: 0; }
-.empty-card code {
- font-family: var(--sw-mono); font-size: 11px; padding: 0 4px; border-radius: 3px;
- background: var(--sw-bg-2); color: var(--sw-fg-1);
-}
-
-.grid {
- display: grid; grid-template-columns: 240px 1fr; gap: 14px; align-items: start;
- transition: grid-template-columns 160ms ease;
-}
-.grid.list-collapsed { grid-template-columns: 36px 1fr; }
-.list {
- padding: 8px; display: flex; flex-direction: column; gap: 2px;
- align-self: start; position: sticky; top: 16px;
-}
-.list.collapsed { padding: 6px 4px; }
-.list-head {
- display: flex; align-items: center; gap: 4px;
- padding: 6px 10px 10px; border-bottom: 1px solid var(--sw-line); margin-bottom: 6px;
-}
-.list.collapsed .list-head {
- border-bottom: 1px solid var(--sw-line); padding: 4px 0 6px; justify-content: center;
-}
-.list-head h4 { margin: 0; font-size: 11.5px; font-weight: 600; color: var(--sw-fg-0); }
-.list-head .sub { font-size: 10px; color: var(--sw-fg-3); margin-left: auto; }
-.list-toggle {
- flex: 0 0 auto; width: 22px; height: 22px; margin-right: 4px;
- background: transparent; border: none; color: var(--sw-fg-3); cursor: pointer;
- font: inherit; border-radius: 3px; display: inline-grid; place-items: center;
-}
-.list-toggle:hover { background: var(--sw-bg-2); color: var(--sw-fg-1); }
-.list-toggle .caret { display: inline-block; font-size: 13px; line-height: 1; transition: transform 0.15s; }
-.list-toggle .caret.open { transform: rotate(90deg); }
-
-.kicker-row {
- font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600;
- color: var(--sw-fg-3); padding: 8px 10px 4px;
-}
-.row {
- display: flex; align-items: center; gap: 8px;
- padding: 7px 10px; border-radius: 5px;
- background: transparent; border: none; color: var(--sw-fg-1);
- font-size: 12px; cursor: pointer; text-align: left; font: inherit;
-}
-.row:hover { background: var(--sw-bg-2); }
-.row.active {
- background: var(--sw-bg-3); color: var(--sw-fg-0);
- box-shadow: inset 2px 0 0 var(--sw-accent);
-}
-.row .dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 7px; }
-.row .dot.pub { background: var(--sw-accent); }
-.row .dot.op { background: var(--sw-warn); }
-.row .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.row .count {
- font-family: var(--sw-mono); font-size: 10px; color: var(--sw-fg-3);
-}
-.row.active .count { color: var(--sw-fg-2); }
-.collapsed-row { justify-content: center; padding: 6px 4px; }
-.collapsed-row .dot { width: 10px; height: 10px; }
-
-.detail { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
-.placeholder { padding: 28px; text-align: center; color: var(--sw-fg-3); font-size: 12px; }
-
-.meta { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; }
-.meta-head { display: flex; align-items: flex-start; gap: 12px; }
-.meta-head h3 { margin: 0 0 6px; font-size: 16px; color: var(--sw-fg-0); }
-.meta-sub { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
-.meta-key { font-size: 10.5px; color: var(--sw-fg-3); }
-.meta-key code {
- font-family: var(--sw-mono); color: var(--sw-fg-1);
- background: var(--sw-bg-2); padding: 1px 4px; border-radius: 3px;
-}
-.desc { margin: 0; font-size: 12px; color: var(--sw-fg-2); line-height: 1.5; }
-.layers { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
-.layers-label {
- font-size: 10px; color: var(--sw-fg-3); text-transform: uppercase; letter-spacing: 0.08em;
-}
-.layer-chip {
- font-family: var(--sw-mono); font-size: 10.5px;
- padding: 2px 6px; border-radius: 3px;
- background: var(--sw-bg-2); border: 1px solid var(--sw-line);
- color: var(--sw-fg-1);
-}
-
-.widgets { padding: 14px 16px; }
-.sec-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; }
-.sec-head h4 { margin: 0; font-size: 12.5px; font-weight: 600; color: var(--sw-fg-0); }
-.sec-head .sub { font-size: 10.5px; color: var(--sw-fg-3); margin-left: auto; }
-.loading { font-size: 11px; color: var(--sw-fg-3); }
-.widget-list { display: flex; flex-direction: column; gap: 10px; }
-.summary-row { display: flex; flex-wrap: wrap; gap: 6px; }
-.type-chip {
- font-size: 10.5px; padding: 3px 8px; border-radius: 10px;
- background: var(--sw-bg-2); border: 1px solid var(--sw-line); color: var(--sw-fg-2);
-}
-.type-chip b { color: var(--sw-fg-0); margin-left: 4px; }
-.widget-table {
- width: 100%; border-collapse: collapse; font-size: 11.5px;
-}
-.widget-table th, .widget-table td {
- text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--sw-line);
-}
-.widget-table th {
- font-weight: 600; font-size: 10.5px; text-transform: uppercase;
- letter-spacing: 0.06em; color: var(--sw-fg-3);
-}
-.widget-table td code {
- font-family: var(--sw-mono); font-size: 10.5px; color: var(--sw-fg-2);
-}
-
-.note { padding: 12px 16px; background: var(--sw-bg-1); }
-.note p { margin: 0; font-size: 11.5px; color: var(--sw-fg-2); line-height: 1.5; }
-.note code {
- font-family: var(--sw-mono); font-size: 10.5px;
- padding: 1px 4px; border-radius: 3px;
- background: var(--sw-bg-2); color: var(--sw-fg-1);
-}
-
-.sw-badge.ok { background: rgba(34, 197, 94, 0.12); color: var(--sw-ok); }
-.sw-badge.warn { background: rgba(234, 179, 8, 0.12); color: var(--sw-warn); }
-</style>
diff --git a/apps/ui/src/render/overview/OverviewDashboardView.vue b/apps/ui/src/render/overview/OverviewDashboardView.vue
index 7e4cc47..de993ef 100644
--- a/apps/ui/src/render/overview/OverviewDashboardView.vue
+++ b/apps/ui/src/render/overview/OverviewDashboardView.vue
@@ -119,6 +119,7 @@ function widgetStyle(span?: number, rowSpan?: number, cols = 12): Record<string,
:title="w.title"
:tip="w.tip"
:limit="w.limit"
+ :layer="w.layer"
:style="widgetStyle(w.span, w.rowSpan, sec.cols)"
/>
<!-- Topology: the SAME LayerServiceMapView component the
diff --git a/apps/ui/src/render/overview/OverviewLanding.vue b/apps/ui/src/render/overview/OverviewLanding.vue
index 7ddbb78..991185c 100644
--- a/apps/ui/src/render/overview/OverviewLanding.vue
+++ b/apps/ui/src/render/overview/OverviewLanding.vue
@@ -59,7 +59,9 @@ watchEffect(() => {
but no overview is set to <code>visibility: public</code>. Operations-only
overviews are reachable from the Admin section in the sidebar.
</p>
- <RouterLink class="sw-btn is-primary" to="/setup">Open Overview setup</RouterLink>
+ <RouterLink class="sw-btn is-primary" to="/admin/overview-templates">
+ Open Overview templates
+ </RouterLink>
</div>
</div>
</div>
diff --git a/apps/ui/src/render/widgets/AlarmsWidget.vue b/apps/ui/src/render/widgets/AlarmsWidget.vue
index 2ae8e60..1bb7d20 100644
--- a/apps/ui/src/render/widgets/AlarmsWidget.vue
+++ b/apps/ui/src/render/widgets/AlarmsWidget.vue
@@ -15,51 +15,185 @@
limitations under the License.
-->
<!--
- Active-alarms widget for the per-overview dashboards. Renders a
- placeholder list until the OAP `getAlarm` query is plumbed through
- (alarms are read-only and recover server-side, so this stays a list,
- not an action surface). Visual treatment mirrors `AlarmsPanel.vue` so
- the two read as siblings.
+ Active-alarms rail for overview dashboards. Polls `/api/alarms` over
+ the last 60 minutes and shows the top N rows + the total.
+
+ Dual-mode (driven by `useOapInfo().capabilities.queryAlarms`):
+ - **New API (`queryAlarms` available)** — narrows to the dashboard's
+ layer via the new entities/layers filter. The widget shows alarms
+ scoped to that layer.
+ - **Legacy API (`getAlarm` only)** — no server-side layer filter
+ exists. We fetch all-layers and surface a small "all layers"
+ banner so the operator understands why the rail isn't matching
+ the dashboard's layer.
+
+ Read-only by design. Click "view all" to jump to /alarms for triage.
-->
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
+import { useQuery } from '@tanstack/vue-query';
import Icon from '@/components/icons/Icon.vue';
+import {
+ bff,
+ OVERVIEW_ALARMS_LIMIT_DEFAULT,
+ type AlarmMessage,
+ type AlarmsConfig,
+} from '@/api/client';
+import { useOapInfo } from '@/shell/useOapInfo';
+import { formatAlarmEntity } from '@/utils/alarmEntity';
+import { mergeIncidents, type AlarmIncident } from '@/utils/alarmIncidents';
+
+const props = withDefaults(
+ defineProps<{
+ title: string;
+ tip?: string;
+ /** Top-N cap; defaults to 10. The BFF still fetches up to 500 so
+ * the "total" count is meaningful even when only N rows render. */
+ limit?: number;
+ /** Overview's layer (`GENERAL`, `MESH`, …). Used only in
+ * new-API mode for server-side filtering; ignored in legacy. */
+ layer?: string;
+ }>(),
+ { limit: 10 },
+);
+
+const WINDOW_MS = 60 * 60_000;
+
+const { capabilities } = useOapInfo();
+const hasQueryAlarms = computed<boolean>(() => capabilities.value.queryAlarms);
+
+/* Shares the queryKey `['alarms/config']` with the page + admin
+ * view so the per-poll fetch cap (`overviewAlarmsLimit`) stays in
+ * sync without a separate roundtrip. */
+const cfgQuery = useQuery({
+ queryKey: ['alarms/config'],
+ queryFn: (): Promise<AlarmsConfig> => bff.alarms.config(),
+ staleTime: Infinity,
+});
+const fetchLimit = computed<number>(
+ () => cfgQuery.data.value?.overviewAlarmsLimit ?? OVERVIEW_ALARMS_LIMIT_DEFAULT,
+);
+
+const alarmsQuery = useQuery({
+ /* Layer is keyed only in new-API mode — legacy mode ignores it
+ * and re-uses a single all-layers cache across every overview.
+ * `fetchLimit` is in the key so a config change re-fetches with
+ * the new cap on the next render tick. */
+ queryKey: computed(() => [
+ 'overview-alarms',
+ hasQueryAlarms.value,
+ hasQueryAlarms.value ? props.layer ?? '' : 'all',
+ fetchLimit.value,
+ ]),
+ queryFn: () => {
+ const end = Date.now();
+ const start = end - WINDOW_MS;
+ return bff.alarms.list({
+ startTime: start,
+ endTime: end,
+ layer: hasQueryAlarms.value && props.layer ? props.layer : undefined,
+ pageSize: fetchLimit.value,
+ });
+ },
+ staleTime: 30_000,
+ refetchInterval: 60_000,
+ refetchOnWindowFocus: false,
+});
+
+const alarms = computed<AlarmMessage[]>(() => alarmsQuery.data.value?.msgs ?? []);
+const truncated = computed<boolean>(() => alarmsQuery.data.value?.truncated ?? false);
+
+/* In legacy mode the BFF can't server-side-filter by layer, but every
+ * row already carries `layerKey` (resolved against the
+ * service-layer-map). Filter client-side so the widget shows only
+ * the layer the dashboard is scoped to — same observable behavior as
+ * the new-API path. Rows whose `layerKey` couldn't be resolved
+ * (unknown service prefix, instance/endpoint scopes) drop out
+ * silently; an unresolved row can't be confidently attributed to
+ * any layer, so showing it on a per-layer dashboard would mislead. */
+const layerScoped = computed<AlarmMessage[]>(() => {
+ if (hasQueryAlarms.value || !props.layer) return alarms.value;
+ const wanted = props.layer.toUpperCase();
+ return alarms.value.filter((a) => (a.layerKey ?? '').toUpperCase() === wanted);
+});
-const props = defineProps<{ title: string; tip?: string; limit?: number }>();
+/* Counts use `mergeIncidents` (group by (entity, rule); active iff
+ * any latest-event is firing) so the header reads the same metric
+ * as the topbar badge + page totals — one count per noisy rule, not
+ * one per re-fire. */
+const incidents = computed<AlarmIncident[]>(() => mergeIncidents(layerScoped.value));
+const activeIncidents = computed<number>(
+ () => incidents.value.filter((i) => i.state === 'firing').length,
+);
-const placeholderRows = [
- { sev: 'err', rule: 'Service SLA below 98%', scope: '— · awaiting OAP getAlarm', since: '—' },
- { sev: 'warn', rule: 'JVM Old GC > 5s/min', scope: '— · awaiting OAP getAlarm', since: '—' },
- { sev: 'info', rule: 'Deployment detected', scope: '— · awaiting OAP getAlarm', since: '—' },
-];
-const rows = computed(() =>
- props.limit && props.limit > 0 ? placeholderRows.slice(0, props.limit) : placeholderRows,
+/* Rail rows = active incidents only, one per (entity, rule), top
+ * `limit` by recency. The page list expands each firing into its
+ * own row for triage; the rail intentionally stays compact — a
+ * noisy rule that fired 4× shows as one row with a `· 4×` chip, not
+ * four. Recovered-only incidents don't appear here at all (they
+ * don't count and they push down active ones). The full triage view
+ * is one click away via "View all". */
+const rows = computed<AlarmIncident[]>(() =>
+ incidents.value.filter((i) => i.state === 'firing').slice(0, props.limit),
);
+
+function formatRelative(ts: number): string {
+ const delta = Date.now() - ts;
+ if (delta < 0) return new Date(ts).toLocaleString();
+ const s = Math.floor(delta / 1000);
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ if (m < 60) return `${m}m`;
+ const h = Math.floor(m / 60);
+ return `${h}h${m % 60 > 0 ? ` ${m % 60}m` : ''}`;
+}
+
+function entityShort(a: AlarmMessage): string {
+ const l = formatAlarmEntity(a.scope, a.name);
+ /* Drop group prefixes (e.g. `mesh-svr::`) from the rail since the
+ * rail is tight. The detail panel keeps them. */
+ return l.segments
+ .map((s) => (s.kind === 'group' ? s.base : s.text))
+ .join('');
+}
+
+function incidentEntity(i: AlarmIncident): string {
+ return entityShort(i.latest);
+}
</script>
<template>
<section class="sw-card alarms-widget">
<header>
<h4>{{ title }}</h4>
- <span class="sw-badge">Phase 5</span>
+ <span class="total mono" :class="{ active: activeIncidents > 0 }">
+ {{ activeIncidents }}{{ truncated ? '+' : '' }} active
+ </span>
<RouterLink class="all-link" to="/alarms">
<span>View all</span>
<Icon name="chev" :size="10" />
</RouterLink>
</header>
+
<div class="body">
- <p class="lede">
- Read-only — alarms recover backend-side. Layer filter is best-effort in v1.
- </p>
- <div class="rows">
- <div v-for="(a, i) in rows" :key="i" class="alarm-row">
- <span class="sw-badge dot" :class="a.sev">{{ a.sev }}</span>
+ <div v-if="alarmsQuery.isPending.value" class="empty">loading…</div>
+ <div v-else-if="rows.length === 0" class="empty">
+ No alarms in the last 60m{{ layer ? ` for ${layer}` : '' }}.
+ </div>
+ <div v-else class="rows">
+ <div
+ v-for="inc in rows"
+ :key="inc.id"
+ class="alarm-row"
+ :class="{ resolved: inc.state === 'recovered' }"
+ >
+ <span class="sev-dot" :class="inc.state === 'firing' ? 'is-err' : 'is-ok'" />
<div class="alarm-text">
- <div class="rule">{{ a.rule }}</div>
- <div class="scope">{{ a.scope }}</div>
+ <div class="rule">{{ inc.latest.message }}</div>
+ <div class="scope">{{ incidentEntity(inc) }}</div>
</div>
- <span class="since">{{ a.since }}</span>
+ <span class="since mono">{{ formatRelative(inc.latest.startTime) }}</span>
</div>
</div>
</div>
@@ -74,23 +208,40 @@ header {
border-bottom: 1px solid var(--sw-line);
}
h4 { margin: 0; font-size: 12px; font-weight: 600; color: var(--sw-fg-0); }
+.total {
+ font-size: 11px;
+ color: var(--sw-fg-3);
+ font-variant-numeric: tabular-nums;
+}
+.total.active { color: var(--sw-err); font-weight: 600; }
.all-link {
margin-left: auto; font-size: 11px; color: var(--sw-fg-2);
text-decoration: none; display: inline-flex; align-items: center; gap: 4px;
}
.all-link:hover { color: var(--sw-accent-2); }
.body { padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 8px; flex: 1; overflow: hidden; }
-.lede { margin: 0; font-size: 10.5px; color: var(--sw-fg-3); line-height: 1.5; }
+.empty {
+ padding: 16px;
+ text-align: center;
+ font-size: 11px;
+ color: var(--sw-fg-3);
+}
.rows { display: flex; flex-direction: column; gap: 4px; overflow: auto; }
.alarm-row {
display: flex; align-items: flex-start; gap: 8px; padding: 6px 0;
border-top: 1px solid var(--sw-line); font-size: 11px;
}
+.alarm-row.resolved { opacity: 0.6; }
.alarm-row:first-child { border-top: none; }
-.sw-badge.dot::before {
- content: ''; width: 5px; height: 5px; border-radius: 50%;
- background: currentColor; display: inline-block; margin-right: 3px;
+.sev-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ margin-top: 5px;
+ flex-shrink: 0;
}
+.sev-dot.is-err { background: var(--sw-err); }
+.sev-dot.is-ok { background: var(--sw-ok); }
.alarm-text { flex: 1; min-width: 0; }
.alarm-text .rule {
color: var(--sw-fg-1); font-weight: 500;
@@ -100,5 +251,5 @@ h4 { margin: 0; font-size: 12px; font-weight: 600; color: var(--sw-fg-0); }
font-family: var(--sw-mono); color: var(--sw-fg-3); font-size: 10px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
-.since { color: var(--sw-fg-3); font-size: 10px; flex: 0 0 auto; }
+.since { color: var(--sw-fg-3); font-size: 10px; flex: 0 0 auto; font-variant-numeric: tabular-nums; }
</style>
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index 46d48b7..e69d46b 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -24,8 +24,13 @@ import { useLayers, firstLayerTab } from '@/shell/useLayers';
import { useLandingOrder } from '@/shell/useLandingOrder';
import { useOverviewDashboards } from '@/render/overview/useOverviewDashboards';
import { useDebugPanel } from '@/controls/debugPanel';
+import { useAlarmCount } from '@/shell/useAlarmCount';
const { enabled: debugPanelEnabled, toggle: toggleDebugPanel } = useDebugPanel();
+/* Shares the same composable as the topbar badge — one query feeds
+ * both, so the two surfaces never disagree and the cache is warm
+ * regardless of which renders first. */
+const alarmCount = useAlarmCount();
const auth = useAuthStore();
const router = useRouter();
@@ -154,6 +159,7 @@ const sections: NavSection[] = [
kicker: 'Operate',
links: [
{ icon: 'svc', label: 'Cluster status', to: '/operate/cluster' },
+ { icon: 'alert', label: 'Alerting rules', to: '/operate/alerting-rules' },
{
icon: 'flame',
label: 'Live debugger',
@@ -185,7 +191,7 @@ const sections: NavSection[] = [
{
kicker: 'Dashboard setup',
links: [
- { icon: 'set', label: 'Overview dashboards', to: '/setup' },
+ { icon: 'set', label: 'Overview templates', to: '/admin/overview-templates' },
{ icon: 'metric', label: 'Layer dashboards', to: '/admin/layer-dashboards' },
{ icon: 'alert', label: 'Alert page', to: '/admin/alert-page-setup' },
],
@@ -256,7 +262,11 @@ watch(
:class="{ 'is-active': isActive('/alarms') }"
>
<Icon name="alert" /><span>Alarms</span>
- <span class="sw-badge err" style="margin-left: auto">7</span>
+ <span
+ v-if="alarmCount.activeIncidents.value > 0"
+ class="sw-badge err"
+ style="margin-left: auto"
+ >{{ alarmCount.displayCount.value }}</span>
</RouterLink>
<div class="sw-nav-section sw-nav-section--icon" style="justify-content: space-between">
diff --git a/apps/ui/src/shell/AppTopbar.vue b/apps/ui/src/shell/AppTopbar.vue
index b4bc9f7..901ab0b 100644
--- a/apps/ui/src/shell/AppTopbar.vue
+++ b/apps/ui/src/shell/AppTopbar.vue
@@ -19,6 +19,7 @@ import { computed, ref, watch } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import Icon from '@/components/icons/Icon.vue';
import { useOapInfo } from '@/shell/useOapInfo';
+import { useAlarmCount } from '@/shell/useAlarmCount';
import { useAutoRefreshStore } from '@/controls/autoRefresh';
import { useTimeRangeStore, TIME_PRESETS, STEP_LIMITS, isValidRange, type TimeStep } from '@/controls/timeRange';
@@ -26,6 +27,32 @@ const route = useRoute();
const { info, reachable, tzOffsetLabel, healthState } = useOapInfo();
+/* Alarm badge — independent 60s timer, rolling 20m window. The
+ * badge sits next to OAP / time / refresh because alarms are a
+ * top-level concern; clicking jumps straight to /alarms regardless of
+ * which page the operator is on. */
+const alarmCount = useAlarmCount();
+const alarmBadgeTooltip = computed<string>(() => {
+ if (alarmCount.hasError.value) {
+ return `Alarms unavailable: ${alarmCount.errorMessage.value ?? 'no response'}`;
+ }
+ const windowMin = Math.round(alarmCount.windowMs.value / 60_000);
+ const active = alarmCount.activeIncidents.value;
+ const inc = alarmCount.incidents.value;
+ const cap = alarmCount.truncated.value ? ' (capped — open the page for the full list)' : '';
+ if (active === 0) {
+ if (inc > 0) {
+ return `No active alarms in the last ${windowMin}m · ${inc} recovered incident${inc === 1 ? '' : 's'}`;
+ }
+ return `No alarms in the last ${windowMin}m`;
+ }
+ return `${active} active incident${active === 1 ? '' : 's'} in the last ${windowMin}m${cap}`;
+});
+const alarmBadgeState = computed<'ok' | 'err' | 'unknown'>(() => {
+ if (alarmCount.hasError.value) return 'unknown';
+ return alarmCount.activeIncidents.value > 0 ? 'err' : 'ok';
+});
+
const oapChipTooltip = computed<string>(() => {
if (!info.value) return 'OAP status — loading…';
if (!reachable.value) {
@@ -472,7 +499,15 @@ function formatRangeStamp(ms: number, step: TimeStep): string {
</ul>
</transition>
</div>
- <div class="sw-btn is-icon"><Icon name="bell" :size="12" /></div>
+ <RouterLink
+ class="sw-btn alarm-badge"
+ :class="`is-${alarmBadgeState}`"
+ :title="alarmBadgeTooltip"
+ to="/alarms"
+ >
+ <Icon name="bell" :size="12" />
+ <span class="alarm-count mono">{{ alarmCount.displayCount.value }}</span>
+ </RouterLink>
</div>
</header>
</template>
@@ -772,4 +807,45 @@ function formatRangeStamp(ms: number, step: TimeStep): string {
70% { box-shadow: 0 0 0 6px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
+
+/* ── Alarm badge ───────────────────────────────────────────────── */
+/* Bell + count, same chip footprint as the OAP pill. Red fill when
+ * any alarm fired in the window, neutral when clean, grey when the
+ * BFF can't reach OAP. Click jumps to /alarms. */
+.alarm-badge {
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0 8px;
+}
+.alarm-badge .alarm-count {
+ font-size: 10.5px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ min-width: 14px;
+ text-align: center;
+}
+.alarm-badge.is-ok {
+ color: var(--sw-fg-2);
+}
+.alarm-badge.is-ok .alarm-count {
+ color: var(--sw-fg-2);
+}
+.alarm-badge.is-err {
+ color: var(--sw-err);
+}
+.alarm-badge.is-err .alarm-count {
+ color: var(--sw-err);
+}
+.alarm-badge.is-err :deep(svg) {
+ animation: pulse-err 1.6s infinite;
+ transform-origin: 50% 50%;
+}
+.alarm-badge.is-unknown {
+ color: var(--sw-fg-3);
+}
+.alarm-badge.is-unknown .alarm-count {
+ color: var(--sw-fg-3);
+}
</style>
diff --git a/apps/ui/src/shell/router/index.ts b/apps/ui/src/shell/router/index.ts
index 2e4f666..dbaf2f8 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -128,7 +128,11 @@ const shellRoutes: RouteRecordRaw[] = [
name: 'overview-dashboard',
component: () => import('@/render/overview/OverviewDashboardView.vue'),
},
- { path: 'setup', name: 'setup', component: () => import('@/features/setup/SetupView.vue') },
+ /* Legacy `/setup` route — the read-only "Overview dashboards"
+ * browser was replaced by `/admin/overview-templates` which both
+ * lists AND edits. Redirect rather than 404 so old bookmarks /
+ * stale links land somewhere useful. */
+ { path: 'setup', redirect: '/admin/overview-templates' },
layerRoute(),
// Alarms — independent page (not a layer template / overview).
// OAP `getAlarm` proxy + background-traffic timeline + per-layer
@@ -136,6 +140,13 @@ const shellRoutes: RouteRecordRaw[] = [
{ path: 'alarms', name: 'alarms', component: () => import('@/features/alarms/AlarmsView.vue') },
// Cluster
{ path: 'operate/cluster', component: () => import('@/features/operate/cluster/ClusterStatusView.vue') },
+ // Alerting rules — read-only catalog backed by admin /status/alarm/*.
+ // Gated on admin-server reachability at the page-body level.
+ {
+ path: 'operate/alerting-rules',
+ name: 'alerting-rules',
+ component: () => import('@/features/operate/alerting-rules/AlertingRulesView.vue'),
+ },
// ── DSL Management ─────────────────────────────────────────────────
// Static sub-routes are declared first so they aren't shadowed by
// the catalog alternation regex (which would otherwise grab `edit`
@@ -195,6 +206,11 @@ const shellRoutes: RouteRecordRaw[] = [
name: 'alert-page-setup',
component: () => import('@/features/admin/alert-page/AlertPageSetupView.vue'),
},
+ {
+ path: 'admin/overview-templates',
+ name: 'overview-templates',
+ component: () => import('@/features/admin/overview-templates/OverviewTemplatesAdmin.vue'),
+ },
{ path: 'admin/users', component: placeholder, props: { title: 'Users', phase: 'Phase 7' } },
{ path: 'admin/roles', component: placeholder, props: { title: 'Roles & permissions', phase: 'Phase 7' } },
];
diff --git a/apps/ui/src/shell/useAlarmCount.ts b/apps/ui/src/shell/useAlarmCount.ts
new file mode 100644
index 0000000..efd1132
--- /dev/null
+++ b/apps/ui/src/shell/useAlarmCount.ts
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { computed } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import { bff, type AlarmsConfig } from '@/api/client';
+
+/**
+ * Topbar alarm-badge data source. Polls `/api/alarms/count` on its own
+ * 60s interval for the last `WINDOW_MS` of activity.
+ *
+ * Why this is independent of `useAutoRefreshSubscribe`:
+ * - The global auto-refresh ticker pauses on `/alarms` (and on the
+ * other per-page-owned routes — traces, logs, profiling). The
+ * badge must keep updating regardless of where the operator is.
+ * - The badge's window is fixed (last 20m), so it has nothing to
+ * align to the global time-range store.
+ *
+ * Visibility-aware: `refetchIntervalInBackground: false` (Vue Query
+ * default) means polling pauses when the tab is hidden; `refetchOn
+ * WindowFocus: true` fires an immediate refresh on tab return.
+ *
+ * `endTime` is captured once per refetch (read via `Date.now()` inside
+ * `queryFn`), so the window slides forward on every poll without
+ * needing the composable to own any reactive time state.
+ */
+
+/** Fallback window when the admin config isn't loaded yet — matches
+ * the BFF's `DEFAULT_CONFIG.defaultWindowMs`. */
+const FALLBACK_WINDOW_MS = 20 * 60_000;
+const POLL_MS = 60_000;
+
+export function useAlarmCount() {
+ /* Shares the queryKey `['alarms/config']` with the page + admin
+ * view; Vue Query dedupes the network call so the topbar badge
+ * doesn't issue an extra roundtrip just to learn the window size. */
+ const cfgQuery = useQuery({
+ queryKey: ['alarms/config'],
+ queryFn: (): Promise<AlarmsConfig> => bff.alarms.config(),
+ staleTime: Infinity,
+ });
+ const windowMs = computed<number>(
+ () => cfgQuery.data.value?.defaultWindowMs ?? FALLBACK_WINDOW_MS,
+ );
+
+ const q = useQuery({
+ /* Re-key on `windowMs` so an admin who changes the config sees
+ * the badge re-fetch with the new window on the next poll. */
+ queryKey: computed(() => ['alarms-count', windowMs.value]),
+ queryFn: () => {
+ const end = Date.now();
+ const start = end - windowMs.value;
+ return bff.alarms.count(start, end);
+ },
+ refetchInterval: POLL_MS,
+ refetchIntervalInBackground: false,
+ refetchOnWindowFocus: true,
+ staleTime: POLL_MS / 2,
+ });
+
+ const total = computed<number>(() => q.data.value?.total ?? 0);
+ const firing = computed<number>(() => q.data.value?.firing ?? 0);
+ const activeIncidents = computed<number>(() => q.data.value?.activeIncidents ?? 0);
+ const incidents = computed<number>(() => q.data.value?.incidents ?? 0);
+ const truncated = computed<boolean>(() => q.data.value?.truncated ?? false);
+ const isLoading = computed<boolean>(() => q.isLoading.value);
+ const hasError = computed<boolean>(() => q.isError.value);
+ const errorMessage = computed<string | null>(() =>
+ q.error.value ? (q.error.value as Error).message : null,
+ );
+
+ /** Pretty count for the badge body. Reads `activeIncidents` — a
+ * recovered incident counts as "no alarm". `200+` when truncated. */
+ const displayCount = computed<string>(() => {
+ if (truncated.value) return `${activeIncidents.value}+`;
+ return String(activeIncidents.value);
+ });
+
+ return {
+ total,
+ firing,
+ incidents,
+ activeIncidents,
+ truncated,
+ displayCount,
+ isLoading,
+ hasError,
+ errorMessage,
+ /** Reactive — reflects the current admin-configured window. */
+ windowMs,
+ refetch: q.refetch,
+ };
+}
diff --git a/apps/ui/src/utils/alarmEntity.ts b/apps/ui/src/utils/alarmEntity.ts
new file mode 100644
index 0000000..5515c5e
--- /dev/null
+++ b/apps/ui/src/utils/alarmEntity.ts
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Pretty-print an alarm's entity identity from its raw OAP `scope` +
+ * `name` fields.
+ *
+ * OAP's `NotifyHandler` already pre-formats the human piece into the
+ * `name` field (e.g. `serviceA to serviceB` for ServiceRelation,
+ * `instX of svcY` for ServiceInstance) — see
+ * skywalking/oap-server/.../alarm/provider/NotifyHandler.java.
+ *
+ * The UI does two things on top:
+ * 1. Prefix with the scope-as-noun ("Service", "Relation
+ * Service→Service", …) so the raw enum + base64 entity ID never
+ * appear.
+ * 2. Pull SkyWalking's `<group>::<base>` convention out of every
+ * name token in the body — `agent::app to cart::api` becomes
+ * `[AGENT] app to [CART] api`, with each `<group>::` rendered as
+ * a small chip via the `kind: 'group'` segment.
+ */
+
+import type { AlarmScope } from '@/api/client';
+
+export interface AlarmEntityLabel {
+ /** Scope rendered as a noun + relation arrows. Empty string for
+ * unknown scopes (UI can fall back to body-only). */
+ prefix: string;
+ /** Token sequence for the body. Render `group` segments as a small
+ * chip-and-name pair, `text` segments verbatim. */
+ segments: AlarmEntitySegment[];
+}
+
+export type AlarmEntitySegment =
+ | { kind: 'group'; group: string; base: string }
+ | { kind: 'text'; text: string };
+
+const PREFIX_BY_SCOPE: Record<string, string> = {
+ Service: 'Service',
+ ServiceInstance: 'Instance',
+ Endpoint: 'Endpoint',
+ Process: 'Process',
+ ServiceRelation: 'Relation Service→Service',
+ ServiceInstanceRelation: 'Relation Instance→Instance',
+ EndpointRelation: 'Relation Endpoint→Endpoint',
+ ProcessRelation: 'Relation Process→Process',
+ All: 'All',
+};
+
+/* `<group>::<base>` matcher. Greedy on the group (everything up to
+ * `::`), bounded on the base by whitespace or the relation/instance
+ * separators OAP uses (`of`, `to`, `in`). Built with a `\b` boundary
+ * on the right so a trailing `.<namespace>` stays in the base. */
+const GROUP_RE = /([A-Za-z0-9_\-]+)::([^\s]+)/g;
+
+export function splitEntityBody(body: string): AlarmEntitySegment[] {
+ const out: AlarmEntitySegment[] = [];
+ let cursor = 0;
+ GROUP_RE.lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = GROUP_RE.exec(body)) !== null) {
+ if (m.index > cursor) {
+ out.push({ kind: 'text', text: body.slice(cursor, m.index) });
+ }
+ out.push({ kind: 'group', group: m[1]!, base: m[2]! });
+ cursor = m.index + m[0]!.length;
+ }
+ if (cursor < body.length) {
+ out.push({ kind: 'text', text: body.slice(cursor) });
+ }
+ /* Bodies with no `::` at all collapse to a single text segment so
+ * the UI can render the same way regardless. */
+ if (out.length === 0) out.push({ kind: 'text', text: body });
+ return out;
+}
+
+export function formatAlarmEntity(
+ scope: AlarmScope | string | null | undefined,
+ name: string,
+): AlarmEntityLabel {
+ const key = typeof scope === 'string' ? scope : '';
+ const prefix = PREFIX_BY_SCOPE[key] ?? '';
+ return { prefix, segments: splitEntityBody(name) };
+}
diff --git a/apps/ui/src/utils/alarmIncidents.ts b/apps/ui/src/utils/alarmIncidents.ts
new file mode 100644
index 0000000..296b855
--- /dev/null
+++ b/apps/ui/src/utils/alarmIncidents.ts
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Incident merging for the alarms surface.
+ *
+ * OAP emits one `AlarmMessage` per firing — a rule that re-fires
+ * after its silence period creates a new row. From the operator's
+ * triage perspective these N rows are ONE incident on (entity, rule),
+ * not N separate alarms. The wire `id` field is
+ * `<entityBase64>.<ruleNumber>` and is stable across firings of the
+ * same rule on the same entity, so it's a free incident key.
+ *
+ * Merging rules:
+ * - Group `AlarmMessage[]` by `id`.
+ * - Sort each group by `startTime` ascending — the LAST entry is
+ * the most recent firing.
+ * - The incident's state is `firing` when the latest entry's
+ * `recoveryTime` is null, else `recovered`. The state of earlier
+ * entries doesn't matter; only the tail decides.
+ * - Latest entry's `message` / `name` / `scope` / `layerKey` /
+ * `tags` / `snapshot` represent the incident — those are what
+ * the operator sees in the row.
+ *
+ * Count semantics (per the spec):
+ * - Page totals + topbar badge + per-layer chips count ACTIVE
+ * incidents (state === 'firing'). A fully-recovered incident
+ * contributes nothing — the operator's "what's on fire right
+ * now?" view stays clean.
+ * - The Timeline chart keeps every individual event so the
+ * fire-then-recovered pattern is still visible; events are split
+ * by state into separate series (firing red, recovered green).
+ */
+
+import type { AlarmMessage } from '@/api/client';
+
+export interface AlarmIncident {
+ /** OAP id field — `<entityBase64>.<ruleNumber>`. Stable per
+ * (entity, rule). */
+ id: string;
+ /** All firings on this (entity, rule) in startTime-asc order. */
+ events: AlarmMessage[];
+ /** Convenience handles to the head + tail of `events`. */
+ oldest: AlarmMessage;
+ latest: AlarmMessage;
+ /** Latest firing's recovery state — `firing` when still active,
+ * `recovered` when OAP cleared it. */
+ state: 'firing' | 'recovered';
+ /** Number of individual firings in this incident. */
+ triggerCount: number;
+ /** Number of events where `recoveryTime !== null`. */
+ recoveredCount: number;
+ /** layerKey from the latest event (best-effort tag the BFF adds). */
+ layerKey: string | null;
+}
+
+export function mergeIncidents(events: AlarmMessage[]): AlarmIncident[] {
+ if (events.length === 0) return [];
+ const groups = new Map<string, AlarmMessage[]>();
+ for (const e of events) {
+ let arr = groups.get(e.id);
+ if (!arr) {
+ arr = [];
+ groups.set(e.id, arr);
+ }
+ arr.push(e);
+ }
+ const out: AlarmIncident[] = [];
+ for (const [id, arr] of groups) {
+ arr.sort((a, b) => a.startTime - b.startTime);
+ const latest = arr[arr.length - 1]!;
+ const oldest = arr[0]!;
+ out.push({
+ id,
+ events: arr,
+ oldest,
+ latest,
+ state: latest.recoveryTime === null ? 'firing' : 'recovered',
+ triggerCount: arr.length,
+ recoveredCount: arr.filter((e) => e.recoveryTime !== null).length,
+ layerKey: latest.layerKey,
+ });
+ }
+ /* Stable display order — most-recent latest event first. */
+ out.sort((a, b) => b.latest.startTime - a.latest.startTime);
+ return out;
+}
+
+/**
+ * List-flavored split of incidents.
+ *
+ * Use for the alarms page's row list (where every firing matters
+ * to triage) — NOT for the counts (which should still call
+ * `mergeIncidents`).
+ *
+ * Rules:
+ * - Every FIRING event (`recoveryTime === null`) becomes its own
+ * list row. A rule that re-fired four times against the same
+ * entity, all still active, shows up as four distinct rows so
+ * the operator can see each firing's timestamp + snapshot.
+ * - All RECOVERED events for the same (entity, rule) collapse
+ * into ONE merged row showing "triggered N× · recovered". They
+ * don't count toward active totals, so a single summary line is
+ * enough for the operator to know "this happened and cleared".
+ *
+ * Output order — newest event first, regardless of whether the row
+ * is a firing event or a merged-recovered group.
+ */
+export function splitForList(events: AlarmMessage[]): AlarmIncident[] {
+ if (events.length === 0) return [];
+
+ const out: AlarmIncident[] = [];
+
+ /* Each firing event is its own pseudo-incident with triggerCount=1
+ * so the row reads as a single event (no `triggered N×` chip). */
+ for (const e of events) {
+ if (e.recoveryTime !== null) continue;
+ out.push({
+ id: `${e.id}::${e.startTime}`,
+ events: [e],
+ oldest: e,
+ latest: e,
+ state: 'firing',
+ triggerCount: 1,
+ recoveredCount: 0,
+ layerKey: e.layerKey,
+ });
+ }
+
+ /* Recovered events: group by OAP id and collapse into one row each.
+ * The latest event in the group represents the row (freshest
+ * snapshot); `triggerCount` is the recovered count so the chip
+ * reads e.g. `triggered 4× · recovered`. */
+ const recoveredGroups = new Map<string, AlarmMessage[]>();
+ for (const e of events) {
+ if (e.recoveryTime === null) continue;
+ let arr = recoveredGroups.get(e.id);
+ if (!arr) {
+ arr = [];
+ recoveredGroups.set(e.id, arr);
+ }
+ arr.push(e);
+ }
+ for (const [id, arr] of recoveredGroups) {
+ arr.sort((a, b) => a.startTime - b.startTime);
+ const latest = arr[arr.length - 1]!;
+ out.push({
+ id,
+ events: arr,
+ oldest: arr[0]!,
+ latest,
+ state: 'recovered',
+ triggerCount: arr.length,
+ recoveredCount: arr.length,
+ layerKey: latest.layerKey,
+ });
+ }
+
+ out.sort((a, b) => b.latest.startTime - a.latest.startTime);
+ return out;
+}
diff --git a/packages/api-client/src/alarm-status.ts b/packages/api-client/src/alarm-status.ts
new file mode 100644
index 0000000..6ea663d
--- /dev/null
+++ b/packages/api-client/src/alarm-status.ts
@@ -0,0 +1,184 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Typed wrapper for OAP admin's `/status/alarm/*` endpoints (exposed
+ * by `AlarmStatusQueryHandler` in skywalking core). These are the
+ * authoritative source for **alarm rule** definitions and per-entity
+ * runtime state — distinct from `/runtime/rule/*`, which only covers
+ * MAL/LAL/OTEL catalogs (alarm rules live in `alarm-settings.yml`
+ * and are NOT in those catalogs).
+ *
+ * Every endpoint returns a `ClusterAlarmStatus` envelope that fans
+ * out across every OAP node in the cluster. Per-node `errorMsg` is
+ * the node's read-side failure (gRPC timeout, etc.) — `null` means
+ * the node responded. `status` is the parsed payload (rule list /
+ * detail / running context). On a sole-node OAP the envelope still
+ * has a one-entry `oapInstances` array.
+ */
+
+export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
+
+export interface AlarmStatusClientOptions {
+ /** OAP admin URL, e.g. `http://oap:17128`. No trailing slash. */
+ adminUrl: string;
+ /** Optional fetch override (tests). */
+ fetch?: FetchLike;
+ /** Optional default headers (basic auth, RPC token, etc.). */
+ headers?: Record<string, string>;
+ /** Default per-call timeout in ms. `0` disables. */
+ timeoutMs?: number;
+}
+
+// ── Wire types (mirror oap-server AlarmRule* + ClusterAlarmStatus) ──
+
+/** Per-OAP-node envelope inside a `ClusterAlarmStatus`. */
+export interface InstanceAlarmStatus<T> {
+ /** gRPC address of the OAP node — `host:port` for peers, `Self()`
+ * literal for the node serving the HTTP request. */
+ address: string;
+ /** Failure reason for THIS node only. Null when the node responded
+ * successfully (status field is then populated). */
+ errorMsg: string | null;
+ status: T | null;
+}
+
+export interface ClusterAlarmStatus<T> {
+ oapInstances: InstanceAlarmStatus<T>[];
+}
+
+/** Returned by `/status/alarm/rules` — only rule IDs per node. */
+export interface AlarmRuleList {
+ ruleList: Array<{ id: string }>;
+}
+
+/** Returned by `/status/alarm/{ruleId}` — full rule body + the set of
+ * entities the rule has fired against on this node. */
+export interface AlarmRuleDetail {
+ ruleId: string;
+ expression: string;
+ /** Number of MINUTE buckets the rule evaluates over. */
+ period: number;
+ /** Minutes of silence after a fire before re-evaluating. */
+ silencePeriod: number;
+ /** Minutes of clean observation needed before a recovery is
+ * emitted. */
+ recoveryObservationPeriod: number;
+ additionalPeriod: number;
+ includeEntityNames: string[];
+ excludeEntityNames: string[];
+ includeEntityNamesRegex?: string;
+ excludeEntityNamesRegex?: string;
+ runningEntities: Array<{
+ scope: string;
+ name: string;
+ formattedMessage: string;
+ }>;
+ tags: Array<{ key: string; value: string }>;
+ hooks: string[];
+ /** OAP metric names the rule's expression depends on — derived by
+ * OAP's MQE visitor at rule load. */
+ includeMetrics: string[];
+}
+
+/** Returned by `/status/alarm/{ruleId}/{entityName}` — per-entity
+ * running window state. Used for the "what's the rule currently
+ * seeing for this entity?" pane. */
+export interface AlarmRunningContext {
+ ruleName: string;
+ entity: string;
+ /** Sliding window snapshot — bucket-per-metric values currently in
+ * the rule's evaluation window. Shape varies by rule; the UI
+ * renders as raw JSON for now. */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ [key: string]: any;
+}
+
+export class AlarmStatusApiError extends Error {
+ readonly status: number;
+ readonly url: string;
+ readonly body?: string;
+ constructor(status: number, url: string, body?: string) {
+ super(`AlarmStatus ${status} ${url}${body ? ` — ${body.slice(0, 200)}` : ''}`);
+ this.name = 'AlarmStatusApiError';
+ this.status = status;
+ this.url = url;
+ this.body = body;
+ }
+}
+
+export class AlarmStatusClient {
+ private readonly fetchImpl: FetchLike;
+ private readonly base: string;
+ private readonly defaultHeaders: Record<string, string>;
+ private readonly timeoutMs: number;
+
+ constructor(options: AlarmStatusClientOptions) {
+ this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
+ this.base = options.adminUrl.replace(/\/$/, '');
+ this.defaultHeaders = options.headers ?? {};
+ this.timeoutMs = options.timeoutMs ?? 0;
+ }
+
+ /** `GET /status/alarm/rules` — every loaded rule, per OAP node. */
+ async listRules(): Promise<ClusterAlarmStatus<AlarmRuleList>> {
+ return this.json<ClusterAlarmStatus<AlarmRuleList>>('/status/alarm/rules');
+ }
+
+ /** `GET /status/alarm/{ruleId}` — full body for one rule, per OAP
+ * node. */
+ async ruleDetail(ruleId: string): Promise<ClusterAlarmStatus<AlarmRuleDetail>> {
+ return this.json<ClusterAlarmStatus<AlarmRuleDetail>>(
+ `/status/alarm/${encodeURIComponent(ruleId)}`,
+ );
+ }
+
+ /** `GET /status/alarm/{ruleId}/{entityName}` — running window state
+ * for one entity. */
+ async ruleContext(
+ ruleId: string,
+ entityName: string,
+ ): Promise<ClusterAlarmStatus<AlarmRunningContext>> {
+ return this.json<ClusterAlarmStatus<AlarmRunningContext>>(
+ `/status/alarm/${encodeURIComponent(ruleId)}/${encodeURIComponent(entityName)}`,
+ );
+ }
+
+ private async json<T>(path: string): Promise<T> {
+ const url = `${this.base}${path}`;
+ const init: RequestInit = {
+ method: 'GET',
+ headers: { Accept: 'application/json', ...this.defaultHeaders },
+ };
+ let timer: ReturnType<typeof setTimeout> | null = null;
+ if (this.timeoutMs > 0) {
+ const ctrl = new AbortController();
+ timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
+ init.signal = ctrl.signal;
+ }
+ try {
+ const res = await this.fetchImpl(url, init);
+ if (!res.ok) {
+ const body = await res.text();
+ throw new AlarmStatusApiError(res.status, url, body);
+ }
+ return (await res.json()) as T;
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+ }
+}
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 990bd24..859cbeb 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -182,6 +182,16 @@ export {
type FetchLike,
} from './runtime-rule.js';
export { StatusClient, type StatusClientOptions, type NormalisedClusterNode } from './status.js';
+export {
+ AlarmStatusClient,
+ AlarmStatusApiError,
+ type AlarmStatusClientOptions,
+ type AlarmRuleList,
+ type AlarmRuleDetail,
+ type AlarmRunningContext,
+ type ClusterAlarmStatus,
+ type InstanceAlarmStatus,
+} from './alarm-status.js';
export {
OalClient,
type OalClientOptions,
(skywalking-horizon-ui) 01/02: ui layer selection: add share-link button + strip seed params after hydrate
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 45f1d38d744bcfddc40d64548b163b98dc158054
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun May 17 21:49:57 2026 +0800
ui layer selection: add share-link button + strip seed params after hydrate
---
apps/ui/src/layer/LayerShell.vue | 81 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 81 insertions(+)
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index 7bd797b..1a05b1c 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -64,6 +64,16 @@ watch(
([key, scope]) => {
if (!key) return;
selectionStore.hydrateFromQuery(key, scope, route.query);
+ // Strip the seeded selection params from the address bar so the
+ // displayed URL stays a clean dashboard link. The store now owns
+ // the live state; the params were only ever a one-shot seed for
+ // shared/pasted links. Other query keys (none today, but room for
+ // future per-route flags) are preserved.
+ const q = route.query;
+ if (q.service != null || q.instance != null || q.endpoint != null) {
+ const { service: _s, instance: _i, endpoint: _e, ...rest } = q;
+ void router.replace({ path: route.path, query: rest });
+ }
},
{ immediate: true },
);
@@ -220,6 +230,36 @@ function pickService(id: string): void {
}, 140);
}
+// Share button — copies a URL that re-creates the current header
+// selection (service/instance/endpoint) when pasted. The store owns
+// the live state and the URL stays frozen on landing, so we have to
+// assemble the link on demand from `selectionStore.shareQuery`. The
+// `copied` chip is the only feedback channel; we have no toast system
+// and don't want one for a single action.
+const shareCopied = ref(false);
+let shareCopiedTimer: ReturnType<typeof setTimeout> | null = null;
+async function copyShareLink(): Promise<void> {
+ const url = window.location.origin + window.location.pathname + selectionStore.shareQuery;
+ try {
+ await navigator.clipboard.writeText(url);
+ } catch {
+ // Fallback for non-secure contexts (clipboard API requires HTTPS
+ // or localhost). A hidden textarea + execCommand is the only
+ // path that works in plain HTTP intranets — common deployment
+ // shape for self-hosted OAP installs.
+ const ta = document.createElement('textarea');
+ ta.value = url;
+ ta.style.position = 'fixed';
+ ta.style.opacity = '0';
+ document.body.appendChild(ta);
+ ta.select();
+ try { document.execCommand('copy'); } finally { document.body.removeChild(ta); }
+ }
+ shareCopied.value = true;
+ if (shareCopiedTimer) clearTimeout(shareCopiedTimer);
+ shareCopiedTimer = setTimeout(() => { shareCopied.value = false; }, 1400);
+}
+
// ── Header identity ──────────────────────────────────────────────────
function initialsFor(name: string): string {
return name
@@ -391,6 +431,21 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
>
<Icon name="refresh" :class="{ spin: landing.isFetching.value }" />
</button>
+ <!-- Share — assemble the current selection into a URL the
+ operator can paste anywhere. The URL only carries the
+ header selection (service/instance/endpoint); the store
+ owns the live state, so this is purely an export. The
+ little 'copied' flash gives the click a visible echo
+ without needing a toast system. -->
+ <button
+ class="sw-btn ghost svc-share"
+ type="button"
+ :title="shareCopied ? 'Link copied' : 'Copy a shareable link to the current view'"
+ @click="copyShareLink"
+ >
+ <Icon name="share" />
+ <span v-if="shareCopied" class="svc-share-flash">copied</span>
+ </button>
<div v-if="serviceKpis.length > 0" class="kpi-strip service-kpis">
<div v-for="(k, i) in serviceKpis" :key="i" class="kpi compact">
<span class="kpi-label inline">
@@ -468,6 +523,32 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
@keyframes svc-refresh-spin {
to { transform: rotate(360deg); }
}
+.svc-share {
+ position: relative;
+ width: 32px;
+ height: 32px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+}
+.svc-share-flash {
+ position: absolute;
+ top: -22px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 2px 6px;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ font-size: 9.5px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--sw-fg-1);
+ pointer-events: none;
+ white-space: nowrap;
+}
.layer-shell {
padding: 16px 20px 48px;