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&nbsp;/&nbsp;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 &amp; 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).


Reply via email to