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 & 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 & 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);
+}