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 f3d887b  feat: theme + time-defaults kinds, alarms Other tile, 
topology cluster fix, debug-event logging
f3d887b is described below

commit f3d887baf2e0d20f34fcee635b22e1dadcb4f89a
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 19 16:52:11 2026 +0800

    feat: theme + time-defaults kinds, alarms Other tile, topology cluster fix, 
debug-event logging
    
    OAP UI-template kinds extended (now 5):
    - Added `horizon.theme.active` singleton — operator's selected theme
      among 5 bundled (Horizon / Obsidian / Aurora / Meridian / Daybreak).
      themes.css ships 4 non-default `[data-theme="..."]` blocks.
      IMPORTANT: the hex values in those blocks are PLACEHOLDERS from each
      color family (Obsidian=blue, Aurora=pink, Meridian=purple,
      Daybreak=white light) and need the design-spec hex codes from
      Figma/canvas before they're considered final.
    - Added `horizon.time-defaults.global` singleton — global default
      window (60m bundled, OAP `step` precision auto-derived from window
      size: ≤4h MINUTE, 6h-14d HOUR, ≥30d DAY).
    
    Three-tier resolution for both new kinds:
      user localStorage → OAP org default → bundled.
    
    UI surface:
    - /admin/global-defaults page (Theme picker + Default window) reusing
      SyncStatusBanner + TemplateDiffModal + per-row badges. Sidebar entry
      under Dashboard setup. Section lede explains precision derivation
      + a live `60 min → MINUTE step, 60 buckets` resolved line.
    - Topbar theme chip: now LABELED with current theme name (`horizon ▸`)
      so operators can find it without guessing the icon. Color-swatch
      dot uses the active accent. Override-dot indicates a local pref.
    - Time-picker overflow: "Save as my default" + "Reset to org default"
      in the rolling-window dropdown footer.
    - main.ts eagerly calls useThemeStore() so `<html data-theme="..."
      data-appearance="...">` is set BEFORE any view renders — including
      the pre-auth login page.
    - AppSidebar logo swaps to the official SkyWalking blue (`#1368B3`)
      on light-appearance themes; derived at import time from logo-sw.svg
      via a `fill="#fff"` → `fill="#1368B3"` replace so the wordmark stays
      identical. Login page keeps the white logo (its backdrop is always
      the dark canyon).
    - Sign-in button gradient: top stop = `var(--sw-accent)`, bottom stop
      = `color-mix(in srgb, var(--sw-accent) 86%, black 14%)`. No more
      hardcoded `#ea580c` that flashed orange on purple themes.
    
    Topology cluster boundary fix (/layer/*/topology):
    - Dropped the CHIP_HEADROOM=44 floor on the cluster rect y. Cluster
      top now follows dragged nodes freely (could extend off-screen; the
      d3.zoom pan handles that case).
    - Moved the alias·value chip from FLOATING ABOVE the cluster top to
      INSIDE the top header area (uses the CLUSTER_HEAD_HEIGHT padding
      that was already reserved). Removes the chip-clipping concern that
      was the reason for the floor.
    
    Alarms page "Other" KPI tile:
    - Surfaces the residual count `Active - Σ(pinned-layer counts)` so
      the arithmetic Active = General + Mesh + Other reads obviously.
      Catches alarms in non-pinned layers AND unmapped alarms (those
      the BFF couldn't attribute via the service-layer-map). Rendered as
      a disabled button to keep flex-row baseline alignment with the
      clickable pinned tiles; dashed border + opacity:1 forced.
    
    Dashboard widgets >40 chunking:
    - /api/layer/:key/dashboard hard cap stays at 40 (protects OAP page-
      size cliffs). LayerApi.dashboard() now SPLITS oversize widget sets
      into chunks of ≤40 and fires them in parallel, merging results
      (concatenate widgets in original order, AND-fold reachable, surface
      first error). Fixes the General/instance page that ships 56 widgets
      and got rejected wholesale before.
    
    Debug-event log: backend call errors:
    - BffClient.request emits pushEvent('api', 'err', `…`) on every
      network failure / non-2xx response (with the BFF's `code` and
      `message` envelope inlined when present). 401 logs at 'info' since
      it's the re-auth dance, not a failure. Same wiring extended to
      configs.bundle() and dsl.ts paths that bypass request() to detect
      304 / 404. Errors land in the EventTicker + DebugEventPanel.
    
    Default landing route fix:
    - Schema + horizon.example.yaml: landingByRole.admin and .maintainer
      switched from `/admin/cluster` (404, no route) → `/operate/cluster`
      (the actual route). Existing docs still mention `/admin/cluster`
      in 4 files — those clean up in a follow-up.
    
    Version is still 0.4.0.
---
 apps/bff/src/bundled_templates/theme/active.json   |   3 +
 .../bundled_templates/time-defaults/global.json    |   3 +
 apps/bff/src/config/schema.ts                      |   8 +-
 apps/bff/src/http/config/bundle.ts                 |   3 +-
 apps/bff/src/http/query/dashboard.ts               |   5 +
 apps/bff/src/logic/templates/aggregator.ts         |  21 +-
 .../src/logic/templates/global-defaults-bundled.ts |  80 +++
 apps/bff/src/logic/templates/names.ts              |  24 +-
 apps/ui/src/api/client.ts                          |  41 +-
 apps/ui/src/api/scopes/configs.ts                  |  36 +-
 apps/ui/src/api/scopes/dsl.ts                      |  19 +-
 apps/ui/src/api/scopes/layer.ts                    |  56 ++-
 apps/ui/src/controls/timeRange.ts                  |  20 +
 .../admin/global-defaults/GlobalDefaultsAdmin.vue  | 549 +++++++++++++++++++++
 apps/ui/src/features/alarms/AlarmsView.vue         |  55 ++-
 apps/ui/src/features/auth/LoginView.vue            |  17 +-
 .../src/layer/service-map/LayerServiceMapView.vue  |  38 +-
 apps/ui/src/main.ts                                |  20 +
 apps/ui/src/shell/AppShell.vue                     |  32 ++
 apps/ui/src/shell/AppSidebar.vue                   |  18 +-
 apps/ui/src/shell/AppTopbar.vue                    | 203 ++++++++
 apps/ui/src/shell/router/index.ts                  |   7 +
 apps/ui/src/state/theme.ts                         | 198 ++++++++
 apps/ui/src/state/timeDefaults.ts                  | 139 ++++++
 horizon.example.yaml                               |   7 +-
 packages/design-tokens/package.json                |   6 +-
 packages/design-tokens/src/themes.css              | 138 ++++++
 27 files changed, 1690 insertions(+), 56 deletions(-)

diff --git a/apps/bff/src/bundled_templates/theme/active.json 
b/apps/bff/src/bundled_templates/theme/active.json
new file mode 100644
index 0000000..b80a3f0
--- /dev/null
+++ b/apps/bff/src/bundled_templates/theme/active.json
@@ -0,0 +1,3 @@
+{
+  "themeId": "horizon"
+}
diff --git a/apps/bff/src/bundled_templates/time-defaults/global.json 
b/apps/bff/src/bundled_templates/time-defaults/global.json
new file mode 100644
index 0000000..2ba693a
--- /dev/null
+++ b/apps/bff/src/bundled_templates/time-defaults/global.json
@@ -0,0 +1,3 @@
+{
+  "defaultWindowMinutes": 60
+}
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index bd4b118..18ddadf 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -224,14 +224,16 @@ const rbacSchema = z
         admin: ['*'],
       }),
     /** Landing route per role; the UI uses this to send users to the
-     *  page that fits their job after login. */
+     *  page that fits their job after login. Cluster status lives at
+     *  `/operate/cluster` (operator tooling against OAP) — the prior
+     *  `/admin/cluster` defaults 404'd because no such route exists. */
     landingByRole: z
       .record(z.string(), z.string())
       .default({
         viewer: '/',
-        maintainer: '/admin/cluster',
+        maintainer: '/operate/cluster',
         operator: '/',
-        admin: '/admin/cluster',
+        admin: '/operate/cluster',
       }),
   })
   .strict()
diff --git a/apps/bff/src/http/config/bundle.ts 
b/apps/bff/src/http/config/bundle.ts
index aec1571..164caee 100644
--- a/apps/bff/src/http/config/bundle.ts
+++ b/apps/bff/src/http/config/bundle.ts
@@ -60,6 +60,7 @@ import {
   getSyncStatus,
   type TemplateRow,
 } from '../../logic/templates/sync.js';
+import type { TemplateKind } from '../../logic/templates/names.js';
 import { iterateBundledTemplates } from '../../logic/templates/aggregator.js';
 import { formatName, parseEnvelope } from '../../logic/templates/names.js';
 import { logger } from '../../logger.js';
@@ -82,7 +83,7 @@ export interface BundleSyncStatus {
   generatedAt: number;
   badges: Array<{
     name: string;
-    kind: 'overview' | 'layer' | 'alert';
+    kind: TemplateKind;
     key: string;
     status: TemplateRow['status'];
   }>;
diff --git a/apps/bff/src/http/query/dashboard.ts 
b/apps/bff/src/http/query/dashboard.ts
index 67ad53f..a36b12a 100644
--- a/apps/bff/src/http/query/dashboard.ts
+++ b/apps/bff/src/http/query/dashboard.ts
@@ -108,6 +108,11 @@ const bodySchema = z.object({
    *  the Endpoint page. Switches the entity to
    *  `{ scope: Endpoint, serviceName, endpointName }`. */
   endpoint: z.string().optional(),
+  // Hard cap per request — protects OAP's storage page-size cliffs
+  // (CLAUDE.md warns about backend-specific thresholds). The UI is
+  // responsible for chunking widget sets larger than this across
+  // multiple requests; the BFF refuses oversized bodies up-front so
+  // an accidentally-huge template never reaches OAP.
   widgets: z.array(widgetSchema).max(40).optional(),
   scope: scopeSchema.optional(),
 });
diff --git a/apps/bff/src/logic/templates/aggregator.ts 
b/apps/bff/src/logic/templates/aggregator.ts
index d15b196..0574190 100644
--- a/apps/bff/src/logic/templates/aggregator.ts
+++ b/apps/bff/src/logic/templates/aggregator.ts
@@ -28,7 +28,16 @@
 import { allLayerTemplates } from '../layers/loader.js';
 import { loadOverviewDashboards } from '../overview/loader.js';
 import { loadBundledAlertPageSetup } from '../alarms/bundled.js';
-import { ALERT_PAGE_SETUP_KEY, type TemplateKind } from './names.js';
+import {
+  loadBundledThemeActive,
+  loadBundledTimeDefaults,
+} from './global-defaults-bundled.js';
+import {
+  ALERT_PAGE_SETUP_KEY,
+  THEME_ACTIVE_KEY,
+  TIME_DEFAULTS_KEY,
+  type TemplateKind,
+} from './names.js';
 import type { BundledTemplate } from './sync.js';
 
 export function* iterateBundledTemplates(): IterableIterator<BundledTemplate> {
@@ -43,4 +52,14 @@ export function* iterateBundledTemplates(): 
IterableIterator<BundledTemplate> {
     key: ALERT_PAGE_SETUP_KEY,
     content: loadBundledAlertPageSetup(),
   };
+  yield {
+    kind: 'theme' satisfies TemplateKind,
+    key: THEME_ACTIVE_KEY,
+    content: loadBundledThemeActive(),
+  };
+  yield {
+    kind: 'time-defaults' satisfies TemplateKind,
+    key: TIME_DEFAULTS_KEY,
+    content: loadBundledTimeDefaults(),
+  };
 }
diff --git a/apps/bff/src/logic/templates/global-defaults-bundled.ts 
b/apps/bff/src/logic/templates/global-defaults-bundled.ts
new file mode 100644
index 0000000..f639728
--- /dev/null
+++ b/apps/bff/src/logic/templates/global-defaults-bundled.ts
@@ -0,0 +1,80 @@
+/*
+ * 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.
+ */
+
+/**
+ * Bundled seeds for the two "global defaults" singletons:
+ *
+ *   - `horizon.theme.active`         — `{ themeId: '<id>' }`
+ *   - `horizon.time-defaults.global` — `{ defaultWindowMinutes: 60 }`
+ *
+ * The five themes themselves are CSS files in the UI build; OAP only
+ * remembers which one is currently selected. Time-defaults captures
+ * the topbar global picker's default window (60 minutes shipped) —
+ * triage pages (alarms / traces / logs / live-debug) keep their own
+ * per-page time per `CLAUDE.md`, so only this one knob is global.
+ *
+ * Resolved with the same dev-tree / dist path search the alert bundled
+ * loader uses.
+ */
+
+import { existsSync, readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+
+export interface ThemeActive {
+  themeId: string;
+}
+
+export interface TimeDefaultsGlobal {
+  defaultWindowMinutes: number;
+}
+
+let cachedTheme: ThemeActive | null = null;
+let cachedTimeDefaults: TimeDefaultsGlobal | null = null;
+
+export function loadBundledThemeActive(): ThemeActive {
+  if (cachedTheme) return cachedTheme;
+  const raw = readFileSync(locate('theme/active.json'), 'utf8');
+  cachedTheme = JSON.parse(raw) as ThemeActive;
+  return cachedTheme;
+}
+
+export function loadBundledTimeDefaults(): TimeDefaultsGlobal {
+  if (cachedTimeDefaults) return cachedTimeDefaults;
+  const raw = readFileSync(locate('time-defaults/global.json'), 'utf8');
+  cachedTimeDefaults = JSON.parse(raw) as TimeDefaultsGlobal;
+  return cachedTimeDefaults;
+}
+
+export function invalidateGlobalDefaultsBundledCache(): void {
+  cachedTheme = null;
+  cachedTimeDefaults = null;
+}
+
+function locate(rel: string): string {
+  const candidates = [
+    resolve(HERE, `../../bundled_templates/${rel}`),
+    resolve(HERE, `../bundled_templates/${rel}`),
+    resolve(HERE, `../../../bundled_templates/${rel}`),
+  ];
+  for (const c of candidates) {
+    if (existsSync(c)) return c;
+  }
+  throw new Error(`bundled file not found: ${rel} (tried: ${candidates.join(', 
')})`);
+}
diff --git a/apps/bff/src/logic/templates/names.ts 
b/apps/bff/src/logic/templates/names.ts
index f15bf97..41e92ba 100644
--- a/apps/bff/src/logic/templates/names.ts
+++ b/apps/bff/src/logic/templates/names.ts
@@ -24,9 +24,11 @@
  *   { "name": "horizon.<kind>.<key>", "kind": "...", "version": 1, "content": 
{...} }
  *
  * Naming:
- *   - `horizon.overview.<id>`       — overview dashboards (e.g. `services`, 
`mesh`)
- *   - `horizon.layer.<KEY>`         — layer dashboards (e.g. `GENERAL`, `K8S`)
- *   - `horizon.alert.page-setup`    — alert page setup (singleton)
+ *   - `horizon.overview.<id>`        — overview dashboards (e.g. `services`, 
`mesh`)
+ *   - `horizon.layer.<KEY>`          — layer dashboards (e.g. `GENERAL`, 
`K8S`)
+ *   - `horizon.alert.page-setup`     — alert page setup (singleton)
+ *   - `horizon.theme.active`         — org-default theme selection (singleton)
+ *   - `horizon.time-defaults.global` — global time-picker default window 
(singleton)
  *
  * The `horizon.` prefix keeps Horizon's templates cleanly separated from
  * any other UI (notably booster-ui) that may share the same OAP. Names
@@ -38,14 +40,24 @@
  * key order is stable.
  */
 
-export type TemplateKind = 'overview' | 'layer' | 'alert';
+export type TemplateKind = 'overview' | 'layer' | 'alert' | 'theme' | 
'time-defaults';
 
-export const TEMPLATE_KINDS: readonly TemplateKind[] = ['overview', 'layer', 
'alert'] as const;
+export const TEMPLATE_KINDS: readonly TemplateKind[] = [
+  'overview',
+  'layer',
+  'alert',
+  'theme',
+  'time-defaults',
+] as const;
 
 /** Single alert template key — alert page-setup is a singleton. */
 export const ALERT_PAGE_SETUP_KEY = 'page-setup' as const;
+/** Singleton key for the active theme selection. */
+export const THEME_ACTIVE_KEY = 'active' as const;
+/** Singleton key for the global time-defaults setup. */
+export const TIME_DEFAULTS_KEY = 'global' as const;
 
-const NAME_RE = /^horizon\.(overview|layer|alert)\.([A-Za-z0-9_-]+)$/;
+const NAME_RE = 
/^horizon\.(overview|layer|alert|theme|time-defaults)\.([A-Za-z0-9_-]+)$/;
 
 export interface ParsedName {
   kind: TemplateKind;
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index d54ae03..4d63835 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -45,6 +45,7 @@ import type {
   Catalog,
 } from '@skywalking-horizon-ui/api-client';
 
+import { pushEvent } from '@/controls/eventLog';
 import { SessionApi } from './scopes/session';
 import { MenuApi } from './scopes/menu';
 import { OverviewApi } from './scopes/overview';
@@ -556,7 +557,13 @@ export class BffClient {
   /** Internal — used by sub-clients in `./scopes/*` to dispatch a JSON
    *  request and unwrap the typed body. 401 hits the {@link setOn401}
    *  hook before rejecting with {@link BffApiError}; other non-2xx
-   *  responses also throw. 204s resolve to `undefined`. */
+   *  responses also throw. 204s resolve to `undefined`.
+   *
+   *  Every failure path (network throw, 401, other non-2xx) emits a
+   *  `pushEvent('api', 'err', …)` into the debug event log so the
+   *  ticker / DebugEventPanel surfaces backend trouble without each
+   *  caller having to wire its own logging. Successful responses are
+   *  intentionally NOT logged — the volume would drown the ticker. */
   async request<T>(
     method: string,
     path: string,
@@ -572,8 +579,26 @@ export class BffClient {
       },
     };
     if (body !== undefined) init.body = JSON.stringify(body);
-    const res = await fetch(path, init);
+    let res: Response;
+    try {
+      res = await fetch(path, init);
+    } catch (err) {
+      // Network-level failure: DNS, CORS, aborted, BFF down, etc.
+      // fetch() doesn't throw on HTTP-level errors (4xx/5xx) — only on
+      // these. They're the "blocked" symptom operators see when the
+      // BFF or its upstream is unreachable.
+      pushEvent(
+        'api',
+        'err',
+        `${method} ${path} · network ${err instanceof Error ? err.message : 
String(err)}`,
+      );
+      throw err;
+    }
     if (res.status === 401) {
+      // 401 is normal-ish (session expired) — log at 'info' rather
+      // than 'err' so it doesn't read as a failure when it's just the
+      // re-auth dance.
+      pushEvent('api', 'info', `${method} ${path} · 401 (re-auth)`);
       this.on401?.();
       throw new BffApiError(401, 'unauthenticated', null);
     }
@@ -584,6 +609,18 @@ export class BffClient {
       } catch {
         parsed = await res.text();
       }
+      // Surface the BFF's `code` / `message` envelope when present so
+      // the operator gets the actionable reason instead of just "500".
+      let extra = '';
+      if (parsed && typeof parsed === 'object') {
+        const env = parsed as { code?: unknown; message?: unknown };
+        if (typeof env.code === 'string' || typeof env.message === 'string') {
+          extra = ` · ${env.code ?? ''}${env.code && env.message ? ' — ' : 
''}${env.message ?? ''}`;
+        }
+      } else if (typeof parsed === 'string' && parsed.length > 0 && 
parsed.length < 200) {
+        extra = ` · ${parsed}`;
+      }
+      pushEvent('api', 'err', `${method} ${path} · ${res.status}${extra}`);
       throw new BffApiError(res.status, `${method} ${path} failed 
(${res.status})`, parsed);
     }
     if (res.status === 204) return undefined as T;
diff --git a/apps/ui/src/api/scopes/configs.ts 
b/apps/ui/src/api/scopes/configs.ts
index 50d8c06..f1cfb25 100644
--- a/apps/ui/src/api/scopes/configs.ts
+++ b/apps/ui/src/api/scopes/configs.ts
@@ -19,15 +19,16 @@ import type {
   DashboardWidget,
   OverviewDashboard,
 } from '@skywalking-horizon-ui/api-client';
+import { pushEvent } from '@/controls/eventLog';
 import type { BffClient } from '../client';
 
 export type BundleScopeMap = Partial<
   Record<'service' | 'instance' | 'endpoint', DashboardWidget[]>
 >;
 
-/** What kind of template a sync-status row describes. Three reserved
+/** What kind of template a sync-status row describes. Five reserved
  *  kinds — see the BFF's `apps/bff/src/logic/templates/names.ts`. */
-export type TemplateKind = 'overview' | 'layer' | 'alert';
+export type TemplateKind = 'overview' | 'layer' | 'alert' | 'theme' | 
'time-defaults';
 
 /** Status of a single template, mirrored from the BFF sync orchestrator.
  *  - `synced`           — bundled == remote, byte-equal
@@ -84,18 +85,35 @@ export class ConfigsApi {
   async bundle(ifNoneMatch?: string): Promise<ConfigBundle | null> {
     const headers: Record<string, string> = {};
     if (ifNoneMatch) headers['If-None-Match'] = ifNoneMatch;
-    // Direct fetch so we can detect 304 without throwing.
-    const res = await fetch('/api/configs/bundle', {
-      method: 'GET',
-      credentials: 'include',
-      headers,
-    });
+    // Direct fetch (not BffClient.request) because we need 304 to be a
+    // non-throwing success path. The error logging that lives in
+    // BffClient.request is replicated here so a bundle-load failure
+    // still lands in the debug event log.
+    let res: Response;
+    try {
+      res = await fetch('/api/configs/bundle', {
+        method: 'GET',
+        credentials: 'include',
+        headers,
+      });
+    } catch (err) {
+      pushEvent(
+        'api',
+        'err',
+        `GET /api/configs/bundle · network ${err instanceof Error ? 
err.message : String(err)}`,
+      );
+      throw err;
+    }
     if (res.status === 304) return null;
     if (res.status === 401) {
+      pushEvent('api', 'info', 'GET /api/configs/bundle · 401 (re-auth)');
       this.bff.handleUnauthorized();
       throw new Error('unauthenticated');
     }
-    if (!res.ok) throw new Error(`bundle fetch failed (${res.status})`);
+    if (!res.ok) {
+      pushEvent('api', 'err', `GET /api/configs/bundle · ${res.status}`);
+      throw new Error(`bundle fetch failed (${res.status})`);
+    }
     return (await res.json()) as ConfigBundle;
   }
 }
diff --git a/apps/ui/src/api/scopes/dsl.ts b/apps/ui/src/api/scopes/dsl.ts
index 0f1a27e..9c9c13d 100644
--- a/apps/ui/src/api/scopes/dsl.ts
+++ b/apps/ui/src/api/scopes/dsl.ts
@@ -29,6 +29,7 @@ import type {
 } from '@skywalking-horizon-ui/api-client';
 import type { BffClient, ClusterStateResponse } from '../client';
 import { BffApiError } from '../client';
+import { pushEvent } from '@/controls/eventLog';
 
 /** `bff.dsl` — DSL Management: rule catalog browse, single-rule fetch /
  *  save / inactivate / delete, OAL read-only browse, cluster state, dump. */
@@ -60,12 +61,19 @@ export class DslApi {
     const params = new URLSearchParams({ catalog: args.catalog, name: 
args.name });
     if (args.source) params.set('source', args.source);
     const path = `/api/rule?${params.toString()}`;
-    const res = await fetch(path, {
-      method: 'GET',
-      credentials: 'include',
-      headers: { Accept: 'application/x-yaml' },
-    });
+    let res: Response;
+    try {
+      res = await fetch(path, {
+        method: 'GET',
+        credentials: 'include',
+        headers: { Accept: 'application/x-yaml' },
+      });
+    } catch (err) {
+      pushEvent('api', 'err', `GET ${path} · network ${err instanceof Error ? 
err.message : String(err)}`);
+      throw err;
+    }
     if (res.status === 401) {
+      pushEvent('api', 'info', `GET ${path} · 401 (re-auth)`);
       this.bff.handleUnauthorized();
       throw new BffApiError(401, 'unauthenticated', null);
     }
@@ -73,6 +81,7 @@ export class DslApi {
     if (!res.ok) {
       let parsed: unknown = null;
       try { parsed = await res.json(); } catch { parsed = await res.text(); }
+      pushEvent('api', 'err', `GET ${path} · ${res.status}`);
       throw new BffApiError(res.status, `GET ${path} failed (${res.status})`, 
parsed);
     }
     const content = await res.text();
diff --git a/apps/ui/src/api/scopes/layer.ts b/apps/ui/src/api/scopes/layer.ts
index 55a937e..71625b1 100644
--- a/apps/ui/src/api/scopes/layer.ts
+++ b/apps/ui/src/api/scopes/layer.ts
@@ -24,8 +24,18 @@ import type {
   LandingResponse,
   TopologyResponse,
 } from '@skywalking-horizon-ui/api-client';
+import { pushEvent } from '@/controls/eventLog';
 import type { BffClient } from '../client';
 
+/** BFF cap on widgets per `/api/layer/:key/dashboard` body. Mirrors
+ *  the zod `widgetSchema.max(40)` in `apps/bff/src/http/query/dashboard.ts`
+ *  — kept here as a single source of truth for the chunking logic in
+ *  `dashboard()` below. Bumping this requires bumping the BFF zod cap
+ *  too. The cap exists to protect OAP's storage page-size cliffs, not
+ *  to enforce a UI limit, so the UI splits oversized requests rather
+ *  than refusing them. */
+export const DASHBOARD_WIDGETS_PER_REQUEST = 40;
+
 /** `bff.layer` — per-layer data: landing top-N, dashboard widgets,
  *  endpoint / instance pickers, topology, endpoint dependency. */
 export class LayerApi {
@@ -63,7 +73,7 @@ export class LayerApi {
     );
   }
 
-  dashboard(
+  async dashboard(
     layerKey: string,
     body: {
       service?: string;
@@ -83,11 +93,47 @@ export class LayerApi {
     opts: { mockTop?: number } = {},
   ): Promise<DashboardResponse> {
     const qs = opts.mockTop && opts.mockTop > 0 ? `?mockTop=${opts.mockTop}` : 
'';
-    return this.bff.request<DashboardResponse>(
-      'POST',
-      `/api/layer/${encodeURIComponent(layerKey)}/dashboard${qs}`,
-      body,
+    const path = `/api/layer/${encodeURIComponent(layerKey)}/dashboard${qs}`;
+    const widgets = body.widgets ?? [];
+
+    // Fast path: a single request is enough.
+    if (widgets.length <= DASHBOARD_WIDGETS_PER_REQUEST) {
+      return this.bff.request<DashboardResponse>('POST', path, body);
+    }
+
+    // Slow path: oversize widget set. The BFF rejects bodies with more
+    // than `DASHBOARD_WIDGETS_PER_REQUEST` widgets (protects OAP's
+    // storage page-size cliffs); the UI chunks instead of refusing.
+    // We fire chunks in parallel because each chunk hits a different
+    // subset of OAP metrics — there's no in-OAP locking that benefits
+    // from serial dispatch.
+    const chunks: DashboardWidget[][] = [];
+    for (let i = 0; i < widgets.length; i += DASHBOARD_WIDGETS_PER_REQUEST) {
+      chunks.push(widgets.slice(i, i + DASHBOARD_WIDGETS_PER_REQUEST));
+    }
+    pushEvent(
+      'api',
+      'info',
+      `${path} · ${widgets.length} widgets → ${chunks.length} chunks of 
≤${DASHBOARD_WIDGETS_PER_REQUEST}`,
+    );
+
+    const responses = await Promise.all(
+      chunks.map((chunk) =>
+        this.bff.request<DashboardResponse>('POST', path, { ...body, widgets: 
chunk }),
+      ),
     );
+
+    // Merge: concatenate `widgets` in original order, AND-fold
+    // `reachable`, surface the first non-empty `error`. All other
+    // top-level fields are deterministic for the same body shape so
+    // we pick the first response's values.
+    const first = responses[0]!;
+    return {
+      ...first,
+      widgets: responses.flatMap((r) => r.widgets),
+      reachable: responses.every((r) => r.reachable),
+      error: responses.find((r) => r.error)?.error,
+    };
   }
 
   endpoints(
diff --git a/apps/ui/src/controls/timeRange.ts 
b/apps/ui/src/controls/timeRange.ts
index 5416e03..e6e5b81 100644
--- a/apps/ui/src/controls/timeRange.ts
+++ b/apps/ui/src/controls/timeRange.ts
@@ -169,6 +169,25 @@ export const useTimeRangeStore = defineStore('time-range', 
() => {
     presetId.value = 'custom';
   }
 
+  /** Pick the preset closest to `minutes`. Used by AppShell when the
+   *  three-tier time-defaults resolution lands a value that differs
+   *  from the static `'1h'` default. Picks the nearest by absolute ms
+   *  delta; ties broken in favor of the shorter window. */
+  function selectByMinutes(minutes: number): void {
+    if (!Number.isFinite(minutes) || minutes <= 0) return;
+    const targetMs = minutes * 60_000;
+    let best = TIME_PRESETS[0]!;
+    let bestDelta = Math.abs(best.durationMs - targetMs);
+    for (const p of TIME_PRESETS) {
+      const d = Math.abs(p.durationMs - targetMs);
+      if (d < bestDelta || (d === bestDelta && p.durationMs < 
best.durationMs)) {
+        best = p;
+        bestDelta = d;
+      }
+    }
+    presetId.value = best.id;
+  }
+
   return {
     presetId,
     preset,
@@ -179,6 +198,7 @@ export const useTimeRangeStore = defineStore('time-range', 
() => {
     label,
     selectPreset,
     selectCustom,
+    selectByMinutes,
     customStartMs,
     customEndMs,
     customStep,
diff --git a/apps/ui/src/features/admin/global-defaults/GlobalDefaultsAdmin.vue 
b/apps/ui/src/features/admin/global-defaults/GlobalDefaultsAdmin.vue
new file mode 100644
index 0000000..39596bc
--- /dev/null
+++ b/apps/ui/src/features/admin/global-defaults/GlobalDefaultsAdmin.vue
@@ -0,0 +1,549 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<!--
+  Admin: global defaults — theme + time-defaults in one place.
+
+  Two singletons on OAP (`horizon.theme.active`, 
`horizon.time-defaults.global`)
+  share this page because both are "set once, leave" preferences. The
+  five themes themselves are code-shipped CSS files — operators pick one;
+  they don't author a theme here. Time-defaults captures the topbar
+  global picker's default window (the only knob — refresh interval and
+  triage-page time stay code defaults).
+
+  Per-user overrides land in localStorage and surface in the topbar
+  (theme chip + time-picker overflow). This page is the org-default
+  setting.
+-->
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import { bff } from '@/api/client';
+import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue';
+import TemplateStatusBadge from 
'@/features/admin/_shared/TemplateStatusBadge.vue';
+import TemplateDiffModal from '@/features/admin/_shared/TemplateDiffModal.vue';
+import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
+import { AVAILABLE_THEMES, type ThemeId } from '@/state/theme';
+
+// Two kinds in scope; we union by reading them both as separate sync
+// calls (`useTemplateSync` is per-kind, so we call it twice).
+const themeSync = useTemplateSync({ kind: 'theme' });
+const timeSync = useTemplateSync({ kind: 'time-defaults' });
+
+// readOnly is shared — both kinds depend on the same OAP admin
+// reachability. The composables return the same value because
+// syncStatus.unreachable is a global flag.
+const readOnly = computed<boolean>(() => themeSync.readOnly.value);
+
+const themeStatus = computed(() => themeSync.badgeFor('horizon.theme.active'));
+const timeStatus = computed(() => 
timeSync.badgeFor('horizon.time-defaults.global'));
+const themeDiverged = computed(() => themeStatus.value === 'diverged');
+const timeDiverged = computed(() => timeStatus.value === 'diverged');
+
+// ── Drafts ─────────────────────────────────────────────────────────
+// We hydrate from the bundle's syncStatus (badge tells us what's there,
+// but not the value); call sync-status once on mount to get the live
+// values. After save, refetch.
+const themeDraft = ref<ThemeId>('horizon');
+const timeDraftMinutes = ref<number>(60);
+const orig = ref<{ themeId: ThemeId; minutes: number } | null>(null);
+const loading = ref(true);
+const loadError = ref<string | null>(null);
+
+async function loadFromOap(): Promise<void> {
+  loading.value = true;
+  loadError.value = null;
+  try {
+    const status = await bff.templateSync.syncStatus();
+    const themeRow = status.rows.find((r) => r.name === 
'horizon.theme.active');
+    const timeRow = status.rows.find((r) => r.name === 
'horizon.time-defaults.global');
+    const themeContent = pickContent<{ themeId?: string }>(themeRow);
+    const timeContent = pickContent<{ defaultWindowMinutes?: number 
}>(timeRow);
+    const tId =
+      themeContent?.themeId &&
+      AVAILABLE_THEMES.some((t) => t.id === themeContent.themeId)
+        ? (themeContent.themeId as ThemeId)
+        : 'horizon';
+    const tMin =
+      typeof timeContent?.defaultWindowMinutes === 'number' &&
+      Number.isInteger(timeContent.defaultWindowMinutes) &&
+      timeContent.defaultWindowMinutes > 0
+        ? timeContent.defaultWindowMinutes
+        : 60;
+    themeDraft.value = tId;
+    timeDraftMinutes.value = tMin;
+    orig.value = { themeId: tId, minutes: tMin };
+  } catch (err) {
+    loadError.value = err instanceof Error ? err.message : String(err);
+  } finally {
+    loading.value = false;
+  }
+}
+
+function pickContent<T>(row: { effective: 'remote' | 'bundled' | null; remote: 
{ configuration: string } | null; bundled: { configuration: string } | null } | 
undefined): T | null {
+  if (!row) return null;
+  const source = row.effective === 'remote' && row.remote ? 
row.remote.configuration : row.bundled?.configuration;
+  if (!source) return null;
+  try {
+    const envelope = JSON.parse(source) as { content?: T };
+    return envelope.content ?? null;
+  } catch {
+    return null;
+  }
+}
+
+void loadFromOap();
+
+// Re-fetch whenever the syncStatus generation changes (operator hit a
+// resync elsewhere, etc.).
+watch(
+  () => themeSync.status.value?.generatedAt,
+  () => { void loadFromOap(); },
+);
+
+// ── Save ───────────────────────────────────────────────────────────
+const saving = ref(false);
+const flash = ref<string | null>(null);
+function setFlash(msg: string): void {
+  flash.value = msg;
+  setTimeout(() => { if (flash.value === msg) flash.value = null; }, 4000);
+}
+
+const themeDirty = computed(() => orig.value !== null && themeDraft.value !== 
orig.value.themeId);
+const timeDirty = computed(() => orig.value !== null && timeDraftMinutes.value 
!== orig.value.minutes);
+const dirty = computed(() => themeDirty.value || timeDirty.value);
+
+async function onSave(): Promise<void> {
+  if (!dirty.value || saving.value) return;
+  if (readOnly.value) {
+    setFlash('cannot save — OAP is unreachable, page is read-only');
+    return;
+  }
+  saving.value = true;
+  try {
+    // Batched as two separate /api/admin/templates/save calls (each
+    // template is a distinct OAP row). If the second fails the first
+    // still landed — surface the partial state to the operator.
+    if (themeDirty.value) {
+      await bff.templateSync.save('horizon.theme.active', { themeId: 
themeDraft.value });
+    }
+    if (timeDirty.value) {
+      await bff.templateSync.save('horizon.time-defaults.global', {
+        defaultWindowMinutes: timeDraftMinutes.value,
+      });
+    }
+    await loadFromOap();
+    setFlash('saved to OAP');
+  } catch (err) {
+    setFlash(err instanceof Error ? `save failed: ${err.message}` : 'save 
failed');
+  } finally {
+    saving.value = false;
+  }
+}
+
+function onReset(): void {
+  if (orig.value) {
+    themeDraft.value = orig.value.themeId;
+    timeDraftMinutes.value = orig.value.minutes;
+  }
+}
+
+// ── Time picker presets ───────────────────────────────────────────
+// A small whitelist — operators can pick from these or type a custom
+// number. Step precision (the OAP `Duration.step` value) is derived
+// from the window size automatically: ≤ 4h → MINUTE, 6h…14d → HOUR,
+// ≥ 30d → DAY. The thresholds match the timeRange store's
+// TIME_PRESETS table and OAP's `DurationUtils` mapping (see
+// CLAUDE.md "Time, step, and timezone").
+const PRESETS = [
+  { label: '15 m', value: 15 },
+  { label: '30 m', value: 30 },
+  { label: '1 h', value: 60 },
+  { label: '2 h', value: 120 },
+  { label: '4 h', value: 240 },
+  { label: '12 h', value: 720 },
+  { label: '24 h', value: 1440 },
+  { label: '7 d', value: 10080 },
+];
+
+/** Resolve the OAP `step` precision for a given window in minutes.
+ *  Mirrors the TIME_PRESETS thresholds in `controls/timeRange.ts`. */
+function precisionForMinutes(m: number): 'MINUTE' | 'HOUR' | 'DAY' {
+  if (m <= 240) return 'MINUTE';        // ≤ 4 h
+  if (m < 30 * 24 * 60) return 'HOUR';  // < 30 d
+  return 'DAY';
+}
+const draftPrecision = computed(() => 
precisionForMinutes(timeDraftMinutes.value));
+const draftBucketCount = computed(() => {
+  const m = timeDraftMinutes.value;
+  switch (draftPrecision.value) {
+    case 'MINUTE': return m;            // 60-min window → 60 one-minute 
buckets
+    case 'HOUR':   return Math.round(m / 60);
+    case 'DAY':    return Math.round(m / 60 / 24);
+  }
+});
+
+// ── Diff modal state ──────────────────────────────────────────────
+const themeDiffOpen = ref(false);
+const timeDiffOpen = ref(false);
+async function onDiffReset(): Promise<void> {
+  await loadFromOap();
+  setFlash('OAP reset to bundled — reload to pick up the change');
+}
+</script>
+
+<template>
+  <div class="gd">
+    <header class="gd__head">
+      <div>
+        <div class="gd__kicker">Dashboard setup · Global defaults</div>
+        <h1>Global defaults</h1>
+        <p class="gd__lede">
+          Org-wide defaults for the UI theme and the topbar's default time
+          window. Both can be overridden per-user in the browser (theme chip
+          in the topbar, "Save as my default" in the time picker). Edits here
+          write to OAP; bundled JSON is the seed + read-only fallback.
+        </p>
+      </div>
+    </header>
+
+    <SyncStatusBanner :banner="themeSync.banner.value" />
+
+    <div v-if="loading" class="gd__empty">Loading from OAP…</div>
+    <div v-else-if="loadError" class="gd__err">{{ loadError }}</div>
+
+    <template v-else>
+      <!-- ── Theme ───────────────────────────────────────────────── -->
+      <section class="gd__section">
+        <header class="gd__sec-head">
+          <h2>Theme</h2>
+          <TemplateStatusBadge :status="themeStatus" />
+          <button
+            v-if="themeDiverged && !readOnly"
+            type="button"
+            class="gd__small"
+            @click="themeDiffOpen = true"
+          >show diff &amp; reset</button>
+        </header>
+        <p class="gd__sec-lede">
+          Five bundled themes ship with Horizon. The org default is the
+          starting theme every user sees on first visit. Users keep their
+          own override afterwards.
+        </p>
+        <div class="gd__themes">
+          <label
+            v-for="t in AVAILABLE_THEMES"
+            :key="t.id"
+            class="gd__theme"
+            :class="{ active: themeDraft === t.id }"
+          >
+            <input
+              v-model="themeDraft"
+              type="radio"
+              name="themeDraft"
+              :value="t.id"
+              :disabled="readOnly"
+            />
+            <span class="gd__theme-id">{{ t.label }}</span>
+            <span class="gd__theme-desc">{{ t.description }}</span>
+          </label>
+        </div>
+      </section>
+
+      <!-- ── Time defaults ──────────────────────────────────────── -->
+      <section class="gd__section">
+        <header class="gd__sec-head">
+          <h2>Default time window</h2>
+          <TemplateStatusBadge :status="timeStatus" />
+          <button
+            v-if="timeDiverged && !readOnly"
+            type="button"
+            class="gd__small"
+            @click="timeDiffOpen = true"
+          >show diff &amp; reset</button>
+        </header>
+        <p class="gd__sec-lede">
+          Default window for the topbar time picker, which feeds
+          <strong>dashboards and overviews only</strong>. The OAP
+          <code>step</code> precision is derived from the window — you
+          don't pick it separately:
+        </p>
+        <ul class="gd__sec-lede gd__sec-lede--list">
+          <li><strong>≤ 4 hours</strong> → <code>MINUTE</code> step (one 
bucket per minute)</li>
+          <li><strong>6 hours … 14 days</strong> → <code>HOUR</code> step (one 
bucket per hour)</li>
+          <li><strong>≥ 30 days</strong> → <code>DAY</code> step (one bucket 
per day)</li>
+        </ul>
+        <p class="gd__sec-lede">
+          <strong>Triage pages</strong> (alarms / traces / logs /
+          live-debugger) own their own time range and always query at
+          <code>SECOND</code> precision — they are <em>not</em>
+          affected by this setting.
+        </p>
+        <div class="gd__presets">
+          <button
+            v-for="p in PRESETS"
+            :key="p.value"
+            type="button"
+            class="gd__preset"
+            :class="{ active: timeDraftMinutes === p.value }"
+            :disabled="readOnly"
+            @click="timeDraftMinutes = p.value"
+          >{{ p.label }}</button>
+          <label class="gd__custom">
+            custom (min)
+            <input
+              v-model.number="timeDraftMinutes"
+              type="number"
+              min="1"
+              max="44640"
+              :disabled="readOnly"
+            />
+          </label>
+        </div>
+        <p class="gd__resolved">
+          <strong>{{ timeDraftMinutes }} min</strong> →
+          <code>{{ draftPrecision }}</code> step,
+          {{ draftBucketCount }} bucket{{ draftBucketCount === 1 ? '' : 's' }} 
per query.
+        </p>
+      </section>
+
+      <!-- ── Actions ────────────────────────────────────────────── -->
+      <footer class="gd__actions">
+        <span v-if="flash" class="gd__flash">{{ flash }}</span>
+        <span v-else-if="dirty" class="gd__dirty">unsaved changes</span>
+        <span v-else class="gd__clean">saved</span>
+        <button
+          type="button"
+          class="gd__btn"
+          :disabled="!dirty || saving"
+          @click="onReset"
+        >reset</button>
+        <button
+          type="button"
+          class="gd__btn gd__btn--primary"
+          :disabled="!dirty || saving || readOnly"
+          :title="readOnly ? 'OAP unreachable — page is read-only' : ''"
+          @click="onSave"
+        >{{ saving ? 'saving…' : readOnly ? 'read-only' : 'save to OAP' 
}}</button>
+      </footer>
+    </template>
+
+    <TemplateDiffModal
+      :open="themeDiffOpen"
+      name="horizon.theme.active"
+      confirm-key="active"
+      @close="themeDiffOpen = false"
+      @reset="onDiffReset"
+    />
+    <TemplateDiffModal
+      :open="timeDiffOpen"
+      name="horizon.time-defaults.global"
+      confirm-key="global"
+      @close="timeDiffOpen = false"
+      @reset="onDiffReset"
+    />
+  </div>
+</template>
+
+<style scoped>
+.gd {
+  padding: 20px 20px 60px;
+  max-width: 1100px;
+  margin: 0 auto;
+  color: var(--sw-fg-1);
+}
+.gd__head { margin-bottom: 14px; }
+.gd__kicker {
+  font-size: 10px;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--sw-fg-3);
+  margin-bottom: 4px;
+}
+.gd h1 {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+}
+.gd__lede {
+  margin: 6px 0 0;
+  font-size: 12px;
+  line-height: 1.55;
+  color: var(--sw-fg-2);
+  max-width: 880px;
+}
+.gd__empty, .gd__err {
+  padding: 20px;
+  font-size: 12px;
+  color: var(--sw-fg-2);
+}
+.gd__err { color: var(--sw-err); }
+
+.gd__section {
+  margin-top: 18px;
+  padding: 16px;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 4px;
+}
+.gd__sec-head {
+  display: flex; align-items: center; gap: 10px;
+  margin-bottom: 4px;
+}
+.gd__sec-head h2 {
+  margin: 0;
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+}
+.gd__sec-lede {
+  margin: 0 0 12px;
+  font-size: 11.5px;
+  line-height: 1.55;
+  color: var(--sw-fg-2);
+}
+.gd__sec-lede--list {
+  list-style: disc;
+  padding-left: 22px;
+}
+.gd__sec-lede--list li {
+  margin: 2px 0;
+}
+.gd__sec-lede code,
+.gd__resolved code {
+  font-family: var(--sw-mono);
+  font-size: 11px;
+  padding: 1px 4px;
+  background: var(--sw-bg-2);
+  border-radius: 3px;
+  color: var(--sw-fg-0);
+}
+.gd__resolved {
+  margin: 10px 0 0;
+  font-size: 11.5px;
+  color: var(--sw-fg-2);
+  padding: 6px 10px;
+  border-left: 2px solid var(--sw-accent-line);
+  background: var(--sw-accent-soft);
+  border-radius: 0 4px 4px 0;
+}
+.gd__resolved strong {
+  color: var(--sw-fg-0);
+}
+
+.gd__themes {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+  gap: 8px;
+}
+.gd__theme {
+  display: grid;
+  grid-template-columns: auto 1fr;
+  column-gap: 8px;
+  align-items: start;
+  padding: 10px 12px;
+  border: 1px solid var(--sw-line);
+  border-radius: 4px;
+  background: var(--sw-bg-2);
+  cursor: pointer;
+}
+.gd__theme.active {
+  border-color: var(--sw-accent-line);
+  background: var(--sw-accent-soft);
+}
+.gd__theme input { grid-row: span 2; margin-top: 2px; }
+.gd__theme-id {
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+}
+.gd__theme-desc {
+  grid-column: 2;
+  font-size: 11px;
+  color: var(--sw-fg-2);
+  line-height: 1.45;
+}
+
+.gd__presets {
+  display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
+}
+.gd__preset {
+  padding: 4px 12px;
+  border: 1px solid var(--sw-line);
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-1);
+  border-radius: 3px;
+  font-size: 11px;
+  cursor: pointer;
+}
+.gd__preset.active {
+  border-color: var(--sw-accent-line);
+  background: var(--sw-accent-soft);
+  color: var(--sw-fg-0);
+}
+.gd__preset[disabled] { opacity: 0.5; cursor: not-allowed; }
+
+.gd__custom {
+  display: inline-flex; align-items: center; gap: 6px;
+  font-size: 11px;
+  color: var(--sw-fg-2);
+  margin-left: 6px;
+}
+.gd__custom input {
+  width: 80px;
+  padding: 3px 6px;
+  border: 1px solid var(--sw-line);
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-0);
+  border-radius: 3px;
+  font-size: 11px;
+}
+
+.gd__actions {
+  margin-top: 18px;
+  display: flex; align-items: center; gap: 10px;
+  justify-content: flex-end;
+}
+.gd__flash { font-size: 11px; color: var(--sw-ok); }
+.gd__dirty { font-size: 11px; color: var(--sw-warn); }
+.gd__clean { font-size: 11px; color: var(--sw-fg-3); }
+.gd__btn {
+  padding: 5px 14px;
+  border: 1px solid var(--sw-line);
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-1);
+  border-radius: 3px;
+  font-size: 11.5px;
+  cursor: pointer;
+}
+.gd__btn--primary {
+  background: var(--sw-accent);
+  border-color: var(--sw-accent);
+  color: #fff;
+  font-weight: 600;
+}
+.gd__btn[disabled] { opacity: 0.5; cursor: not-allowed; }
+
+.gd__small {
+  padding: 2px 8px;
+  font-size: 10.5px;
+  border: 1px solid var(--sw-line);
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-2);
+  border-radius: 3px;
+  cursor: pointer;
+}
+</style>
diff --git a/apps/ui/src/features/alarms/AlarmsView.vue 
b/apps/ui/src/features/alarms/AlarmsView.vue
index 15eb707..8982418 100644
--- a/apps/ui/src/features/alarms/AlarmsView.vue
+++ b/apps/ui/src/features/alarms/AlarmsView.vue
@@ -374,8 +374,26 @@ const pinnedKpis = computed<Array<{ key: string; label: 
string; count: number }>
     count: countsByLayer.value.get(k) ?? 0,
   }));
 });
+/** "Other" KPI tile — the residual between `totalCount` and the
+ *  pinned-layer counts. Surfaces alarms in layers the operator didn't
+ *  pin AND alarms the BFF couldn't attribute to a known layer (bucket
+ *  key === 'OTHER'). Without this tile the math `totalCount === sum
+ *  of visible KPI tiles` reads as broken because the overflow pills
+ *  below are smaller and easy to miss. The tile becomes a passthrough
+ *  filter when clicked — same selectChip dispatch as a pinned layer. */
+const otherKpiCount = computed<number>(() => {
+  const pinned = new Set(pinnedLayers.value);
+  let n = 0;
+  for (const [k, v] of countsByLayer.value.entries()) {
+    if (!pinned.has(k)) n += v;
+  }
+  return n;
+});
 /** Non-pinned layers with at least one active incident, descending
- *  by count. Recovered-only layers drop out — "no alarm as number". */
+ *  by count. Recovered-only layers drop out — "no alarm as number".
+ *  These render as small pills BELOW the KPI tiles; the "Other" KPI
+ *  tile above already surfaces the aggregate count, so these are the
+ *  detailed breakdown for triage filtering. */
 const overflowChips = computed<Array<{ key: string; label: string; count: 
number }>>(() => {
   const pinned = new Set(pinnedLayers.value);
   const out: Array<{ key: string; label: string; count: number }> = [];
@@ -624,6 +642,24 @@ onMounted(() => {
         <div class="ax__kpi-label">{{ k.label }}</div>
         <div class="ax__kpi-val" :class="{ 'ax__kpi-val--err': k.count > 0 
}">{{ k.count }}</div>
       </button>
+      <!-- "Other" KPI tile — sum of all non-pinned-layer alarms (and
+           unmapped / 'OTHER' bucket). Always rendered (even at zero)
+           so operators can mentally verify
+           `Active = General + Mesh + Other` at a glance. Read-only
+           aggregate; clicking it doesn't filter. The smaller pills
+           below remain the per-layer filters. Rendered as a
+           `<button disabled>` (not a `<div>`) so the flex row's
+           box-metrics match the neighbour KPI buttons exactly —
+           same padding / border / line-height resolution. -->
+      <button
+        type="button"
+        disabled
+        class="ax__kpi ax__kpi--passive"
+        title="Sum of alarms in non-pinned layers (and unmapped). Use the 
chips below to filter by a specific other layer."
+      >
+        <div class="ax__kpi-label">Other</div>
+        <div class="ax__kpi-val" :class="{ 'ax__kpi-val--err': otherKpiCount > 
0 }">{{ otherKpiCount }}</div>
+      </button>
       <div v-if="overflowChips.length > 0" class="ax__chips">
         <button
           v-for="c in overflowChips"
@@ -1039,6 +1075,23 @@ onMounted(() => {
 .ax__kpi--total {
   border-color: var(--sw-line-2);
 }
+/* "Other" KPI tile — read-only aggregate, rendered as a disabled
+ * button so the flex-row metrics match neighbours exactly. Dashed
+ * border + cursor:default makes the read-only-ness obvious; an
+ * explicit opacity:1 override keeps the value legible (browser-
+ * default disabled-button styling fades to ~0.5). */
+.ax__kpi--passive {
+  cursor: default;
+  border-style: dashed;
+  opacity: 1;
+}
+.ax__kpi--passive:hover {
+  border-color: var(--sw-line);
+}
+.ax__kpi--passive .ax__kpi-label,
+.ax__kpi--passive .ax__kpi-val {
+  color: var(--sw-fg-2);
+}
 .ax__kpi-label {
   font-size: 10px;
   text-transform: uppercase;
diff --git a/apps/ui/src/features/auth/LoginView.vue 
b/apps/ui/src/features/auth/LoginView.vue
index afc018f..cef1023 100644
--- a/apps/ui/src/features/auth/LoginView.vue
+++ b/apps/ui/src/features/auth/LoginView.vue
@@ -17,6 +17,11 @@
 <script setup lang="ts">
 import { computed, onMounted, onUnmounted, ref } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
+// Full "SkyWalking" wordmark + moon, white-fill. The login page's
+// backdrop is always the dark canyon photo, so the logo stays white
+// regardless of which theme the operator has picked for the post-
+// login app. (The sidebar logo DOES swap to blue on light themes —
+// see AppSidebar.vue — because that surface follows the theme.)
 import logoSw from '@/assets/icons/logo-sw.svg?raw';
 import loginBgUrl from '@/assets/login-bg.jpg?url';
 import { useAuthStore } from '@/state/auth';
@@ -487,7 +492,17 @@ async function submit(): Promise<void> {
   width: 100%;
   height: 38px;
   margin-top: 4px;
-  background: linear-gradient(180deg, var(--sw-accent), #ea580c);
+  /* Gradient stops both derived from --sw-accent so the button tracks
+   * the operator's theme — Obsidian gets purple→darker-purple,
+   * Daybreak gets dawn-pink→darker-dawn-pink, etc. The dark stop is
+   * 14% darker than the accent via color-mix; if the runtime is too
+   * old for color-mix the fallback flat accent below still works. */
+  background: var(--sw-accent);
+  background: linear-gradient(
+    180deg,
+    var(--sw-accent),
+    color-mix(in srgb, var(--sw-accent) 86%, black 14%)
+  );
   color: #fff;
   border: 1px solid rgba(255, 255, 255, 0.12);
   border-radius: 8px;
diff --git a/apps/ui/src/layer/service-map/LayerServiceMapView.vue 
b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
index a6123a5..2404099 100644
--- a/apps/ui/src/layer/service-map/LayerServiceMapView.vue
+++ b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
@@ -785,21 +785,20 @@ const clusterRects = computed<ClusterRect[]>(() => {
     if (count === 0) continue;
     // Each "centre" needs room for the node circle (NODE_R) plus the
     // label text rendered beneath the node (~26px). Inflate by both.
+    // `padTop` reserves CLUSTER_HEAD_HEIGHT above the topmost node for
+    // the alias·value chip — which now renders INSIDE the cluster top
+    // (see the chip template below). Drawing the chip inside removes
+    // the prior CHIP_HEADROOM floor: the cluster top can now follow a
+    // dragged node freely (even off the visible canvas top), and the
+    // node stays visually enclosed because `y` is derived purely from
+    // node positions instead of being clamped to a constant.
     const padTop = NODE_R + CLUSTER_HEAD_HEIGHT;
     const padBot = NODE_R + 32; // label + RPM
     const padSide = NODE_R + 18;
     const x = minX - padSide - CLUSTER_MARGIN;
-    // Floor the cluster top so the floating chip (~40px above the top
-    // border) never clips against the SVG's y=0 edge even when the
-    // operator drags a node to the very top of the canvas. When we
-    // clamp the top up, shrink the height by the same amount so the
-    // bottom edge stays anchored to maxY + padding.
-    const CHIP_HEADROOM = 44;
-    const rawY = minY - padTop - CLUSTER_MARGIN;
-    const y = Math.max(CHIP_HEADROOM, rawY);
+    const y = minY - padTop - CLUSTER_MARGIN;
     const w = (maxX - minX) + (padSide + CLUSTER_MARGIN) * 2;
-    const naturalH = (maxY - minY) + padTop + padBot + CLUSTER_MARGIN * 2;
-    const h = naturalH - (y - rawY);
+    const h = (maxY - minY) + padTop + padBot + CLUSTER_MARGIN * 2;
     out.push({ key: b.key, alias: b.alias, rect: { x, y, w, h } });
   }
   return out;
@@ -1487,13 +1486,18 @@ function fmtWithUnit(v: number | null | undefined, 
unit: string | undefined): st
                     stroke-width="1"
                     stroke-dasharray="4 5"
                   />
-                  <!-- Floating chip: sits with its baseline ~18px
-                       above the box's top edge so neither the border
-                       nor the chip overlap the cluster contents. The
-                       cluster *value* is rendered noticeably larger
-                       than the alias label — the name is the signal,
-                       the alias is just a qualifier. -->
-                  <g transform="translate(20, -18)">
+                  <!-- Header chip rendered INSIDE the cluster top
+                       (in the CLUSTER_HEAD_HEIGHT padding above the
+                       topmost node, reserved by the rect math above).
+                       Previously this chip floated above the box top,
+                       which forced a CHIP_HEADROOM floor on the rect
+                       y that broke encompass when a node was dragged
+                       above the floor. Drawing inside the cluster
+                       removes that constraint entirely. The cluster
+                       *value* is rendered noticeably larger than the
+                       alias label — the name is the signal, the
+                       alias is just a qualifier. -->
+                  <g transform="translate(20, 22)">
                     <rect
                       x="0"
                       y="-19"
diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts
index d999315..165191f 100644
--- a/apps/ui/src/main.ts
+++ b/apps/ui/src/main.ts
@@ -22,8 +22,15 @@ import App from './App.vue';
 import router from './shell/router/index';
 import { bffClient } from './api/client';
 import { useAuthStore } from './state/auth';
+import { useThemeStore } from './state/theme';
 
 import '@skywalking-horizon-ui/design-tokens/tokens.css';
+// The theme-variant overrides — `[data-theme="<id>"]` selectors that swap
+// `--sw-*` palette tokens. The Pinia themeStore writes the active id to
+// `<html data-theme>` on mount and on every user / org change. Loading
+// this AFTER tokens.css preserves the cascade order (variant overrides
+// the default).
+import '@skywalking-horizon-ui/design-tokens/themes.css';
 import './assets/styles/global.css';
 
 const queryClient = new QueryClient({
@@ -42,6 +49,19 @@ app.use(pinia);
 app.use(router);
 app.use(VueQueryPlugin, { queryClient });
 
+// Instantiate the theme store eagerly so its `watch(immediate:true)`
+// fires once at bootstrap. That writes `<html data-theme="…">` and
+// `data-appearance="…"` BEFORE any view (including the pre-auth
+// LoginView) renders, so the operator's localStorage theme override
+// applies to every page — not just the post-login surfaces inside
+// AppShell. Pinia stores are lazy by default; without this line the
+// login page falls through to the `:root` Horizon palette regardless
+// of what the operator picked. The org-default tier (OAP-stored
+// `horizon.theme.active`) is still loaded later from AppShell once
+// auth is through; pre-auth, only the user-override + bundled tiers
+// resolve.
+useThemeStore();
+
 // Mid-session 401 → clear auth state and bounce to login while preserving the
 // current path so the user can be returned after re-auth.
 bffClient.setOn401(() => {
diff --git a/apps/ui/src/shell/AppShell.vue b/apps/ui/src/shell/AppShell.vue
index bea678f..d254b69 100644
--- a/apps/ui/src/shell/AppShell.vue
+++ b/apps/ui/src/shell/AppShell.vue
@@ -27,6 +27,36 @@ import { ensureConfigBundle, useConfigBundle } from 
'@/controls/configBundle';
 import { useClickTracking } from '@/controls/useClickTracking';
 import { useLayers } from '@/shell/useLayers';
 import { useDebugPanel } from '@/controls/debugPanel';
+import { useThemeStore } from '@/state/theme';
+import { useTimeDefaultsStore } from '@/state/timeDefaults';
+import { useTimeRangeStore } from '@/controls/timeRange';
+import { watch } from 'vue';
+
+// Eager-load the theme + time-defaults org defaults so the renderer
+// has the right `<html data-theme>` and the right default window for
+// the first paint. Both stores expose 3-tier resolution (user pref
+// in localStorage → OAP → bundled) — at construction they already
+// reflect the user pref + bundled fallback; this call fills the OAP
+// tier as soon as auth is through.
+const themeStore = useThemeStore();
+const timeDefaultsStore = useTimeDefaultsStore();
+const timeRangeStore = useTimeRangeStore();
+
+// Apply the resolved time-defaults to the live time-range store as
+// soon as it lands. The time-range store is constructed with a static
+// `'1h'` default; this watch promotes the resolved value (user pref →
+// OAP → bundled) over that. Subsequent operator picks on the time
+// picker stay sticky — we don't override an explicit selection.
+let timeDefaultsApplied = false;
+watch(
+  () => timeDefaultsStore.defaultWindowMinutes,
+  (m) => {
+    if (timeDefaultsApplied) return;
+    timeRangeStore.selectByMinutes(m);
+    timeDefaultsApplied = true;
+  },
+  { immediate: true },
+);
 
 // Kick the config preload once the shell mounts (i.e. after the auth
 // guard has let the user through). All layer dashboard configs +
@@ -35,6 +65,8 @@ import { useDebugPanel } from '@/controls/debugPanel';
 // spinner for what's effectively static template content.
 onMounted(() => {
   void ensureConfigBundle();
+  void themeStore.loadOrgDefault();
+  void timeDefaultsStore.loadOrgDefault();
 });
 
 // Global delegated click tracker — emits `click` events into the
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index 78464db..c0a1598 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -18,7 +18,16 @@
 import { computed, ref, watch } from 'vue';
 import { RouterLink, useRoute, useRouter } from 'vue-router';
 import Icon, { type IconName } from '@/components/icons/Icon.vue';
+// Full "SkyWalking" wordmark + moon. The shipped file is white-fill
+// (designed for dark backgrounds). For light-appearance themes we
+// derive a blue (`#1368B3` — the official SkyWalking brand blue)
+// variant by replacing the fill color in the raw SVG string. Keeps
+// the SAME wordmark shape; just recolors. Avoids shipping a separate
+// blue asset that could drift from the white one.
 import logoSw from '@/assets/icons/logo-sw.svg?raw';
+import { useThemeStore, AVAILABLE_THEMES } from '@/state/theme';
+
+const logoSwBlue = logoSw.replace(/fill="#fff"/g, 'fill="#1368B3"');
 import { useAuthStore } from '@/state/auth';
 import { useLayers, firstLayerTab } from '@/shell/useLayers';
 import { useLandingOrder } from '@/shell/useLandingOrder';
@@ -34,6 +43,10 @@ const alarmCount = useAlarmCount();
 
 const auth = useAuthStore();
 const router = useRouter();
+const themeStore = useThemeStore();
+const isLightAppearance = computed<boolean>(
+  () => AVAILABLE_THEMES.find((t) => t.id === themeStore.active)?.appearance 
=== 'light',
+);
 async function signOut(): Promise<void> {
   await auth.logout();
   await router.push({ name: 'login' });
@@ -198,6 +211,7 @@ const sections: NavSection[] = [
       { icon: 'set', label: 'Overview templates', to: 
'/admin/overview-templates' },
       { icon: 'metric', label: 'Layer dashboards', to: 
'/admin/layer-dashboards' },
       { icon: 'alert', label: 'Alert page', to: '/admin/alert-page-setup' },
+      { icon: 'set', label: 'Global defaults', to: '/admin/global-defaults' },
     ],
   },
   {
@@ -261,7 +275,7 @@ watch(
 <template>
   <aside class="sw-side">
     <RouterLink to="/" class="sw-brand" aria-label="SkyWalking Horizon">
-      <span class="brand-logo" v-html="logoSw" />
+      <span class="brand-logo" v-html="isLightAppearance ? logoSwBlue : 
logoSw" />
       <small>Horizon</small>
     </RouterLink>
 
@@ -741,6 +755,8 @@ watch(
   align-items: center;
   color: var(--sw-fg-0);
 }
+/* Logo SVG variant is chosen in <script setup> via v-html, not via
+ * CSS scoping. See `isLightAppearance` above. */
 .brand-logo :deep(svg) {
   height: 16px;
   width: auto;
diff --git a/apps/ui/src/shell/AppTopbar.vue b/apps/ui/src/shell/AppTopbar.vue
index 901ab0b..e3938a4 100644
--- a/apps/ui/src/shell/AppTopbar.vue
+++ b/apps/ui/src/shell/AppTopbar.vue
@@ -22,6 +22,49 @@ import { useOapInfo } from '@/shell/useOapInfo';
 import { useAlarmCount } from '@/shell/useAlarmCount';
 import { useAutoRefreshStore } from '@/controls/autoRefresh';
 import { useTimeRangeStore, TIME_PRESETS, STEP_LIMITS, isValidRange, type 
TimeStep } from '@/controls/timeRange';
+import { useThemeStore, AVAILABLE_THEMES, type ThemeId } from '@/state/theme';
+import { useTimeDefaultsStore } from '@/state/timeDefaults';
+
+// Per-user "Save as my default" / "Reset to org default" on the time
+// picker. The org default is what the admin set on
+// /admin/global-defaults (3-tier: localStorage → OAP → bundled 60m).
+const timeDefaultsStore = useTimeDefaultsStore();
+function saveCurrentAsMyTimeDefault(): void {
+  const dur = timeRange.preset?.durationMs;
+  if (!dur || !Number.isFinite(dur)) return;
+  const minutes = Math.max(1, Math.round(dur / 60_000));
+  timeDefaultsStore.setUserOverride(minutes);
+}
+function resetTimeDefaultToOrg(): void {
+  timeDefaultsStore.clearUserOverride();
+  // After clearing the local pref the resolved minutes flip back to
+  // org-default-or-bundled — apply that to the visible picker.
+  timeRange.selectByMinutes(timeDefaultsStore.defaultWindowMinutes);
+}
+
+// Per-user theme chip — opens a popover with the 5 bundled themes +
+// an option to clear the local override and fall back to the org
+// default. Lives in the topbar's right cluster, next to alarm badge.
+// Per CLAUDE.md the theme is a runtime concern, not a feature flag;
+// this is the only surface where end users touch it.
+const themeStore = useThemeStore();
+const themeMenuOpen = ref(false);
+const themeChipEl = ref<HTMLElement | null>(null);
+function toggleThemeMenu(): void { themeMenuOpen.value = !themeMenuOpen.value; 
}
+function pickTheme(id: ThemeId): void {
+  themeStore.setUserOverride(id);
+  themeMenuOpen.value = false;
+}
+function resetThemeOverride(): void {
+  themeStore.clearUserOverride();
+  themeMenuOpen.value = false;
+}
+function onThemeChipBlur(e: FocusEvent): void {
+  // Close when focus leaves the cluster — the popover lives outside
+  // the chip, so we check `relatedTarget` for cluster containment.
+  const next = e.relatedTarget as HTMLElement | null;
+  if (!themeChipEl.value?.contains(next)) themeMenuOpen.value = false;
+}
 
 const route = useRoute();
 
@@ -465,6 +508,31 @@ function formatRangeStamp(ms: number, step: TimeStep): 
string {
                 </div>
               </div>
             </div>
+            <!-- Per-user "Save as my default" / "Reset to org default".
+                 Persists the current rolling window's minute count into
+                 localStorage, or clears it so the org default wins. Hidden
+                 when the current selection is a custom range (we can't
+                 represent that as a single minute count). -->
+            <div class="tr-defaults">
+              <div class="tr-defaults-line">
+                <span>My default: <strong>{{ 
timeDefaultsStore.defaultWindowMinutes }}m</strong>{{ 
timeDefaultsStore.hasUserOverride ? ' (your override)' : ' (org default)' 
}}</span>
+              </div>
+              <div class="tr-defaults-foot">
+                <button
+                  type="button"
+                  class="tr-cust-btn ghost"
+                  :disabled="timeRange.presetId === 'custom'"
+                  :title="timeRange.presetId === 'custom' ? 'Pick a rolling 
preset first' : ''"
+                  @click="saveCurrentAsMyTimeDefault"
+                >Save as my default</button>
+                <button
+                  type="button"
+                  class="tr-cust-btn ghost"
+                  :disabled="!timeDefaultsStore.hasUserOverride"
+                  @click="resetTimeDefaultToOrg"
+                >Reset to org default</button>
+              </div>
+            </div>
           </div>
         </transition>
       </div>
@@ -508,6 +576,44 @@ function formatRangeStamp(ms: number, step: TimeStep): 
string {
         <Icon name="bell" :size="12" />
         <span class="alarm-count mono">{{ alarmCount.displayCount.value 
}}</span>
       </RouterLink>
+
+      <!-- ── Theme chip ────────────────────────────────────────────
+        Labeled with the current theme name so operators can find it
+        without guessing the icon. The small dot indicates an active
+        user override (theme differs from org default). Click opens a
+        popover with the 5 themes + Reset. -->
+      <div ref="themeChipEl" class="theme-chip-cluster" tabindex="-1" 
@focusout="onThemeChipBlur">
+        <button
+          type="button"
+          class="sw-btn theme-chip"
+          :title="`Theme: ${themeStore.active}${themeStore.hasUserOverride ? ' 
(your override)' : ''} — click to change`"
+          @click="toggleThemeMenu"
+        >
+          <span class="theme-chip-swatch" />
+          <span class="theme-chip-label">{{ themeStore.active }}</span>
+          <span v-if="themeStore.hasUserOverride" class="theme-chip-dot" />
+          <Icon name="caret" :size="10" />
+        </button>
+        <transition name="rf-menu">
+          <ul v-if="themeMenuOpen" class="theme-menu">
+            <li class="theme-menu-head">Theme</li>
+            <li
+              v-for="t in AVAILABLE_THEMES"
+              :key="t.id"
+              :class="{ on: themeStore.active === t.id }"
+              @click="pickTheme(t.id)"
+            >
+              {{ t.label }}
+              <span v-if="!themeStore.hasUserOverride && t.id === 
themeStore.active" class="theme-menu-org">(org default)</span>
+            </li>
+            <li
+              v-if="themeStore.hasUserOverride"
+              class="theme-menu-reset"
+              @click="resetThemeOverride"
+            >Reset to org default</li>
+          </ul>
+        </transition>
+      </div>
     </div>
   </header>
 </template>
@@ -848,4 +954,101 @@ function formatRangeStamp(ms: number, step: TimeStep): 
string {
 .alarm-badge.is-unknown .alarm-count {
   color: var(--sw-fg-3);
 }
+
+/* ── Theme chip ────────────────────────────────────────────────────
+   Mirrors `refresh-cluster` — a small icon button + popover. The dot
+   surfaces when the user has a local theme override active. */
+.theme-chip-cluster {
+  position: relative;
+  display: inline-flex;
+  outline: none;
+}
+.theme-chip {
+  position: relative;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 0 8px 0 8px;
+  height: 26px;
+}
+.theme-chip-swatch {
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  background: var(--sw-accent);
+  border: 1px solid var(--sw-line-2);
+  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
+}
+.theme-chip-label {
+  font-size: 11px;
+  color: var(--sw-fg-1);
+  text-transform: capitalize;
+  letter-spacing: 0.02em;
+}
+.theme-chip-dot {
+  width: 5px;
+  height: 5px;
+  border-radius: 50%;
+  background: var(--sw-accent);
+  border: 1px solid var(--sw-bg-0);
+}
+.theme-menu {
+  position: absolute;
+  right: 0;
+  top: calc(100% + 4px);
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  padding: 4px 0;
+  margin: 0;
+  list-style: none;
+  min-width: 200px;
+  z-index: 60;
+  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
+}
+.theme-menu-head {
+  font-size: 10px;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  color: var(--sw-fg-3);
+  padding: 4px 10px 6px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.theme-menu li:not(.theme-menu-head) {
+  padding: 6px 10px;
+  font-size: 11.5px;
+  color: var(--sw-fg-1);
+  cursor: pointer;
+  display: flex;
+  justify-content: space-between;
+  gap: 8px;
+}
+.theme-menu li:not(.theme-menu-head):hover {
+  background: var(--sw-bg-3);
+}
+.theme-menu li.on { color: var(--sw-accent); font-weight: 500; }
+.theme-menu-org { color: var(--sw-fg-3); font-size: 10.5px; }
+.theme-menu-reset {
+  border-top: 1px solid var(--sw-line);
+  color: var(--sw-fg-2);
+  font-size: 10.5px !important;
+}
+
+/* ── Per-user time-defaults footer (in the time-picker dropdown) ──── */
+.tr-defaults {
+  border-top: 1px solid var(--sw-line);
+  padding: 8px 10px;
+}
+.tr-defaults-line {
+  font-size: 10.5px;
+  color: var(--sw-fg-2);
+  margin-bottom: 6px;
+}
+.tr-defaults-line strong {
+  color: var(--sw-fg-0);
+  font-weight: 600;
+}
+.tr-defaults-foot {
+  display: flex; gap: 6px;
+}
 </style>
diff --git a/apps/ui/src/shell/router/index.ts 
b/apps/ui/src/shell/router/index.ts
index 8f9ab2f..e1947e5 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -206,6 +206,13 @@ const shellRoutes: RouteRecordRaw[] = [
     name: 'alert-page-setup',
     component: () => 
import('@/features/admin/alert-page/AlertPageSetupView.vue'),
   },
+  // Global defaults — theme + time-defaults combined. Two OAP singletons
+  // edited in one place because they share the "set once and leave" cadence.
+  {
+    path: 'admin/global-defaults',
+    name: 'global-defaults',
+    component: () => 
import('@/features/admin/global-defaults/GlobalDefaultsAdmin.vue'),
+  },
   {
     path: 'admin/overview-templates',
     name: 'overview-templates',
diff --git a/apps/ui/src/state/theme.ts b/apps/ui/src/state/theme.ts
new file mode 100644
index 0000000..c36e62d
--- /dev/null
+++ b/apps/ui/src/state/theme.ts
@@ -0,0 +1,198 @@
+/*
+ * 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.
+ */
+
+/**
+ * Theme selection — three-tier resolution.
+ *
+ *   browser (this user's localStorage)
+ *     ↑ overrides
+ *   org default   (admin set on /admin/global-defaults, stored on OAP
+ *                  as `horizon.theme.active`)
+ *     ↑ overrides
+ *   bundled code  (`bundled_templates/theme/active.json`)
+ *
+ * Each tier is observable so the UI can render "your override differs
+ * from the org default" affordances. The store keeps the resolved id
+ * and reflects it via `<html data-theme="<id>">`; CSS in
+ * `themes.css` swaps tokens off that attribute. Adding a theme is
+ * one CSS block + one entry in `AVAILABLE_THEMES`.
+ */
+
+import { defineStore } from 'pinia';
+import { computed, ref, watch } from 'vue';
+import { useConfigBundle } from '@/controls/configBundle';
+import { debug } from '@/utils/debug';
+import type { TemplateBadge } from '@/api/scopes/configs';
+
+export type ThemeId = 'horizon' | 'obsidian' | 'aurora' | 'meridian' | 
'daybreak';
+
+/** The five bundled themes (design-specified names). Matches the
+ *  `[data-theme="..."]` selectors in
+ *  `packages/design-tokens/src/themes.css`. The first three are dark;
+ *  the last two are light. */
+export const AVAILABLE_THEMES: ReadonlyArray<{
+  id: ThemeId;
+  label: string;
+  description: string;
+  /** `dark` themes use the white SkyWalking logo; `light` themes use
+   *  the blue (`#1368B3`) variant. Read by the brand-logo CSS to know
+   *  which inline SVG to show. */
+  appearance: 'dark' | 'light';
+}> = [
+  { id: 'horizon',  label: 'Horizon',  description: 'Flagship dark — canyon 
orange accent on deep blue-grey. Default.', appearance: 'dark' },
+  { id: 'obsidian', label: 'Obsidian', description: 'Dark, blue accent.',  
appearance: 'dark' },
+  { id: 'aurora',   label: 'Aurora',   description: 'Dark, pink accent.',  
appearance: 'dark' },
+  { id: 'meridian', label: 'Meridian', description: 'Dark, purple accent.', 
appearance: 'dark' },
+  { id: 'daybreak', label: 'Daybreak', description: 'White light theme.',   
appearance: 'light' },
+];
+
+const USER_KEY = 'horizon:theme:user';
+const FALLBACK: ThemeId = 'horizon';
+
+function readUserOverride(): ThemeId | null {
+  if (typeof localStorage === 'undefined') return null;
+  try {
+    const raw = localStorage.getItem(USER_KEY);
+    if (!raw) return null;
+    if (AVAILABLE_THEMES.some((t) => t.id === raw)) return raw as ThemeId;
+    return null;
+  } catch {
+    return null;
+  }
+}
+
+function writeUserOverride(id: ThemeId | null): void {
+  if (typeof localStorage === 'undefined') return;
+  try {
+    if (id === null) localStorage.removeItem(USER_KEY);
+    else localStorage.setItem(USER_KEY, id);
+  } catch {
+    /* quota / disabled storage — degrade silently */
+  }
+}
+
+function isThemeBadge(b: TemplateBadge): boolean {
+  return b.name === 'horizon.theme.active';
+}
+
+export const useThemeStore = defineStore('theme', () => {
+  const userOverride = ref<ThemeId | null>(readUserOverride());
+
+  // Org default is read directly from the bundle's syncStatus — the
+  // bundle endpoint already overlays remote-wins on bundled per template.
+  // For the singleton it doesn't carry the full content; we lazy-fetch
+  // it on first need from /api/admin/templates/sync-status.
+  const orgDefault = ref<ThemeId | null>(null);
+
+  const { bundle } = useConfigBundle();
+
+  // Resolved active id — what the renderer should display.
+  const active = computed<ThemeId>(() => userOverride.value ?? 
orgDefault.value ?? FALLBACK);
+
+  // Whether the user has explicitly chosen a theme that differs from the
+  // org default. Drives the topbar chip's "dot" + the reset affordance.
+  const hasUserOverride = computed<boolean>(() => {
+    if (userOverride.value === null) return false;
+    return userOverride.value !== (orgDefault.value ?? FALLBACK);
+  });
+
+  // Apply the active id to <html data-theme> AND <html data-appearance>
+  // ('dark' / 'light') immediately AND on every change. The
+  // `data-appearance` attribute drives appearance-dependent CSS the
+  // theme palette alone can't express — e.g. the SkyWalking logo SVG
+  // swap (white on dark, blue on light). Pinia stores can be created
+  // before the DOM is ready in SSR setups; guard for that here.
+  watch(
+    active,
+    (next) => {
+      if (typeof document === 'undefined') return;
+      document.documentElement.setAttribute('data-theme', next);
+      const appearance =
+        AVAILABLE_THEMES.find((t) => t.id === next)?.appearance ?? 'dark';
+      document.documentElement.setAttribute('data-appearance', appearance);
+      debug('theme', `applied data-theme="${next}" 
data-appearance="${appearance}"`);
+    },
+    { immediate: true },
+  );
+
+  /** Fetch the org default from the syncStatus admin endpoint. The
+   *  badge in `configBundle.syncStatus` only carries status (not the
+   *  themeId), so the store hits sync-status once at boot to read the
+   *  actual value. */
+  async function loadOrgDefault(): Promise<void> {
+    // Lazy import to break a circular dep: api/client imports stores
+    // (auth), stores would otherwise import api/client.
+    const { bff } = await import('@/api/client');
+    try {
+      const status = await bff.templateSync.syncStatus();
+      const row = status.rows.find((r) => r.name === 'horizon.theme.active');
+      if (!row) {
+        orgDefault.value = null;
+        return;
+      }
+      const source = row.effective === 'remote' && row.remote
+        ? row.remote.configuration
+        : row.bundled?.configuration;
+      if (!source) {
+        orgDefault.value = null;
+        return;
+      }
+      const envelope = JSON.parse(source) as { content?: { themeId?: unknown } 
};
+      const id = envelope?.content?.themeId;
+      if (typeof id === 'string' && AVAILABLE_THEMES.some((t) => t.id === id)) 
{
+        orgDefault.value = id as ThemeId;
+        debug('theme', `loaded org default = ${id}`);
+      } else {
+        orgDefault.value = null;
+      }
+    } catch (err) {
+      debug('theme', 'failed to load org default', err);
+      orgDefault.value = null;
+    }
+  }
+
+  // When the bundle's syncStatus changes (refresh, resync, OAP came
+  // back up), re-read the org default so the renderer follows.
+  watch(
+    () => bundle.value?.syncStatus.generatedAt,
+    () => {
+      const badge = bundle.value?.syncStatus.badges.find(isThemeBadge);
+      if (badge) void loadOrgDefault();
+    },
+    { immediate: false },
+  );
+
+  function setUserOverride(id: ThemeId): void {
+    userOverride.value = id;
+    writeUserOverride(id);
+  }
+
+  function clearUserOverride(): void {
+    userOverride.value = null;
+    writeUserOverride(null);
+  }
+
+  return {
+    active,
+    userOverride,
+    orgDefault,
+    hasUserOverride,
+    loadOrgDefault,
+    setUserOverride,
+    clearUserOverride,
+  };
+});
diff --git a/apps/ui/src/state/timeDefaults.ts 
b/apps/ui/src/state/timeDefaults.ts
new file mode 100644
index 0000000..8c9376b
--- /dev/null
+++ b/apps/ui/src/state/timeDefaults.ts
@@ -0,0 +1,139 @@
+/*
+ * 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.
+ */
+
+/**
+ * Global time-defaults — three-tier resolution mirroring the theme
+ * store. Captures one knob: the default window for the topbar global
+ * time picker (which feeds dashboards / overviews per CLAUDE.md;
+ * triage pages keep their own per-page time).
+ *
+ *   browser (user pref in localStorage)
+ *     ↑
+ *   org default (admin set on /admin/global-defaults, on OAP as
+ *                `horizon.time-defaults.global`)
+ *     ↑
+ *   bundled code (60 minutes)
+ */
+
+import { defineStore } from 'pinia';
+import { computed, ref, watch } from 'vue';
+import { useConfigBundle } from '@/controls/configBundle';
+import { debug } from '@/utils/debug';
+
+const USER_KEY = 'horizon:time-defaults:user';
+const FALLBACK_MINUTES = 60;
+
+function readUserOverride(): number | null {
+  if (typeof localStorage === 'undefined') return null;
+  try {
+    const raw = localStorage.getItem(USER_KEY);
+    if (!raw) return null;
+    const n = Number(raw);
+    if (!Number.isInteger(n) || n <= 0 || n > 60 * 24 * 31) return null;
+    return n;
+  } catch {
+    return null;
+  }
+}
+
+function writeUserOverride(minutes: number | null): void {
+  if (typeof localStorage === 'undefined') return;
+  try {
+    if (minutes === null) localStorage.removeItem(USER_KEY);
+    else localStorage.setItem(USER_KEY, String(minutes));
+  } catch {
+    /* quota / disabled storage — degrade silently */
+  }
+}
+
+export const useTimeDefaultsStore = defineStore('time-defaults', () => {
+  const userOverride = ref<number | null>(readUserOverride());
+  const orgDefault = ref<number | null>(null);
+
+  const { bundle } = useConfigBundle();
+
+  const defaultWindowMinutes = computed<number>(
+    () => userOverride.value ?? orgDefault.value ?? FALLBACK_MINUTES,
+  );
+  const hasUserOverride = computed<boolean>(() => {
+    if (userOverride.value === null) return false;
+    return userOverride.value !== (orgDefault.value ?? FALLBACK_MINUTES);
+  });
+
+  async function loadOrgDefault(): Promise<void> {
+    const { bff } = await import('@/api/client');
+    try {
+      const status = await bff.templateSync.syncStatus();
+      const row = status.rows.find((r) => r.name === 
'horizon.time-defaults.global');
+      if (!row) {
+        orgDefault.value = null;
+        return;
+      }
+      const source = row.effective === 'remote' && row.remote
+        ? row.remote.configuration
+        : row.bundled?.configuration;
+      if (!source) {
+        orgDefault.value = null;
+        return;
+      }
+      const envelope = JSON.parse(source) as {
+        content?: { defaultWindowMinutes?: unknown };
+      };
+      const m = envelope?.content?.defaultWindowMinutes;
+      if (typeof m === 'number' && Number.isInteger(m) && m > 0) {
+        orgDefault.value = m;
+        debug('time-defaults', `loaded org default = ${m} min`);
+      } else {
+        orgDefault.value = null;
+      }
+    } catch (err) {
+      debug('time-defaults', 'failed to load org default', err);
+      orgDefault.value = null;
+    }
+  }
+
+  watch(
+    () => bundle.value?.syncStatus.generatedAt,
+    () => {
+      const badge = bundle.value?.syncStatus.badges.find(
+        (b) => b.name === 'horizon.time-defaults.global',
+      );
+      if (badge) void loadOrgDefault();
+    },
+    { immediate: false },
+  );
+
+  function setUserOverride(minutes: number): void {
+    userOverride.value = minutes;
+    writeUserOverride(minutes);
+  }
+
+  function clearUserOverride(): void {
+    userOverride.value = null;
+    writeUserOverride(null);
+  }
+
+  return {
+    defaultWindowMinutes,
+    userOverride,
+    orgDefault,
+    hasUserOverride,
+    loadOrgDefault,
+    setUserOverride,
+    clearUserOverride,
+  };
+});
diff --git a/horizon.example.yaml b/horizon.example.yaml
index a124d1a..10ea283 100644
--- a/horizon.example.yaml
+++ b/horizon.example.yaml
@@ -177,11 +177,14 @@ rbac:
     admin:
       - "*"
   # Where each role lands after login. First role on the user wins.
+  # Cluster status lives under /operate/cluster (it's an operator tool
+  # against OAP, not an admin / RBAC surface); the prior `/admin/cluster`
+  # values 404'd because no route by that name exists.
   landingByRole:
     viewer: /
-    maintainer: /admin/cluster
+    maintainer: /operate/cluster
     operator: /
-    admin: /admin/cluster
+    admin: /operate/cluster
 
 session:
   ttlMinutes: 60
diff --git a/packages/design-tokens/package.json 
b/packages/design-tokens/package.json
index 2efd2ad..7dec6e7 100644
--- a/packages/design-tokens/package.json
+++ b/packages/design-tokens/package.json
@@ -10,11 +10,13 @@
       "types": "./dist/index.d.ts",
       "import": "./dist/index.js"
     },
-    "./tokens.css": "./src/tokens.css"
+    "./tokens.css": "./src/tokens.css",
+    "./themes.css": "./src/themes.css"
   },
   "files": [
     "dist",
-    "src/tokens.css"
+    "src/tokens.css",
+    "src/themes.css"
   ],
   "scripts": {
     "build": "tsc -p tsconfig.build.json",
diff --git a/packages/design-tokens/src/themes.css 
b/packages/design-tokens/src/themes.css
new file mode 100644
index 0000000..3604fab
--- /dev/null
+++ b/packages/design-tokens/src/themes.css
@@ -0,0 +1,138 @@
+/*
+ * 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.
+ */
+
+/* ── Horizon NG theme variants ────────────────────────────────────────
+ *
+ * The `:root` block in `tokens.css` is the **Horizon** flagship dark
+ * palette. Each other theme is a `[data-theme="<id>"]` block that
+ * overrides only the tokens that change; the `--rr-*` aliases pick up
+ * new `--sw-*` values via `var()` automatically.
+ *
+ * Five bundled themes (design-specified names + dominant hue):
+ *   horizon   — flagship dark, canyon orange accent (the :root default)
+ *   obsidian  — dark, BLUE accent
+ *   aurora    — dark, PINK accent
+ *   meridian  — dark, PURPLE accent
+ *   daybreak  — WHITE light theme, muted accent
+ *
+ * IMPORTANT — the specific hex values below are PLACEHOLDERS pulled
+ * from typical hues in each color family. They are NOT the design
+ * spec's exact tokens (which I do not have visibility into). Replace
+ * each `--sw-accent` / `--sw-accent-*` value with the design's
+ * canonical hex once provided.
+ *
+ * Switched at runtime by setting `data-theme="<id>"` on `<html>`.
+ * Adding a theme means appending another block here + listing the id
+ * in `themeStore.AVAILABLE_THEMES`.
+ */
+
+/* ── horizon (default, no-op override; declared for completeness) ── */
+[data-theme="horizon"] {
+  /* Mirrors :root in tokens.css. */
+}
+
+/* ── obsidian — dark, BLUE accent ─────────────────────────────────── */
+[data-theme="obsidian"] {
+  --sw-bg-0: #000000;
+  --sw-bg-1: #0a0a0a;
+  --sw-bg-2: #141414;
+  --sw-bg-3: #1c1c1c;
+  --sw-bg-4: #262626;
+  --sw-line: #2a2f38;
+  --sw-line-2: #3a4252;
+  --sw-line-3: #4a5468;
+
+  --sw-fg-0: #ffffff;
+  --sw-fg-1: #ededed;
+  --sw-fg-2: #c8c8c8;
+  --sw-fg-3: #989898;
+
+  /* TODO: replace with design-spec blue */
+  --sw-accent: #3a8ed0;
+  --sw-accent-2: #5aa3e0;
+  --sw-accent-soft: rgba(58, 142, 208, 0.16);
+  --sw-accent-line: rgba(58, 142, 208, 0.5);
+}
+
+/* ── aurora — dark, PINK accent ───────────────────────────────────── */
+[data-theme="aurora"] {
+  --sw-bg-0: #0e0a14;
+  --sw-bg-1: #181020;
+  --sw-bg-2: #221830;
+  --sw-bg-3: #2a1f3c;
+  --sw-bg-4: #34294a;
+  --sw-line: #2e2240;
+  --sw-line-2: #3e2f56;
+  --sw-line-3: #4e3f6c;
+
+  --sw-fg-0: #f5edf5;
+  --sw-fg-1: #d8cce0;
+  --sw-fg-2: #a89cb8;
+  --sw-fg-3: #7e7090;
+
+  /* TODO: replace with design-spec pink */
+  --sw-accent: #ec4899;
+  --sw-accent-2: #f472b6;
+  --sw-accent-soft: rgba(236, 72, 153, 0.16);
+  --sw-accent-line: rgba(236, 72, 153, 0.45);
+}
+
+/* ── meridian — dark, PURPLE accent ───────────────────────────────── */
+[data-theme="meridian"] {
+  --sw-bg-0: #0d0a1a;
+  --sw-bg-1: #15102a;
+  --sw-bg-2: #1d1838;
+  --sw-bg-3: #261f48;
+  --sw-bg-4: #2f285a;
+  --sw-line: #2a2548;
+  --sw-line-2: #3a345e;
+  --sw-line-3: #4c4476;
+
+  --sw-fg-0: #efedf7;
+  --sw-fg-1: #d4cfe6;
+  --sw-fg-2: #a59fc0;
+  --sw-fg-3: #7b7398;
+
+  /* TODO: replace with design-spec purple */
+  --sw-accent: #a855f7;
+  --sw-accent-2: #c084fc;
+  --sw-accent-soft: rgba(168, 85, 247, 0.16);
+  --sw-accent-line: rgba(168, 85, 247, 0.5);
+}
+
+/* ── daybreak — WHITE light theme ─────────────────────────────────── */
+[data-theme="daybreak"] {
+  --sw-bg-0: #ffffff;
+  --sw-bg-1: #fbfbfd;
+  --sw-bg-2: #f4f5f8;
+  --sw-bg-3: #ecedf2;
+  --sw-bg-4: #e2e4eb;
+  --sw-line: #e3e5ec;
+  --sw-line-2: #d3d6e0;
+  --sw-line-3: #b8bcc8;
+
+  --sw-fg-0: #14181f;
+  --sw-fg-1: #2e3640;
+  --sw-fg-2: #5b6373;
+  --sw-fg-3: #8a93a0;
+
+  /* TODO: replace with design-spec daybreak accent (muted is a guess) */
+  --sw-accent: #4b5563;
+  --sw-accent-2: #6b7280;
+  --sw-accent-soft: rgba(75, 85, 99, 0.10);
+  --sw-accent-line: rgba(75, 85, 99, 0.35);
+}


Reply via email to