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
The following commit(s) were added to refs/heads/main by this push:
new f444252 chore: cleanup batch — orphaned routes, alarm window unify,
theme-aware widgets, gateway-prefix support
f444252 is described below
commit f444252a2ed9f52f08613086e1293474629d6f7d
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 19 17:13:14 2026 +0800
chore: cleanup batch — orphaned routes, alarm window unify, theme-aware
widgets, gateway-prefix support
Cleanup:
- Docs: /admin/cluster → /operate/cluster across 7 files (the route is
under /operate/, not /admin/; the prior path 404'd).
- "Coming in Phase 6/7" placeholder strip + matching docs section
deleted from ClusterStatusView. Six obsolete "Phase 7 admin" code
comments updated to point at the existing /admin/* surfaces.
- Orphaned disk-write routes removed:
POST /api/admin/overview-templates/:id (UPDATE — no UI caller)
POST /api/admin/layer-templates/:key (UPDATE — no UI caller)
Operator updates go through /api/admin/templates/save (OAP-backed).
Dead zod schemas (~150 lines) + matching UI scope methods deleted.
GET list/get + POST create + DELETE stay — still used by the admin
pages for create / delete (those still hit disk pending follow-up).
Alarm window unification:
- The overview AlarmsWidget no longer hardcodes a 60-minute window —
it reads `defaultWindowMs` from /api/alarms/config like the alarms
page and topbar badge already do. All three surfaces now query the
same operator-configured window, so counts reconcile.
- AlertPageSetupView's lede updated to reflect the unification.
Theme-aware widget colors:
- New shared util apps/ui/src/utils/cssVar.ts exposes readCssVar() and
readAccent() — resolves CSS custom properties at runtime so chart
libraries that can't consume `var(--…)` strings (ECharts canvas,
D3) still track the active theme.
- Bare `#f97316` swapped for readAccent() in:
AlarmSnapshotChart.vue (palette[0] + trigger label color)
AlarmsTimeline.vue (tooltip header color)
LayerZipkinTracesView.vue (service palette[0])
ZipkinTracePopout.vue (service palette[0])
Remaining palette entries (varied service-distinguishing colors)
stay hardcoded — they exist to be distinct from one another, not to
match brand.
Gateway-prefix support:
- BffClient.request() prepends import.meta.env.BASE_URL (minus the
trailing slash) to every API path. Same withBase() applied to the
direct-fetch sites (configs.bundle's 304 detection, dsl.getRule /
saveRule / oalFileContent's content-type-specific reads).
- The same BASE_URL already feeds createWebHistory() so router and
data calls are aligned. Build with `vite build --base=/horizon/`
and the SPA + every API call resolves under `/horizon/` — gateways
that strip the prefix when forwarding to the BFF work unchanged.
---
apps/bff/src/http/admin/overview-templates.ts | 30 ----
apps/bff/src/http/config/layer-template.ts | 199 +--------------------
apps/bff/src/http/query/menu.ts | 3 +-
apps/bff/src/logic/dashboard/defaults.ts | 5 +-
apps/bff/src/rbac/route-policy.ts | 7 +-
apps/bff/src/util/mqe-catalog.ts | 8 +-
apps/ui/src/api/client.ts | 18 +-
apps/ui/src/api/scopes/configs.ts | 4 +-
apps/ui/src/api/scopes/dsl.ts | 8 +-
apps/ui/src/api/scopes/layer-template.ts | 9 +-
apps/ui/src/api/scopes/overview.ts | 9 +-
apps/ui/src/components/charts/AlarmsTimeline.vue | 3 +-
.../admin/alert-page/AlertPageSetupView.vue | 5 +-
apps/ui/src/features/alarms/AlarmSnapshotChart.vue | 30 ++--
.../features/operate/cluster/ClusterStatusView.vue | 35 ----
apps/ui/src/layer/traces/LayerZipkinTracesView.vue | 10 +-
apps/ui/src/layer/traces/ZipkinTracePopout.vue | 14 +-
.../render/layer-dashboard/LayerDashboardsView.vue | 4 +-
apps/ui/src/render/widgets/AlarmsWidget.vue | 20 ++-
apps/ui/src/utils/cssVar.ts | 44 +++++
apps/ui/src/utils/metricCatalog.ts | 5 +-
docs/access-control/admin-pages.md | 2 +-
docs/access-control/rbac.md | 8 +-
docs/compatibility/cluster-status.md | 13 +-
docs/compatibility/oap-version.md | 2 +-
docs/operate/cluster-metadata.md | 2 +-
docs/setup/overview.md | 2 +-
docs/setup/rbac.md | 8 +-
28 files changed, 170 insertions(+), 337 deletions(-)
diff --git a/apps/bff/src/http/admin/overview-templates.ts
b/apps/bff/src/http/admin/overview-templates.ts
index f0eecf6..48b6758 100644
--- a/apps/bff/src/http/admin/overview-templates.ts
+++ b/apps/bff/src/http/admin/overview-templates.ts
@@ -40,7 +40,6 @@ import {
findOverviewFile,
getOverviewDashboard,
loadOverviewDashboards,
- writeOverviewDashboard,
} from '../../logic/overview/loader.js';
export interface OverviewTemplatesAdminDeps {
@@ -154,35 +153,6 @@ export function registerOverviewTemplatesAdminRoutes(
},
);
- /* 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 });
- },
- );
-
/* POST /api/admin/overview-templates — create a brand-new
* dashboard. The body is a full OverviewDashboard JSON; the id is
* pulled from the body (matching the editor flow where the
diff --git a/apps/bff/src/http/config/layer-template.ts
b/apps/bff/src/http/config/layer-template.ts
index c173157..37a7779 100644
--- a/apps/bff/src/http/config/layer-template.ts
+++ b/apps/bff/src/http/config/layer-template.ts
@@ -28,178 +28,21 @@
* shape immediately.
*/
-import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
-import { z } from 'zod';
+import type { FastifyInstance } from 'fastify';
import type { ConfigSource } from '../../config/loader.js';
import type { SessionStore } from '../../user/sessions.js';
import { requireAuth } from '../../user/middleware.js';
-import {
- allLayerTemplates,
- getLayerTemplate,
- writeLayerTemplate,
- type LayerTemplate,
-} from '../../logic/layers/loader.js';
-import { widgetSchema } from '../query/dashboard.js';
+import { allLayerTemplates } from '../../logic/layers/loader.js';
export interface LayerTemplateConfigDeps {
config: ConfigSource;
sessions: SessionStore;
}
-// One LayerMetricColumn / OverviewMetric / TopologyMetricDef row. The
-// columns family across header / overview / topology share the same
-// MQE-plus-presentation shape; we extract a base + extend per row type.
-const metricColumnSchema = z
- .object({
- id: z.string().optional(),
- metric: z.string().min(1).optional(),
- label: z.string(),
- tip: z.string().optional(),
- unit: z.string().optional(),
- mqe: z.string().optional(),
- aggregation: z.enum(['sum', 'avg']).optional(),
- scale: z.number().finite().optional(),
- precision: z.number().int().min(0).max(6).optional(),
- })
- .passthrough();
-
-// Header config (legacy field name `metrics`, canonical `header`).
-const headerSchema = z
- .object({
- orderBy: z.string().optional(),
- columns: z.array(metricColumnSchema).max(8).optional(),
- })
- .passthrough();
-
-// Overview-tile config — `groups` is the canonical shape; the legacy
-// fields (`metrics`, `throughput`, `spark`) are preserved so older
-// bundled JSONs round-trip cleanly.
-const overviewGroupSchema = z
- .object({
- title: z.string(),
- size: z.enum(['auto', 'square']),
- metrics: z.array(metricColumnSchema).max(20),
- })
- .passthrough();
-const overviewSchema = z
- .object({
- groups: z.array(overviewGroupSchema).max(10).optional(),
- metrics: z.array(metricColumnSchema).max(20).optional(),
- throughput: z.string().optional(),
- spark: z.string().optional(),
- })
- .passthrough();
-
-// Topology + endpoint-dependency: node + edge metric defs. The role
-// field is a string union per TopologyMetricDef; we accept any string
-// here so future roles don't break saves.
-const topologyMetricSchema = metricColumnSchema.extend({
- role: z.string().optional(),
-});
-const topologyConfigSchema = z
- .object({
- nodeMetrics: z.array(topologyMetricSchema).max(20).optional(),
- linkServerMetrics: z.array(topologyMetricSchema).max(20).optional(),
- linkClientMetrics: z.array(topologyMetricSchema).max(20).optional(),
- })
- .passthrough();
-const endpointDependencyConfigSchema = z
- .object({
- nodeMetrics: z.array(topologyMetricSchema).max(20).optional(),
- linkMetrics: z.array(topologyMetricSchema).max(20).optional(),
- })
- .passthrough();
-
-const tracesConfigSchema = z
- .object({
- source: z.enum(['native', 'zipkin', 'both']).optional(),
- })
- .passthrough();
-
-const logConfigSchema = z
- .object({
- scope: z.enum(['service', 'instance', 'endpoint']).optional(),
- defaultTags: z
- .array(z.object({ key: z.string().min(1), value: z.string()
}).passthrough())
- .max(20)
- .optional(),
- })
- .passthrough();
-
-// `.passthrough()` on the outer template AND on `components` keeps the
-// schema from silently dropping fields the loader interface knows about
-// (visibility, group, topology, endpointDependency, traces, log) or
-// from hard-rejecting newer component flags (networkProfiling,
-// pprofProfiling) that bundled JSONs use today. Adding a new flag in
-// `LayerComponentFlags` no longer requires a schema bump to ship.
-const adminTemplateSchema = z
- .object({
- key: z.string().regex(/^[A-Z][A-Z0-9_]*$/),
- alias: z.string().optional(),
- group: z.string().optional(),
- visibility: z.enum(['public', 'operate']).optional(),
- color: z.string().optional(),
- documentLink: z.string().optional(),
- slots: z
- .object({
- services: z.string().optional(),
- instances: z.string().optional(),
- endpoints: z.string().optional(),
- endpointDependency: z.string().optional(),
- })
- .passthrough(),
- components: z
- .object({
- service: z.boolean().optional(),
- instances: z.boolean().optional(),
- endpoints: z.boolean().optional(),
- endpointDependency: z.boolean().optional(),
- topology: z.boolean().optional(),
- traces: z.boolean().optional(),
- logs: z.boolean().optional(),
- traceProfiling: z.boolean().optional(),
- ebpfProfiling: z.boolean().optional(),
- asyncProfiling: z.boolean().optional(),
- networkProfiling: z.boolean().optional(),
- pprofProfiling: z.boolean().optional(),
- })
- .passthrough(),
- // Accept both `header` (canonical) and `metrics` (legacy alias).
- header: headerSchema.optional(),
- metrics: headerSchema.optional(),
- overview: overviewSchema.optional(),
- dashboards: z
- .object({
- service: z.array(widgetSchema).max(40).optional(),
- instance: z.array(widgetSchema).max(40).optional(),
- endpoint: z.array(widgetSchema).max(40).optional(),
- dependency: z.array(widgetSchema).max(40).optional(),
- topology: z.array(widgetSchema).max(40).optional(),
- trace: z.array(widgetSchema).max(40).optional(),
- logs: z.array(widgetSchema).max(40).optional(),
- traceProfiling: z.array(widgetSchema).max(40).optional(),
- ebpfProfiling: z.array(widgetSchema).max(40).optional(),
- asyncProfiling: z.array(widgetSchema).max(40).optional(),
- })
- .passthrough()
- .optional(),
- widgets: z.array(widgetSchema).max(40).optional(),
- topology: topologyConfigSchema.optional(),
- endpointDependency: endpointDependencyConfigSchema.optional(),
- traces: tracesConfigSchema.optional(),
- log: logConfigSchema.optional(),
- naming: z
- .object({
- pattern: z.string().min(1),
- flags: z.string().optional(),
- displayGroup: z.string().optional(),
- valueGroup: z.string().optional(),
- alias: z.string().min(1),
- })
- .passthrough()
- .optional(),
- })
- .passthrough();
+// All zod schemas for admin template validation were removed when
+// `POST /api/admin/layer-templates/:key` was retired. Layer-template
+// updates now flow through `/api/admin/templates/save` (OAP-backed,
+// validated server-side by the OAP UI-template endpoint shape).
export function registerLayerTemplateRoutes(
app: FastifyInstance,
@@ -210,31 +53,7 @@ export function registerLayerTemplateRoutes(
return reply.send({ templates: allLayerTemplates() });
});
- app.post(
- '/api/admin/layer-templates/:key',
- { preHandler: auth },
- async (req: FastifyRequest, reply: FastifyReply) => {
- const params = req.params as { key: string };
- const layerKey = params.key.toUpperCase();
- const parsed = adminTemplateSchema.safeParse(req.body);
- if (!parsed.success) {
- return reply.code(400).send({ error: 'invalid_template', detail:
parsed.error.flatten() });
- }
- if (parsed.data.key.toUpperCase() !== layerKey) {
- return reply
- .code(400)
- .send({ error: 'key_mismatch', detail: 'URL key does not match body
key' });
- }
- try {
- writeLayerTemplate(parsed.data as LayerTemplate);
- } catch (err) {
- return reply.code(500).send({
- error: 'write_failed',
- detail: err instanceof Error ? err.message : String(err),
- });
- }
- const refreshed = getLayerTemplate(layerKey);
- return reply.send({ template: refreshed });
- },
- );
+ // POST /api/admin/layer-templates/:key removed — operator updates
+ // now go through `/api/admin/templates/save` (OAP-backed). Bundled
+ // JSON is immutable at runtime.
}
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index d0bccbb..809ca2a 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -117,7 +117,8 @@ interface MenuRaw {
/**
* Horizon-side defaults for per-layer term aliases and color. OAP doesn't
* expose these — they live alongside the UI's sidebar config. Operators can
- * override via `horizon.yaml.layers.<key>` (future Phase 7 admin).
+ * override via the Dashboard setup → Layer dashboards admin page,
+ * which writes to OAP via the UI-template sync surface.
*
* Keys match `Layer.name` in OAP's enum (UPPER_SNAKE_CASE).
*/
diff --git a/apps/bff/src/logic/dashboard/defaults.ts
b/apps/bff/src/logic/dashboard/defaults.ts
index 55ff193..88a1bc9 100644
--- a/apps/bff/src/logic/dashboard/defaults.ts
+++ b/apps/bff/src/logic/dashboard/defaults.ts
@@ -19,8 +19,9 @@
* Default dashboard widget sets per OAP layer enum. These are lifted
* verbatim from the booster-ui templates the operator already knows —
* see `docs/design/research/booster-templates/<layer>/<layer>-service.json`
- * for the source rows. Phase 7 admin will let operators edit + persist
- * their own set; until then the BFF serves these defaults.
+ * for the source rows. Operators can edit + persist their own set via
+ * the admin pages under Dashboard setup; this file is the seed the
+ * BFF falls back to when OAP holds no overriding template.
*
* 24-column grid: matches booster-ui's vue-grid-layout dimensions so
* positions and spans port without rework.
diff --git a/apps/bff/src/rbac/route-policy.ts
b/apps/bff/src/rbac/route-policy.ts
index 22a6c77..2229e6d 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -144,7 +144,8 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
'GET /api/overview/dashboards': 'overview:read',
'GET /api/overview/dashboards/:id': 'overview:read',
'GET /api/admin/layer-templates': 'dashboard:read',
- 'POST /api/admin/layer-templates/:key': 'dashboard:write',
+ // POST /api/admin/layer-templates/:key removed — updates go through
+ // `/api/admin/templates/save` (OAP-backed). See template-sync.ts.
// ── DSL / OAL / MQE rules (admin operate) ────────────────────────
'GET /api/rule': 'rule:read',
@@ -184,7 +185,9 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
'GET /api/admin/overview-templates': 'overview:read',
'GET /api/admin/overview-templates/:id': 'overview:read',
'POST /api/admin/overview-templates': 'overview:write',
- 'POST /api/admin/overview-templates/:id': 'overview:write',
+ // POST /api/admin/overview-templates/:id removed — operator updates
+ // now go through `/api/admin/templates/save` (OAP-backed). Bundled
+ // JSON is immutable at runtime.
'DELETE /api/admin/overview-templates/:id': 'overview:write',
// ── Template sync (admin) — OAP UI-template REST overlay ─────────
diff --git a/apps/bff/src/util/mqe-catalog.ts b/apps/bff/src/util/mqe-catalog.ts
index ae3bedd..3c683c8 100644
--- a/apps/bff/src/util/mqe-catalog.ts
+++ b/apps/bff/src/util/mqe-catalog.ts
@@ -30,8 +30,8 @@
*
* Returns `null` when no mapping exists for the (metric, layer) pair —
* the BFF then surfaces a `null` value cell and the UI renders an
- * em-dash. Operators can extend the catalog via the Phase 7 admin
- * surface; for now we ship a conservative built-in set.
+ * em-dash. The built-in set below is the conservative ship default;
+ * operators can extend it via the Dashboard setup admin pages.
*/
import type { LandingColumn } from '@skywalking-horizon-ui/api-client';
@@ -94,8 +94,8 @@ const BROWSER_SERVICE: Record<string, string> = {
/**
* Database virtual-service metrics (`virtual_database`). MQ + native
* database (mysql/postgresql/…) layers have richer per-tech catalogs in
- * OAP — we ship the lowest common denominator here and let admin
- * override land in Phase 7.
+ * OAP — we ship the lowest common denominator here; operators
+ * override per deployment through Dashboard setup → Layer dashboards.
*/
const DATABASE_SERVICE: Record<string, string> = {
cpm: 'avg(service_cpm)',
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 4d63835..bebb3bd 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -47,6 +47,21 @@ import type {
import { pushEvent } from '@/controls/eventLog';
import { SessionApi } from './scopes/session';
+
+/** Deploy-base prefix for every API call. Pulled from Vite's
+ * `BASE_URL` so the same build artifact works whether served at
+ * `/` (default), `/horizon/` (behind a gateway), or any other
+ * sub-path. Mirrors the router's `createWebHistory(BASE_URL)`
+ * behavior — both must use the same prefix or the SPA navigates
+ * to working URLs but its data calls 404. */
+const API_BASE = import.meta.env.BASE_URL.replace(/\/$/, '');
+/** Prepend the deploy base to a path that starts with `/`. Exported
+ * so the direct-fetch paths (configs.bundle's 304 detection, dsl's
+ * text/plain responses) can apply the same prefix as the central
+ * `BffClient.request` path. */
+export function withBase(path: string): string {
+ return API_BASE + path;
+}
import { MenuApi } from './scopes/menu';
import { OverviewApi } from './scopes/overview';
import { SetupApi } from './scopes/setup';
@@ -579,9 +594,10 @@ export class BffClient {
},
};
if (body !== undefined) init.body = JSON.stringify(body);
+ const url = withBase(path);
let res: Response;
try {
- res = await fetch(path, init);
+ res = await fetch(url, init);
} catch (err) {
// Network-level failure: DNS, CORS, aborted, BFF down, etc.
// fetch() doesn't throw on HTTP-level errors (4xx/5xx) — only on
diff --git a/apps/ui/src/api/scopes/configs.ts
b/apps/ui/src/api/scopes/configs.ts
index f1cfb25..e4a3668 100644
--- a/apps/ui/src/api/scopes/configs.ts
+++ b/apps/ui/src/api/scopes/configs.ts
@@ -20,7 +20,7 @@ import type {
OverviewDashboard,
} from '@skywalking-horizon-ui/api-client';
import { pushEvent } from '@/controls/eventLog';
-import type { BffClient } from '../client';
+import { withBase, type BffClient } from '../client';
export type BundleScopeMap = Partial<
Record<'service' | 'instance' | 'endpoint', DashboardWidget[]>
@@ -91,7 +91,7 @@ export class ConfigsApi {
// still lands in the debug event log.
let res: Response;
try {
- res = await fetch('/api/configs/bundle', {
+ res = await fetch(withBase('/api/configs/bundle'), {
method: 'GET',
credentials: 'include',
headers,
diff --git a/apps/ui/src/api/scopes/dsl.ts b/apps/ui/src/api/scopes/dsl.ts
index 9c9c13d..021eeb7 100644
--- a/apps/ui/src/api/scopes/dsl.ts
+++ b/apps/ui/src/api/scopes/dsl.ts
@@ -28,7 +28,7 @@ import type {
RuleSource,
} from '@skywalking-horizon-ui/api-client';
import type { BffClient, ClusterStateResponse } from '../client';
-import { BffApiError } from '../client';
+import { BffApiError, withBase } from '../client';
import { pushEvent } from '@/controls/eventLog';
/** `bff.dsl` — DSL Management: rule catalog browse, single-rule fetch /
@@ -63,7 +63,7 @@ export class DslApi {
const path = `/api/rule?${params.toString()}`;
let res: Response;
try {
- res = await fetch(path, {
+ res = await fetch(withBase(path), {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/x-yaml' },
@@ -106,7 +106,7 @@ export class DslApi {
if (args.allowStorageChange) params.set('allowStorageChange', 'true');
if (args.force) params.set('force', 'true');
const path = `/api/rule?${params.toString()}`;
- const res = await fetch(path, {
+ const res = await fetch(withBase(path), {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'text/plain' },
@@ -143,7 +143,7 @@ export class DslApi {
/** Returns the raw `.oal` text or `null` on 404. */
async oalFileContent(name: string): Promise<string | null> {
- const res = await fetch(`/api/oal/files/${encodeURIComponent(name)}`, {
+ const res = await
fetch(withBase(`/api/oal/files/${encodeURIComponent(name)}`), {
method: 'GET',
credentials: 'include',
headers: { Accept: 'text/plain' },
diff --git a/apps/ui/src/api/scopes/layer-template.ts
b/apps/ui/src/api/scopes/layer-template.ts
index c5873b0..20f41fb 100644
--- a/apps/ui/src/api/scopes/layer-template.ts
+++ b/apps/ui/src/api/scopes/layer-template.ts
@@ -29,11 +29,6 @@ export class LayerTemplatesApi {
);
}
- save(template: AdminLayerTemplate): Promise<{ template: AdminLayerTemplate
}> {
- return this.bff.request<{ template: AdminLayerTemplate }>(
- 'POST',
- `/api/admin/layer-templates/${encodeURIComponent(template.key)}`,
- template,
- );
- }
+ // save() removed — LayerDashboardsAdmin now saves via
+ // `bff.templateSync.save('horizon.layer.<KEY>', content)`.
}
diff --git a/apps/ui/src/api/scopes/overview.ts
b/apps/ui/src/api/scopes/overview.ts
index cf60b46..94ec685 100644
--- a/apps/ui/src/api/scopes/overview.ts
+++ b/apps/ui/src/api/scopes/overview.ts
@@ -64,13 +64,8 @@ export class OverviewApi {
`/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,
- );
- }
+ // adminSave removed — OverviewTemplatesAdmin now saves via
+ // `bff.templateSync.save('horizon.overview.<id>', content)`.
adminCreate(body: OverviewDashboard): Promise<{ ok: true; id: string }> {
return this.bff.request<{ ok: true; id: string }>(
'POST',
diff --git a/apps/ui/src/components/charts/AlarmsTimeline.vue
b/apps/ui/src/components/charts/AlarmsTimeline.vue
index 31ea706..c4457aa 100644
--- a/apps/ui/src/components/charts/AlarmsTimeline.vue
+++ b/apps/ui/src/components/charts/AlarmsTimeline.vue
@@ -41,6 +41,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
+import { readAccent } from '@/utils/cssVar';
import type { EChartsType } from 'echarts/core';
import type { AlarmMessage } from '@/api/client';
@@ -142,7 +143,7 @@ function buildOption(): echarts.EChartsCoreOption {
const recovered = b?.recovered ?? 0;
const total = firing + recovered;
return [
- `<div
style="font-weight:600;color:#f97316;">${formatMinute(ts)}</div>`,
+ `<div
style="font-weight:600;color:${readAccent()};">${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;">
diff --git a/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
b/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
index 99f8050..195dbce 100644
--- a/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
+++ b/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
@@ -273,8 +273,9 @@ function prettyLayer(k: string): string {
</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.
+ Time window applied to all three alarm surfaces — the topbar alarm
badge, the
+ alarms page's first load, and the overview "Active alarms" widget.
Unified
+ here so the counts reconcile across pages.
</p>
<div class="aps__win-options">
<label
diff --git a/apps/ui/src/features/alarms/AlarmSnapshotChart.vue
b/apps/ui/src/features/alarms/AlarmSnapshotChart.vue
index d208037..e4b183f 100644
--- a/apps/ui/src/features/alarms/AlarmSnapshotChart.vue
+++ b/apps/ui/src/features/alarms/AlarmSnapshotChart.vue
@@ -44,6 +44,7 @@ import {
import { CanvasRenderer } from 'echarts/renderers';
import type { EChartsType } from 'echarts/core';
import type { AlarmMqeMetric } from '@/api/client';
+import { readAccent } from '@/utils/cssVar';
echarts.use([
LineChart,
@@ -80,14 +81,22 @@ const props = withDefaults(
);
const MINUTE_MS = 60_000;
-const PALETTE = [
- '#f97316',
- '#60a5fa',
- '#a78bfa',
- '#22d3ee',
- '#34d399',
- '#f472b6',
-];
+
+/** Series palette. First entry tracks the active theme's `--sw-accent`
+ * so the dominant series re-colors with the theme; the remaining
+ * entries are intentionally varied for service-distinguishing. They
+ * stay constant across themes because their job is "be a distinct
+ * color," not "match brand". */
+function buildPalette(): string[] {
+ return [
+ readAccent('#f97316'),
+ '#60a5fa',
+ '#a78bfa',
+ '#22d3ee',
+ '#34d399',
+ '#f472b6',
+ ];
+}
interface SeriesIn {
label: string;
@@ -223,7 +232,8 @@ function buildOption(): echarts.EChartsCoreOption {
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.06)' } },
},
series: series.map((s, i) => {
- const color = PALETTE[i % PALETTE.length];
+ const palette = buildPalette();
+ const color = palette[i % palette.length];
const isOnly = series.length === 1;
return {
name: s.label,
@@ -261,7 +271,7 @@ function buildOption(): echarts.EChartsCoreOption {
{
xAxis: props.triggerTime,
label: {
- color: '#f97316',
+ color: readAccent(),
formatter: `trigger ${fmtMinute(props.triggerTime)}`,
},
},
diff --git a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
index 7d1f4b0..ebd4179 100644
--- a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
+++ b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
@@ -217,19 +217,6 @@ function refreshAll(): void {
</tbody>
</table>
</section>
-
- <!-- ── Coming-soon strip (storage / module-activity / TTL) ───── -->
- <div class="phase-note">
- <strong>Coming in Phase 6 / 7</strong>
- <ul>
- <li>Per-node cluster map (host/port, role, heartbeat)</li>
- <li>Module activity matrix (module × provider × node)</li>
- <li>Storage backend health (BanyanDB / Elasticsearch / JDBC)</li>
- <li>Receiver activity (gRPC / HTTP / Kafka / OTLP throughput, queue
depth)</li>
- <li>Navigable effective-configuration tree with two-node diff</li>
- <li>TTL & retention grid (hot / warm / cold)</li>
- </ul>
- </div>
</div>
</template>
@@ -486,26 +473,4 @@ function refreshAll(): void {
color: var(--sw-fg-3);
}
-.phase-note {
- background: var(--sw-bg-1);
- border: 1px dashed var(--sw-line-2);
- border-radius: 8px;
- padding: 14px 16px;
- margin-top: 20px;
-}
-.phase-note strong {
- display: block;
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--sw-accent);
- margin-bottom: 8px;
-}
-.phase-note ul {
- margin: 0;
- padding-left: 18px;
- color: var(--sw-fg-1);
- font-size: 12px;
- line-height: 1.7;
-}
</style>
diff --git a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
index 36fa459..0992cda 100644
--- a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
@@ -27,6 +27,7 @@ import { useRoute } from 'vue-router';
import type { ZipkinTraceListRow } from '@skywalking-horizon-ui/api-client';
import { useLayerZipkinTraces, useZipkinTrace } from
'@/layer/traces/useZipkinTraces';
import { useZipkinTracePopout } from '@/layer/traces/useZipkinTracePopout';
+import { readAccent } from '@/utils/cssVar';
import { bffClient } from '@/api/client';
// Zipkin trace data is keyed by its own service universe (the names
@@ -352,12 +353,17 @@ const detailRows = computed<DetailRow[]>(() => {
}
return out;
});
-const SERVICE_PALETTE = ['#f97316', '#60a5fa', '#a78bfa', '#22d3ee',
'#f472b6', '#34d399', '#fbbf24', '#fb7185'];
+/* Per-service color palette. First entry tracks `--sw-accent` so the
+ * trace waterfall's brand color follows the active theme; the rest
+ * stay constant because their job is "be distinct from each other"
+ * across services, not "match brand". Rebuilt per call so theme
+ * swaps land immediately on next render. */
function detailColor(name: string | null | undefined): string {
if (!name) return 'var(--sw-fg-3)';
+ const palette = [readAccent('#f97316'), '#60a5fa', '#a78bfa', '#22d3ee',
'#f472b6', '#34d399', '#fbbf24', '#fb7185'];
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
- return SERVICE_PALETTE[Math.abs(h) % SERVICE_PALETTE.length];
+ return palette[Math.abs(h) % palette.length]!;
}
function detailLeftPct(us: number): number {
return Math.max(0, Math.min(100, (us / (detailBounds.value.totalUs || 1)) *
100));
diff --git a/apps/ui/src/layer/traces/ZipkinTracePopout.vue
b/apps/ui/src/layer/traces/ZipkinTracePopout.vue
index 3697377..bede5f6 100644
--- a/apps/ui/src/layer/traces/ZipkinTracePopout.vue
+++ b/apps/ui/src/layer/traces/ZipkinTracePopout.vue
@@ -33,6 +33,7 @@ import { computed, ref, watch } from 'vue';
import type { ZipkinSpan } from '@skywalking-horizon-ui/api-client';
import { useZipkinTracePopout } from '@/layer/traces/useZipkinTracePopout';
import { useZipkinTrace } from '@/layer/traces/useZipkinTraces';
+import { readAccent } from '@/utils/cssVar';
const { openTraceId, closeTrace } = useZipkinTracePopout();
const traceIdRef = computed(() => openTraceId.value);
@@ -129,15 +130,18 @@ function clearSpan(): void {
watch(traceIdRef, () => { selectedSpanId.value = null; });
// ── Color per service so each row reads as a band ────────────────
-const SERVICE_PALETTE = [
- '#f97316', '#60a5fa', '#a78bfa', '#22d3ee',
- '#f472b6', '#34d399', '#fbbf24', '#fb7185',
-];
+// First entry tracks `--sw-accent` so the brand color in the trace
+// waterfall follows the active theme. Rest stay constant for service
+// differentiation.
function serviceColor(name: string | null | undefined): string {
if (!name) return 'var(--sw-fg-3)';
+ const palette = [
+ readAccent('#f97316'), '#60a5fa', '#a78bfa', '#22d3ee',
+ '#f472b6', '#34d399', '#fbbf24', '#fb7185',
+ ];
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
- return SERVICE_PALETTE[Math.abs(h) % SERVICE_PALETTE.length];
+ return palette[Math.abs(h) % palette.length]!;
}
// ── Formatting ────────────────────────────────────────────────────
diff --git a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
index 961ecd8..a718385 100644
--- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
@@ -448,7 +448,7 @@ function isVisible(
}
if (cond.startsWith('#entity.')) {
// Entity-attribute predicates need an attributes feed we don't
- // surface yet (Phase 7-ish). Render the widget for now.
+ // surface yet. Render the widget unconditionally for now.
return true;
}
return true;
@@ -618,7 +618,7 @@ function isVisible(
<div v-if="configLoading" class="empty">Loading dashboard config…</div>
<div v-else-if="widgets.length === 0" class="empty">
- No widgets defined for this layer. Phase 7 admin will let operators add
their own.
+ No widgets defined for this layer. Add some via Dashboard setup → Layer
dashboards.
</div>
<!-- The previous "Select an instance/endpoint above to view its
metrics" branches implied operator action was needed and
diff --git a/apps/ui/src/render/widgets/AlarmsWidget.vue
b/apps/ui/src/render/widgets/AlarmsWidget.vue
index 1bb7d20..78b6523 100644
--- a/apps/ui/src/render/widgets/AlarmsWidget.vue
+++ b/apps/ui/src/render/widgets/AlarmsWidget.vue
@@ -58,14 +58,22 @@ const props = withDefaults(
{ limit: 10 },
);
-const WINDOW_MS = 60 * 60_000;
+// Fallback window when the admin config hasn't loaded yet — matches
+// the alert page-setup bundled default so the first paint reads the
+// same window as the admin's saved value once it lands.
+const FALLBACK_WINDOW_MS = 20 * 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. */
+ * view so the per-poll fetch cap (`overviewAlarmsLimit`) AND the
+ * window (`defaultWindowMs`) stay in sync without a separate
+ * roundtrip. Previously this widget hardcoded a 60-minute window,
+ * which contradicted the alarms page + topbar badge using the
+ * admin's `defaultWindowMs` (default 20m). Unified now: all three
+ * surfaces query the same window per the operator-configured value
+ * in `/admin/alert-page-setup`. */
const cfgQuery = useQuery({
queryKey: ['alarms/config'],
queryFn: (): Promise<AlarmsConfig> => bff.alarms.config(),
@@ -74,6 +82,9 @@ const cfgQuery = useQuery({
const fetchLimit = computed<number>(
() => cfgQuery.data.value?.overviewAlarmsLimit ??
OVERVIEW_ALARMS_LIMIT_DEFAULT,
);
+const windowMs = computed<number>(
+ () => cfgQuery.data.value?.defaultWindowMs ?? FALLBACK_WINDOW_MS,
+);
const alarmsQuery = useQuery({
/* Layer is keyed only in new-API mode — legacy mode ignores it
@@ -85,10 +96,11 @@ const alarmsQuery = useQuery({
hasQueryAlarms.value,
hasQueryAlarms.value ? props.layer ?? '' : 'all',
fetchLimit.value,
+ windowMs.value,
]),
queryFn: () => {
const end = Date.now();
- const start = end - WINDOW_MS;
+ const start = end - windowMs.value;
return bff.alarms.list({
startTime: start,
endTime: end,
diff --git a/apps/ui/src/utils/cssVar.ts b/apps/ui/src/utils/cssVar.ts
new file mode 100644
index 0000000..6d0484e
--- /dev/null
+++ b/apps/ui/src/utils/cssVar.ts
@@ -0,0 +1,44 @@
+/*
+ * 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.
+ */
+
+/**
+ * Read a CSS custom property off `<html>` at runtime — used by chart
+ * libraries (ECharts canvas-rendered series, etc.) that can't consume
+ * `var(--…)` strings directly and need a resolved hex/color string.
+ *
+ * Examples:
+ * readCssVar('--sw-accent') // active theme's accent hex
+ * readCssVar('--sw-fg-2', '#888') // muted fg, with fallback
+ *
+ * Resolved at call time, so callers that need to track theme changes
+ * should re-read on every render (or wire a reactive `useThemeStore`
+ * subscription if updates need to be reactive across an ECharts
+ * instance — that's a per-call decision).
+ */
+export function readCssVar(name: string, fallback = ''): string {
+ if (typeof window === 'undefined' || typeof document === 'undefined') return
fallback;
+ if (!name.startsWith('--')) name = `--${name}`;
+ const v =
getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+ return v || fallback;
+}
+
+/** Convenience: the current theme's accent color, with a safe hex
+ * fallback so chart-init paths that race ahead of the stylesheet
+ * load don't get an empty string. */
+export function readAccent(fallback = '#f97316'): string {
+ return readCssVar('--sw-accent', fallback);
+}
diff --git a/apps/ui/src/utils/metricCatalog.ts
b/apps/ui/src/utils/metricCatalog.ts
index 2efb76b..df555ae 100644
--- a/apps/ui/src/utils/metricCatalog.ts
+++ b/apps/ui/src/utils/metricCatalog.ts
@@ -21,8 +21,9 @@
* Definitions cribbed from booster-ui's widget configs in
* `oap-server/.../ui-initialized-templates` — each upstream widget carries
* `{name, title, tips}` per expression, which we collapse to one
- * `MetricMeta` per logical metric. Phase 7's admin UI lets operators
- * extend/override this catalog per deployment.
+ * `MetricMeta` per logical metric. Operators extend / override per
+ * deployment via the admin pages under Dashboard setup
+ * (`/admin/overview-templates`, `/admin/layer-dashboards`).
*
* ---------------------------------------------------------------------
* Terminology standard
diff --git a/docs/access-control/admin-pages.md
b/docs/access-control/admin-pages.md
index c463ab5..448e8d4 100644
--- a/docs/access-control/admin-pages.md
+++ b/docs/access-control/admin-pages.md
@@ -117,7 +117,7 @@ Read-only. To change roles, edit `rbac.roles` in
`horizon.yaml`; hot-reload appl
| Page | Verb | Default role(s) granted |
|---|---|---|
-| `/admin/cluster` | `cluster:read` | maintainer, operator, admin |
+| `/operate/cluster` | `cluster:read` | maintainer, operator, admin |
| `/admin/auth-status` | `auth:read` | (none built-in; assign explicitly) |
| `/admin/users` | `user:read` | (none built-in; assign explicitly) |
| `/admin/roles` | `role:read` | (none built-in; assign explicitly) |
diff --git a/docs/access-control/rbac.md b/docs/access-control/rbac.md
index debb0b4..3766315 100644
--- a/docs/access-control/rbac.md
+++ b/docs/access-control/rbac.md
@@ -47,7 +47,7 @@ Source: `apps/bff/src/rbac/verbs.ts`. Twenty-eight verbs
grouped into areas:
| Verb | Gates |
|---|---|
-| `cluster:read` | Cluster Status page (`/admin/cluster`). |
+| `cluster:read` | Cluster Status page (`/operate/cluster`). |
| `inspect:read` | Inspect page (`/admin/inspect`). |
### Admin surface
@@ -139,9 +139,9 @@ Default mapping:
```yaml
landingByRole:
viewer: /
- maintainer: /admin/cluster
+ maintainer: /operate/cluster
operator: /
- admin: /admin/cluster
+ admin: /operate/cluster
```
When a user has multiple roles, the **first role on the user** wins. Order
matters in `auth.local.users[].roles` and in LDAP group-mapping resolution.
@@ -215,7 +215,7 @@ roles:
auditor:
- "*:read" # all reads only
landingByRole:
- auditor: /admin/cluster
+ auditor: /operate/cluster
```
`*:read` grants every read — useful for audit access without write capability.
diff --git a/docs/compatibility/cluster-status.md
b/docs/compatibility/cluster-status.md
index c484c2d..8331c6f 100644
--- a/docs/compatibility/cluster-status.md
+++ b/docs/compatibility/cluster-status.md
@@ -1,6 +1,6 @@
# Cluster Status Check Sequence
-The Cluster Status page (`/admin/cluster`, sidebar **Operate → Cluster**) is
the operator's single pane for "is the OAP backend healthy and configured
correctly?" It runs **two independent checks in parallel** against the two OAP
ports — they do not block each other, and the page surfaces each pane's result
independently.
+The Cluster Status page (`/operate/cluster`, sidebar **Operate → Cluster**) is
the operator's single pane for "is the OAP backend healthy and configured
correctly?" It runs **two independent checks in parallel** against the two OAP
ports — they do not block each other, and the page surfaces each pane's result
independently.
This page is intentionally two-pane: a healthy `:12800` with broken `:17128`
is a real and recoverable state (forgot to expose the admin port behind a
Kubernetes Service), and Horizon makes that diagnosis obvious.
@@ -101,14 +101,3 @@ The triage flow during "Horizon shows banners I don't
understand":
3. **Is the health score `> 0`?** OAP is up but degraded — pull `details` from
`checkHealth` (visible in the Query pane) and triage on the OAP side.
4. **Cluster member count off?** Either DNS / Service config is wrong, or one
OAP node is down — check `/status/cluster/nodes` output and your OAP cluster
controller.
-## Planned (Phase 6 / 7)
-
-The page documents upcoming additions in an inline strip:
-
-- **Per-node module activity matrix** — module × provider × node
active/inactive grid (currently the dump is consumed cluster-wide; per-node
breakdown requires a per-node admin call).
-- **Storage backend health** — BanyanDB / Elasticsearch / JDBC connection
pool, index lag, throughput.
-- **Receiver activity** — gRPC / HTTP / Kafka / OTLP throughput, queue depth.
-- **Effective configuration tree** — two-node diff of merged config (advanced
troubleshooting).
-- **TTL & retention grid** — hot / warm / cold storage timeline per metric
scope.
-
-These are not implemented today. The placeholder on the page lists them so the
operator knows what is and is not surfaced.
diff --git a/docs/compatibility/oap-version.md
b/docs/compatibility/oap-version.md
index ebaf1eb..c07d86b 100644
--- a/docs/compatibility/oap-version.md
+++ b/docs/compatibility/oap-version.md
@@ -37,7 +37,7 @@ If you only need triage (dashboards, alarms, traces, logs),
v10 is sufficient. I
Once Horizon is up:
- **Topbar status chip** — small build-version pill in the right-side cluster
strip, fed by the GraphQL `version` query.
-- **Cluster Status page → Query pane** (`/admin/cluster`) — version, server
timezone, current timestamp, health score.
+- **Cluster Status page → Query pane** (`/operate/cluster`) — version, server
timezone, current timestamp, health score.
The version is fetched via:
diff --git a/docs/operate/cluster-metadata.md b/docs/operate/cluster-metadata.md
index 1f52605..8474dd8 100644
--- a/docs/operate/cluster-metadata.md
+++ b/docs/operate/cluster-metadata.md
@@ -1,6 +1,6 @@
# Cluster Status & Metadata
-Path: `/admin/cluster`. Verb: `cluster:read` (granted by maintainer, operator,
admin).
+Path: `/operate/cluster`. Verb: `cluster:read` (granted by maintainer,
operator, admin).
This is the operator's single pane for "is the OAP backend wired correctly?".
It surfaces:
diff --git a/docs/setup/overview.md b/docs/setup/overview.md
index bffbe9d..6efdbd8 100644
--- a/docs/setup/overview.md
+++ b/docs/setup/overview.md
@@ -77,7 +77,7 @@ session:
### 5. Open the UI
-Browse to `http://<bff-host>:8081/`. Log in with the user you created. The
first thing to check is the **Cluster Status** page (`/admin/cluster`):
+Browse to `http://<bff-host>:8081/`. Log in with the user you created. The
first thing to check is the **Cluster Status** page (`/operate/cluster`):
- Query pane should be green — version, timezone, health score visible.
- Admin pane should be green if you set `SW_ADMIN_SERVER=default` and the rest
of the selectors on OAP.
diff --git a/docs/setup/rbac.md b/docs/setup/rbac.md
index 778d4f4..8ea0423 100644
--- a/docs/setup/rbac.md
+++ b/docs/setup/rbac.md
@@ -14,9 +14,9 @@ rbac:
admin: ["*"]
landingByRole:
viewer: /
- maintainer: /admin/cluster
+ maintainer: /operate/cluster
operator: /
- admin: /admin/cluster
+ admin: /operate/cluster
```
## Fields
@@ -56,9 +56,9 @@ Default:
```yaml
landingByRole:
viewer: /
- maintainer: /admin/cluster
+ maintainer: /operate/cluster
operator: /
- admin: /admin/cluster
+ admin: /operate/cluster
```
The login flow returns this route as `landingRoute` in the login response. The
UI router uses it as the post-login destination unless a `?redirect=` query
param overrides (e.g., the user was bounced to login from a protected route —
they return there after auth).