You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/05/22 23:47:42 UTC
(skywalking-horizon-ui) branch main updated: feat(admin): browser-local template authoring — create, preview, delete/disable (#9)
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 4f5c164 feat(admin): browser-local template authoring — create, preview, delete/disable (#9)
4f5c164 is described below
commit 4f5c1648d41ca49cebd454676820c95bc20a61a3
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Sat May 23 07:46:24 2026 +0800
feat(admin): browser-local template authoring — create, preview, delete/disable (#9)
Dashboard authoring (layer + overview admin) now shares one model: edits
live as a browser-local draft, the live page stays on the published OAP
version, and "Check diff & push" publishes (create-or-update on OAP).
- Create mirrors edit: "+ New dashboard" writes a local draft (id = name,
checked unique), preview, then push. Remote-only dashboards (pushed, no
bundled default) now render on the live page + sidebar, not just the
editor.
- Delete = soft-disable (OAP has no hard DELETE): local-only drafts are
removed from the browser; on-OAP templates are disabled and drop from
the picker live-state, sidebar, and live page. Layer disable hides the
layer (menu route honors disabled templates); Reactivate re-enables from
the bundled default via the OAP update path. Styled in-app confirm
dialogs replace the native confirm box.
- Overview editor rebuilt as a layer-style 12-col canvas (drag-reorder,
corner-resize, click-to-edit drawer, selectable section-breaks/title,
dashed selectable hints) mirroring the live grid; composite-metrics KPI
rows are stacked cards so they read in the narrow drawer.
- Layer editor: Diverged/Local filters moved inside the picker dropdown,
one-line "Layer: <name> <key> <status>" header showing the same sync
status the picker does.
- Reliability: clear "Cannot reach the server" message when the BFF is
down (API client no longer double-reads the response body); fixed the
sidebar scrolling to the bottom on every navigation (the Debug-events
toggle's active state was the scroll target).
- Docs: rewrote the layer + overview template admin sections for the
draft -> preview -> push model, create, and delete/disable.
---
.claude/skills/local-boot/SKILL.md | 22 +-
.gitignore | 3 +
CHANGELOG.md | 93 +-
.../bff/src/bundled_templates/layers/activemq.json | 2 +-
apps/bff/src/http/admin/template-sync.ts | 63 +
apps/bff/src/http/config/bundle.ts | 14 +
apps/bff/src/http/query/menu.ts | 37 +-
apps/bff/src/rbac/route-policy.ts | 1 +
apps/bff/src/server.ts | 6 +-
apps/ui/src/api/client.ts | 32 +-
apps/ui/src/api/scopes/template-sync.ts | 10 +
apps/ui/src/controls/configBundle.ts | 55 +-
apps/ui/src/controls/localTemplateEdits.ts | 100 ++
apps/ui/src/controls/previewMode.ts | 51 +
apps/ui/src/controls/previewOverride.ts | 76 +
apps/ui/src/controls/sidebar.ts | 56 +
.../src/features/admin/_shared/SyncAllButton.vue | 191 ---
.../features/admin/_shared/useTemplateSources.ts | 94 ++
.../src/features/admin/_shared/useTemplateSync.ts | 42 +-
.../admin/layer-templates/LayerDashboardsAdmin.vue | 1489 +++++++++++++++++---
.../overview-templates/OverviewTemplatesAdmin.vue | 1457 +++++++++++++------
apps/ui/src/layer/LayerShell.vue | 69 +-
apps/ui/src/shell/AppShell.vue | 15 +-
apps/ui/src/shell/AppSidebar.vue | 136 +-
apps/ui/src/shell/AppTopbar.vue | 39 +-
apps/ui/src/shell/PreviewModeBanner.vue | 110 ++
apps/ui/src/shell/TemplateConflictPrompt.vue | 163 ++-
apps/ui/src/shell/layerFromTemplate.ts | 94 ++
apps/ui/src/shell/router/index.ts | 21 +
apps/ui/src/shell/useLayers.ts | 50 +-
apps/ui/src/utils/stableJson.ts | 38 +
docs/customization/layer-templates.md | 23 +-
docs/customization/overview-templates.md | 41 +-
33 files changed, 3678 insertions(+), 1015 deletions(-)
diff --git a/.claude/skills/local-boot/SKILL.md b/.claude/skills/local-boot/SKILL.md
index 1c16348..df4ba15 100644
--- a/.claude/skills/local-boot/SKILL.md
+++ b/.claude/skills/local-boot/SKILL.md
@@ -76,12 +76,21 @@ Then open **`http://127.0.0.1:9091`** and log in as `admin` / `admin`.
## Boot against the public demo OAP
-The demo OAP needs basic-auth. The password is NOT committed — ask the
-developer for it and export it before booting:
+The demo OAP needs basic-auth (network username `admin`). The password is
+NOT committed — it lives in `oap-password.local` next to this file, which
+is git-ignored via the repo-wide `*.local` rule. Source it before booting;
+if the file is missing, ask the developer and recreate it (one line, the
+password only):
```bash
REPO="$(git rev-parse --show-toplevel)"
-read -rsp "Demo OAP password: " OAP_PASSWORD && export OAP_PASSWORD && echo
+SECRET="$REPO/.claude/skills/local-boot/oap-password.local"
+if [ -s "$SECRET" ]; then
+ OAP_PASSWORD="$(cat "$SECRET")"; export OAP_PASSWORD
+else
+ read -rsp "Demo OAP password: " OAP_PASSWORD && export OAP_PASSWORD && echo
+ printf '%s\n' "$OAP_PASSWORD" > "$SECRET" && chmod 600 "$SECRET" # cache for next boot
+fi
pkill -f "tsx watch src/server.ts" 2>/dev/null
pkill -f "tsx/dist/cli.mjs watch" 2>/dev/null; pkill -f vite 2>/dev/null
@@ -94,9 +103,10 @@ HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.demo.yaml" \
node_modules/.bin/vite --host 127.0.0.1 & )
```
-When invoked as an agent and `OAP_PASSWORD` is not already set, ask the
-developer for it (do not guess, do not hardcode it into a file). The OAP
-network username is fixed as `admin` in `horizon.demo.yaml`.
+The cached `oap-password.local` is git-ignored, so it is safe to keep on
+disk between boots. If it is absent and `OAP_PASSWORD` is unset, ask the
+developer rather than guessing. The OAP network username is fixed as
+`admin` in `horizon.demo.yaml`.
## Boot against an LDAP directory (test)
diff --git a/.gitignore b/.gitignore
index 0db8ab1..694f426 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,6 @@ horizon.yaml
horizon-audit.jsonl
horizon-wire.jsonl
horizon-setup.json
+
+# Release
+scripts/.release-work
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e6d349..0539b63 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,98 @@ packages) plus the BFF's `HORIZON_VERSION` default.
## 0.6.0
-(In development — fill in highlights here before cutting the release.)
+### Dashboard authoring — browser-local drafts + preview
+
+The Layer dashboards and Overview templates admin pages now share one
+editing model where your work-in-progress lives in your browser, and the
+live, shared version is whatever OAP serves.
+
+- **Edits are local to your browser.** "Save (local)" stores your draft in
+ this browser only — it is never written to the server and nobody else
+ sees it. Everyone (including you, in normal viewing) keeps seeing the
+ published remote dashboard until you publish.
+- **Load any source into the editor.** A single **Reset to ▾** control loads
+ the **Bundled** (shipped default) or **Remote** (OAP live) version into the
+ editor; editing from there becomes your local draft.
+- **Preview any source on the real page.** A **Preview ▾** control opens the
+ actual layer / overview page in a new tab rendering your **Local** draft,
+ the **Bundled** default, or **Remote** — via `?mode=preview&source=…`,
+ which stays in the URL and propagates as you navigate the menu. A banner
+ names what you're previewing (dismiss with × or Esc).
+- **Publish with a diff.** **Check diff & push** shows a side-by-side
+ local→remote diff and publishes to OAP; it's enabled only when your local
+ draft actually differs from remote. Bundled can also be pushed straight to
+ OAP. Resetting to remote clears the local draft.
+- Preview faithfully reflects your draft's **enabled components / menu
+ labels** — disabled tabs disappear and renamed nouns ("Nodes", "Topics")
+ show through — without pushing anything to the server. Preview works even
+ for layers OAP currently reports no services for.
+- An editors-only reminder lists any unpublished local drafts with quick
+ links to the relevant edit page (no more "use local vs remote" prompt —
+ remote is always the live source).
+- **Create mirrors edit.** "+ New dashboard" writes a local draft (the id
+ is the template name, checked unique) — edit and preview it, then
+ **Check diff & push** publishes it. A pushed dashboard with no bundled
+ default is **remote-only** and now renders everywhere (live page +
+ sidebar), not just in the editor.
+- **Delete = soft-disable** (OAP has no hard delete). A local-only draft is
+ removed from the browser; a dashboard on OAP is disabled — dropped from
+ the picker's live state, the sidebar, and the live page. A **disabled**
+ status chip shows it. Confirmations are styled in-app dialogs, not the
+ browser's native box.
+
+### Layer dashboards editor
+
+- The layer picker is a single filterable dropdown showing alias + key +
+ sync status. A live **menu preview** sits beside the Alias / Components /
+ **Menu labels** (per-layer slot aliases) editor; clicking a menu item
+ jumps to that component's config. Scope tabs and section headings read in
+ the layer's own vocabulary.
+- The service-list metrics editor gains a **sample-data preview** (plus a
+ faithful **landing KPI tile** preview that reuses the real header
+ components), the column remove-button alignment is fixed, and the landing
+ KPI tile config makes clear it just picks which existing column feeds the
+ headline + sparkline.
+- The picker's Diverged / Local filters now sit **inside the dropdown**
+ (next to the search), and the editor header reads on one line —
+ `Layer: <name> <key> <status>` — showing the same sync-status chip the
+ picker does.
+- **Disable / Reactivate a layer.** Disabling soft-disables the layer on
+ OAP and drops it from the sidebar (the menu honors disabled templates);
+ a disabled layer offers **Reactivate**, which re-enables it from the
+ bundled default (the OAP update path clears the disabled flag).
+
+### Overview templates editor
+
+- Rebuilt as a **layer-style canvas**: a 12-column grid you drag to
+ reorder and corner-drag to resize, click-to-edit in a right drawer that
+ appears on selection (Esc / deselect hides it), with section-breaks and
+ the dashboard title selectable and unselected widgets hinted by a dashed
+ outline. The canvas mirrors the live grid (fixed row height), so the
+ layout matches the real page — including side-by-side widgets like
+ topology + alarms.
+- The **composite-metrics** KPI editor stacks each row as a card
+ (label / source / MQE / unit / aggr / style / max) so nothing truncates
+ in the narrow drawer.
+
+### Navigation & shell
+
+- The **main sidebar folds** to a narrow rail to reclaim width; the logo
+ moves into the topbar while folded (the original wordmark is unchanged).
+- The active menu item now reliably expands, highlights, and scrolls into
+ view on entering a page (route matching is case-insensitive). Fixed a
+ regression where the sidebar scrolled to the very bottom on every
+ navigation (the "Debug events" toggle's active state was being treated
+ as the scroll target).
+- **Zipkin trace mode** drops the per-layer service-KPI header — the Zipkin
+ explorer is a self-contained, cross-service view.
+
+### Reliability
+
+- When the BFF is unreachable the UI now shows a clear "Cannot reach the
+ server" message instead of the cryptic "body stream already read" — the
+ API client reads each error response body once and surfaces the real
+ status/text (or a wrapped network error).
## 0.5.0
diff --git a/apps/bff/src/bundled_templates/layers/activemq.json b/apps/bff/src/bundled_templates/layers/activemq.json
index 74435c9..e82e783 100644
--- a/apps/bff/src/bundled_templates/layers/activemq.json
+++ b/apps/bff/src/bundled_templates/layers/activemq.json
@@ -119,7 +119,7 @@
"latest(aggregate_labels(meter_activemq_cluster_heap_memory_usage_max,sum))/1024/1024"
],
"span": 3,
- "rowSpan": 1
+ "rowSpan": 2
},
{
"id": "enqueue_dequeue",
diff --git a/apps/bff/src/http/admin/template-sync.ts b/apps/bff/src/http/admin/template-sync.ts
index 99b08b1..364e576 100644
--- a/apps/bff/src/http/admin/template-sync.ts
+++ b/apps/bff/src/http/admin/template-sync.ts
@@ -328,6 +328,69 @@ export function registerTemplateSyncAdminRoutes(
});
}
});
+
+ /* POST /api/admin/templates/disable — "delete" a template. OAP has no
+ * hard DELETE, so deletion is a soft `disable` of the remote UI-template
+ * (a disabled row is dropped from the bundle, so the template vanishes
+ * from the UI). Irreversible from the UI by design — there is no
+ * re-enable entrance.
+ *
+ * - remote present → disable that row.
+ * - bundled, no remote → create a remote from the bundled config
+ * first (so OAP has a row to flag), then disable it; otherwise a
+ * bundled-fallback has no id to disable and the bundle keeps serving.
+ * - neither → nothing on OAP (a local-only browser draft
+ * is removed client-side); no-op.
+ *
+ * Local-only drafts never reach here — the SPA removes them from the
+ * browser directly (a true hard delete; they were never on OAP). */
+ app.post<{ Body: { name?: string } }>(
+ '/api/admin/templates/disable',
+ { preHandler: auth },
+ async (req, reply) => {
+ const { name } = req.body ?? {};
+ if (typeof name !== 'string') {
+ return reply.code(400).send({
+ code: 'invalid_disable_body',
+ message: 'body must be { name: string }',
+ });
+ }
+ if (!parseName(name)) {
+ return reply.code(400).send({
+ code: 'invalid_template_name',
+ message: `expected horizon.<overview|layer|alert>.<key>, got ${JSON.stringify(name)}`,
+ });
+ }
+ const status = await loadStatus(deps);
+ if (status.unreachable) {
+ return reply.code(409).send({
+ code: 'oap_unreachable',
+ message: 'OAP admin port unreachable — templates are read-only',
+ });
+ }
+ const row = status.rows.find((r) => r.name === name);
+ if (!row?.remote && !row?.bundled) {
+ // Nothing on OAP and no bundle — already gone.
+ resync();
+ return reply.send(await loadStatus(deps));
+ }
+ try {
+ if (row.remote) {
+ await deps.uiTemplateClient().disable(row.remote.id);
+ } else if (row.bundled) {
+ // Bundled-fallback (no remote yet): materialise a remote from the
+ // bundled config so there's a row to flag disabled.
+ const created = await deps.uiTemplateClient().create(row.bundled.configuration);
+ await deps.uiTemplateClient().disable(created.id);
+ }
+ resync();
+ return reply.send(await loadStatus(deps));
+ } catch (err) {
+ logger.warn({ err: errMsg(err), name }, 'disable on OAP failed');
+ return reply.code(502).send({ code: 'oap_write_failed', message: errMsg(err) });
+ }
+ },
+ );
}
async function loadStatus(deps: TemplateSyncAdminDeps): Promise<SyncStatus> {
diff --git a/apps/bff/src/http/config/bundle.ts b/apps/bff/src/http/config/bundle.ts
index 7db97c4..49dc90c 100644
--- a/apps/bff/src/http/config/bundle.ts
+++ b/apps/bff/src/http/config/bundle.ts
@@ -142,11 +142,25 @@ async function buildBundle(deps: ConfigBundleDeps, preferLocal = false): Promise
}
const overviews: OverviewDashboard[] = [];
+ const diskOverviewIds = new Set<string>();
for (const dash of loadOverviewDashboards()) {
+ diskOverviewIds.add(dash.id);
const effective = pickOverviewContent(dash, remoteByName, preferLocal);
if (effective === null) continue; // disabled
overviews.push(effective);
}
+ // Remote-only overviews: dashboards that live on OAP with no on-disk
+ // base — created in the admin UI and pushed. The disk loop can't see
+ // them, so surface them straight from the remote envelope. (Layers can't
+ // be remote-only: every layer ships a bundled template.)
+ for (const row of sync.rows) {
+ if (row.kind !== 'overview' || row.status === 'disabled' || !row.remote) continue;
+ const env = parseEnvelope(row.remote.configuration);
+ if (!env || !isOverviewLike(env.content)) continue;
+ const dash = env.content as OverviewDashboard;
+ if (diskOverviewIds.has(dash.id)) continue; // already handled above
+ overviews.push(dash);
+ }
const syncStatus: BundleSyncStatus = {
unreachable: sync.unreachable,
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index a122fc5..39965cd 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -22,12 +22,16 @@ import type {
LayerDef,
LayerSlots,
MenuResponse,
+ UITemplateClient,
} from '@skywalking-horizon-ui/api-client';
import type { ConfigSource } from '../../config/loader.js';
import type { SessionStore } from '../../user/sessions.js';
import { requireAuth } from '../../user/middleware.js';
import { buildOapOpts, graphqlPost, type GraphqlOptions } from '../../client/graphql.js';
import { allLayerTemplates, getLayerTemplate, type LayerComponentFlags } from '../../logic/layers/loader.js';
+import { getSyncStatus } from '../../logic/templates/sync.js';
+import { iterateBundledTemplates } from '../../logic/templates/aggregator.js';
+import { logger } from '../../logger.js';
/**
* Map the JSON config's `components.*` flags onto the wire `caps`
@@ -63,6 +67,34 @@ export interface MenuRouteDeps {
config: ConfigSource;
sessions: SessionStore;
fetch?: FetchLike;
+ /** OAP UI-template client — lets the menu honor disabled layer templates
+ * (a layer disabled in the admin disappears from the sidebar). Optional
+ * so tests can omit it; without it no layer is filtered as disabled. */
+ uiTemplateClient?: () => UITemplateClient;
+}
+
+/** Canonical layer keys disabled on OAP (`horizon.layer.<KEY>` rows flagged
+ * disabled). Reads the shared 30s sync cache; soft-fails to an empty set so
+ * the sidebar never breaks because the template status couldn't be read. */
+async function disabledLayerKeys(deps: MenuRouteDeps): Promise<Set<string>> {
+ const out = new Set<string>();
+ if (!deps.uiTemplateClient) return out;
+ try {
+ const sync = await getSyncStatus({
+ client: deps.uiTemplateClient(),
+ bundled: () => iterateBundledTemplates(),
+ logger,
+ });
+ if (sync.unreachable) return out;
+ for (const row of sync.rows) {
+ if (row.kind === 'layer' && row.status === 'disabled') {
+ out.add(canonical(row.key.toUpperCase()));
+ }
+ }
+ } catch {
+ // Status unavailable — show every layer rather than hide wrongly.
+ }
+ return out;
}
// `listLayers` — active layers in this deployment.
@@ -295,8 +327,11 @@ export function registerMenuRoute(app: FastifyInstance, deps: MenuRouteDeps): vo
// feedback. Add more keys here if other internal-only layers
// need the same treatment.
const HIDDEN_LAYERS = new Set(['BANYANDB']);
+ // Layers whose template was disabled in the admin — soft-deleted, so
+ // drop them from the sidebar (matches how disabled overviews vanish).
+ const disabled = await disabledLayerKeys(deps);
const layers = ordered
- .filter((key) => !HIDDEN_LAYERS.has(key))
+ .filter((key) => !HIDDEN_LAYERS.has(key) && !disabled.has(key))
.map((key) =>
deriveLayer(
key,
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index e4dd6cb..0658daa 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -205,6 +205,7 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
'POST /api/admin/templates/resync': 'overview:write',
'POST /api/admin/templates/save': 'overview:write',
'POST /api/admin/templates/save-local': 'overview:write',
+ 'POST /api/admin/templates/disable': 'overview:write',
'POST /api/admin/templates/revert-local': 'overview:write',
'POST /api/admin/templates/:name/push-bundled': 'overview:write',
'POST /api/admin/templates/sync-all': 'overview:write',
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 330c03e..1ca49f9 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -145,7 +145,11 @@ registerAuthHealthRoute(app, { config: source, ldapHealth });
// ── Query ──────────────────────────────────────────────────────────
registerOapInfoRoute(app, { config: source, sessions });
-registerMenuRoute(app, { config: source, sessions });
+registerMenuRoute(app, {
+ config: source,
+ sessions,
+ uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+});
registerLandingRoute(app, { config: source, sessions });
registerInstanceRoute(app, { config: source, sessions });
registerEndpointRoute(app, { config: source, sessions });
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index bf0bf22..b447c15 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -614,12 +614,16 @@ export class BffClient {
// 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)}`,
+ const detail = err instanceof Error ? err.message : String(err);
+ pushEvent('api', 'err', `${method} ${path} · network ${detail}`);
+ // Wrap into a clear, actionable message — the raw "Failed to fetch" /
+ // "Load failed" reads as a mystery to operators. status 0 = no HTTP
+ // response reached us at all (BFF down / unreachable).
+ throw new BffApiError(
+ 0,
+ `Cannot reach the server — the BFF is unreachable (${detail}).`,
+ null,
);
- throw err;
}
if (res.status === 401) {
// 401 is normal-ish (session expired) — log at 'info' rather
@@ -630,11 +634,19 @@ export class BffClient {
throw new BffApiError(401, 'unauthenticated', null);
}
if (!res.ok) {
- let parsed: unknown = null;
- try {
- parsed = await res.json();
- } catch {
- parsed = await res.text();
+ // Read the body ONCE as text, then try to parse it as JSON. Calling
+ // res.json() then res.text() in a fallback double-reads the stream
+ // ("body stream already read") and masks the real error — which is
+ // exactly what happens when a non-JSON error (e.g. a Vite proxy 502
+ // page when the BFF is down) comes back.
+ const raw = await res.text().catch(() => '');
+ let parsed: unknown = raw;
+ if (raw) {
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ parsed = raw;
+ }
}
// Surface the BFF's `code` / `message` envelope when present so
// the operator gets the actionable reason instead of just "500".
diff --git a/apps/ui/src/api/scopes/template-sync.ts b/apps/ui/src/api/scopes/template-sync.ts
index 88b311e..09abb43 100644
--- a/apps/ui/src/api/scopes/template-sync.ts
+++ b/apps/ui/src/api/scopes/template-sync.ts
@@ -78,6 +78,16 @@ export class TemplateSyncApi {
});
}
+ /** "Delete" a template — OAP has no hard DELETE, so this soft-disables
+ * the remote UI-template. The BFF refuses (409 `bundled_not_deletable`)
+ * when the template ships a bundled default; only remote-only templates
+ * are genuinely removable. Returns the fresh sync status. */
+ disable(name: string): Promise<TemplateSyncStatus> {
+ return this.bff.request<TemplateSyncStatus>('POST', '/api/admin/templates/disable', {
+ name,
+ });
+ }
+
/** Operator wants the bundled JSON to overwrite whatever OAP has for
* this name. Used for "adopt my code defaults" from the diff view. */
pushBundled(name: string): Promise<TemplateSyncStatus> {
diff --git a/apps/ui/src/controls/configBundle.ts b/apps/ui/src/controls/configBundle.ts
index 95e73b4..877f0da 100644
--- a/apps/ui/src/controls/configBundle.ts
+++ b/apps/ui/src/controls/configBundle.ts
@@ -37,9 +37,35 @@ import { bffClient } from '@/api/client';
import { pushEvent } from '@/controls/eventLog';
import { debug } from '@/utils/debug';
import { useTemplatePreference } from '@/controls/templatePreference';
+import { useLocalTemplateEdits, layerEditName, overviewEditName } from '@/controls/localTemplateEdits';
+import { usePreviewMode, getPreviewSource } from '@/controls/previewMode';
+import { usePreviewOverride } from '@/controls/previewOverride';
import type { ConfigBundle, BundleScopeMap } from '@/api/scopes/configs';
import type { DashboardWidget, OverviewDashboard } from '@skywalking-horizon-ui/api-client';
+// Browser-side unpublished drafts. Overlaid on live pages ONLY while the
+// route is in `?mode=preview` — the editor's explicit preview entrance.
+// Normal viewing always renders remote; the draft is never shown to other
+// users or in plain review.
+const localEdits = useLocalTemplateEdits();
+const previewMode = usePreviewMode();
+// The Preview dropdown can preview ANY source (local/bundled/remote); it
+// writes the chosen content here and this takes precedence over the draft.
+const previewOverride = usePreviewOverride();
+
+/** Content to overlay for `name` while previewing, or undefined.
+ * `source=local` reads the LIVE local draft (so it follows Reset/Save —
+ * no stale snapshot); `bundled`/`remote` read the override snapshot the
+ * Preview dropdown captured. */
+function previewContentFor<T>(name: string): T | undefined {
+ if (!previewMode.value) return undefined;
+ const src = getPreviewSource();
+ if (src === 'local') return localEdits.get<T>(name);
+ if (src === 'bundled' || src === 'remote') return previewOverride.get<T>(name);
+ // No explicit source (hand-typed ?mode=preview) — override then draft.
+ return previewOverride.get<T>(name) ?? localEdits.get<T>(name);
+}
+
/** `local` only when the operator opted to preview unpublished edits;
* otherwise `remote` (the default runtime source of truth). */
function preferParam(): 'local' | 'remote' {
@@ -157,15 +183,40 @@ export function getDashboardConfig(
layerKey: string,
scope: 'service' | 'instance' | 'endpoint',
): DashboardWidget[] | null {
+ // In preview mode, overlay the previewed source's content for this layer.
+ type LayerContent = { dashboards?: Record<string, DashboardWidget[]>; widgets?: DashboardWidget[] };
+ const draft = previewContentFor<LayerContent>(layerEditName(layerKey));
+ if (draft) {
+ const d = draft.dashboards?.[scope] ?? (scope === 'service' ? draft.widgets : undefined);
+ if (d !== undefined) return d;
+ }
const b = state.value;
if (!b) return null;
const layer = b.layers[layerKey.toLowerCase()] as BundleScopeMap | undefined;
return layer?.[scope] ?? null;
}
-/** Sync lookup. Returns null when the bundle hasn't loaded yet. */
+/** Sync lookup. Returns null when the bundle hasn't loaded yet. Local
+ * browser drafts replace the matching overview for the editing operator. */
export function getOverviews(): OverviewDashboard[] | null {
- return state.value?.overviews ?? null;
+ const base = state.value?.overviews ?? null;
+ if (!base) return null;
+ if (!previewMode.value) return base;
+ // Overlay each overview with its previewed source's content…
+ const out = base.map((ov) => previewContentFor<OverviewDashboard>(overviewEditName(ov.id)) ?? ov);
+ // …and inject previewed overviews absent from the bundle.
+ const seen = new Set(base.map((o) => o.id));
+ for (const name of [...previewOverride.names(), ...localEdits.names()]) {
+ if (!name.startsWith('horizon.overview.')) continue;
+ const id = name.slice('horizon.overview.'.length);
+ if (seen.has(id)) continue;
+ const content = previewContentFor<OverviewDashboard>(name);
+ if (content) {
+ out.push(content);
+ seen.add(id);
+ }
+ }
+ return out;
}
export function useConfigBundle(): {
diff --git a/apps/ui/src/controls/localTemplateEdits.ts b/apps/ui/src/controls/localTemplateEdits.ts
new file mode 100644
index 0000000..041df09
--- /dev/null
+++ b/apps/ui/src/controls/localTemplateEdits.ts
@@ -0,0 +1,100 @@
+/*
+ * 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.
+ */
+
+/**
+ * Browser-side TEMP store for in-progress dashboard-template edits.
+ *
+ * An operator's edit is a *local draft* that lives only in this browser
+ * (localStorage) until they explicitly push it to OAP. It is NOT written
+ * to the BFF's on-disk bundled files (those stay the pristine shipped
+ * defaults) and it is NOT shared with other users. The config bundle
+ * overlays these drafts on top of the remote/bundled content so the
+ * editing operator previews their own edits on live pages; everyone else
+ * keeps seeing remote.
+ *
+ * Keyed by the canonical template name the sync layer uses:
+ * `horizon.layer.<KEY>` / `horizon.overview.<id>`
+ *
+ * Push-to-remote clears the entry (it's now the live version); reset-to-
+ * bundled also clears it (the operator abandoned the draft).
+ */
+
+import { ref, computed, type ComputedRef } from 'vue';
+
+const STORAGE_KEY = 'horizon:localTemplateEdits:v1';
+
+type EditMap = Record<string, unknown>;
+
+function read(): EditMap {
+ if (typeof localStorage === 'undefined') return {};
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ return raw ? (JSON.parse(raw) as EditMap) : {};
+ } catch {
+ return {};
+ }
+}
+
+const edits = ref<EditMap>(read());
+
+function persist(): void {
+ if (typeof localStorage === 'undefined') return;
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(edits.value));
+ } catch {
+ /* quota / disabled — in-memory copy still works for this session */
+ }
+}
+
+/** Canonical name builders — match the sync layer's `horizon.<kind>.<key>`. */
+export function layerEditName(key: string): string {
+ return `horizon.layer.${key.toUpperCase()}`;
+}
+export function overviewEditName(id: string): string {
+ return `horizon.overview.${id}`;
+}
+
+export function useLocalTemplateEdits(): {
+ /** Reactive map of name → draft content. */
+ edits: typeof edits;
+ /** Count of unpushed drafts (drives badges / the conflict prompt). */
+ count: ComputedRef<number>;
+ has: (name: string) => boolean;
+ get: <T = unknown>(name: string) => T | undefined;
+ set: (name: string, content: unknown) => void;
+ remove: (name: string) => void;
+ names: () => string[];
+} {
+ return {
+ edits,
+ count: computed(() => Object.keys(edits.value).length),
+ has: (name) => Object.prototype.hasOwnProperty.call(edits.value, name),
+ get: <T = unknown>(name: string) => edits.value[name] as T | undefined,
+ set: (name, content) => {
+ edits.value = { ...edits.value, [name]: content };
+ persist();
+ },
+ remove: (name) => {
+ if (!Object.prototype.hasOwnProperty.call(edits.value, name)) return;
+ const next = { ...edits.value };
+ delete next[name];
+ edits.value = next;
+ persist();
+ },
+ names: () => Object.keys(edits.value),
+ };
+}
diff --git a/apps/ui/src/controls/previewMode.ts b/apps/ui/src/controls/previewMode.ts
new file mode 100644
index 0000000..73453c0
--- /dev/null
+++ b/apps/ui/src/controls/previewMode.ts
@@ -0,0 +1,51 @@
+/*
+ * 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.
+ */
+
+/**
+ * Whether the current route is previewing unpublished template content
+ * (`?mode=preview`), and from which source (`&source=local|bundled|remote`).
+ * Browser-side template drafts (controls/localTemplateEdits) +
+ * preview-overrides (controls/previewOverride) overlay live pages ONLY
+ * while this is on. The router keeps it sticky across in-app (menu)
+ * navigation and re-applies the query so the URL always reflects it —
+ * see shell/router/index.ts. Normal viewing always renders remote.
+ */
+
+import { ref } from 'vue';
+
+export type PreviewSource = 'local' | 'bundled' | 'remote';
+
+const previewMode = ref(false);
+const previewSource = ref<PreviewSource | null>(null);
+
+export function setPreviewMode(on: boolean, source?: string | null): void {
+ previewMode.value = on;
+ if (!on) {
+ previewSource.value = null;
+ return;
+ }
+ if (source === 'local' || source === 'bundled' || source === 'remote') {
+ previewSource.value = source;
+ }
+}
+
+export function usePreviewMode(): typeof previewMode {
+ return previewMode;
+}
+export function getPreviewSource(): PreviewSource | null {
+ return previewSource.value;
+}
diff --git a/apps/ui/src/controls/previewOverride.ts b/apps/ui/src/controls/previewOverride.ts
new file mode 100644
index 0000000..d8c4e27
--- /dev/null
+++ b/apps/ui/src/controls/previewOverride.ts
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Per-template "preview this exact content" override, used by the admin's
+ * Preview dropdown to render ANY source (local / bundled / remote) on the
+ * real page. The admin writes the chosen source's content here, then opens
+ * the live page in a new tab with `?mode=preview`; the config bundle
+ * overlay reads this override (taking precedence over the local draft).
+ *
+ * Stored in localStorage — NOT sessionStorage — because the preview opens
+ * in a new tab, which does not share sessionStorage. Keyed by the canonical
+ * template name (`horizon.layer.<KEY>` / `horizon.overview.<id>`).
+ */
+
+import { ref } from 'vue';
+
+const STORAGE_KEY = 'horizon:previewOverride:v1';
+type OverrideMap = Record<string, unknown>;
+
+function read(): OverrideMap {
+ if (typeof localStorage === 'undefined') return {};
+ try {
+ return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as OverrideMap;
+ } catch {
+ return {};
+ }
+}
+
+const overrides = ref<OverrideMap>(read());
+
+function persist(): void {
+ if (typeof localStorage === 'undefined') return;
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides.value));
+ } catch {
+ /* quota / disabled — in-memory copy still works for this tab */
+ }
+}
+
+export function usePreviewOverride(): {
+ get: <T = unknown>(name: string) => T | undefined;
+ set: (name: string, content: unknown) => void;
+ clear: (name: string) => void;
+ names: () => string[];
+} {
+ return {
+ names: () => Object.keys(overrides.value),
+ get: <T = unknown>(name: string) => overrides.value[name] as T | undefined,
+ set: (name, content) => {
+ overrides.value = { ...overrides.value, [name]: content };
+ persist();
+ },
+ clear: (name) => {
+ if (!Object.prototype.hasOwnProperty.call(overrides.value, name)) return;
+ const next = { ...overrides.value };
+ delete next[name];
+ overrides.value = next;
+ persist();
+ },
+ };
+}
diff --git a/apps/ui/src/controls/sidebar.ts b/apps/ui/src/controls/sidebar.ts
new file mode 100644
index 0000000..1628ff3
--- /dev/null
+++ b/apps/ui/src/controls/sidebar.ts
@@ -0,0 +1,56 @@
+/*
+ * 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.
+ */
+
+/**
+ * Collapse state for the main navigation sidebar. Shared between the
+ * shell grid (AppShell drives the `.sw` column width) and the sidebar
+ * itself (AppSidebar renders the expand/collapse affordance). Sticky
+ * per browser via localStorage so the operator's fold choice survives
+ * reloads. Defaults expanded.
+ */
+
+import { ref, watch } from 'vue';
+
+const STORAGE_KEY = 'horizon:sidebarCollapsed:v1';
+
+function detectInitial(): boolean {
+ if (typeof localStorage === 'undefined') return false;
+ return localStorage.getItem(STORAGE_KEY) === '1';
+}
+
+const collapsed = ref<boolean>(detectInitial());
+
+watch(collapsed, (on) => {
+ if (typeof localStorage === 'undefined') return;
+ try {
+ localStorage.setItem(STORAGE_KEY, on ? '1' : '0');
+ } catch {
+ /* private mode / quota — degrade silently */
+ }
+});
+
+export function useSidebar(): {
+ collapsed: typeof collapsed;
+ toggle: () => void;
+} {
+ return {
+ collapsed,
+ toggle: () => {
+ collapsed.value = !collapsed.value;
+ },
+ };
+}
diff --git a/apps/ui/src/features/admin/_shared/SyncAllButton.vue b/apps/ui/src/features/admin/_shared/SyncAllButton.vue
deleted file mode 100644
index 88d3e3d..0000000
--- a/apps/ui/src/features/admin/_shared/SyncAllButton.vue
+++ /dev/null
@@ -1,191 +0,0 @@
-<!--
- Licensed to the Apache Software Foundation (ASF) under one or more
- contributor license agreements. See the NOTICE file distributed with
- this work for additional information regarding copyright ownership.
- The ASF licenses this file to You under the Apache License, Version 2.0
- (the "License"); you may not use this file except in compliance with
- the License. You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
--->
-<!--
- "Sync all to OAP" for one template family. Pushes the bundled copy of
- every template that differs from OAP (diverged) or is missing on OAP
- (bundled-fallback) — already-synced templates are left untouched. The
- push always goes through a manual confirm listing exactly what will be
- written, since it mutates OAP's shared template store.
--->
-<script setup lang="ts">
-import { computed, ref } from 'vue';
-import Modal from '@/features/operate/_shared/Modal.vue';
-import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
-import { refreshConfigBundle, setTemplateRenderMode } from '@/controls/configBundle';
-import { useTemplatePreference } from '@/controls/templatePreference';
-import { bffClient } from '@/api/client';
-import type { TemplateKind } from '@/api/scopes/configs';
-
-const props = defineProps<{ kind: TemplateKind }>();
-
-const sync = useTemplateSync({ kind: props.kind });
-const pref = useTemplatePreference();
-
-// Effective render source for diverged templates (null defaults to remote).
-const renderMode = computed<'local' | 'remote'>(() => (pref.mode === 'local' ? 'local' : 'remote'));
-function setMode(m: 'local' | 'remote'): void {
- if (renderMode.value !== m) void setTemplateRenderMode(m);
-}
-
-/** Templates whose bundled copy differs from OAP (push targets). */
-const diffNames = computed<string[]>(() => {
- const s = sync.status.value;
- if (!s) return [];
- return s.badges
- .filter(
- (b) => b.kind === props.kind && (b.status === 'diverged' || b.status === 'bundled-fallback'),
- )
- .map((b) => b.name)
- .sort();
-});
-
-const open = ref(false);
-const busy = ref(false);
-const result = ref<{ synced: number; failed: { name: string; error: string }[] } | null>(null);
-
-const disabled = computed(() => sync.readOnly.value || diffNames.value.length === 0);
-const buttonTitle = computed(() => {
- if (sync.readOnly.value) return 'OAP unreachable — page is read-only';
- if (diffNames.value.length === 0) return 'Everything already matches OAP — nothing to push';
- return `Push ${diffNames.value.length} changed template(s) to OAP`;
-});
-
-function openConfirm(): void {
- if (disabled.value) return;
- result.value = null;
- open.value = true;
-}
-
-async function confirmSync(): Promise<void> {
- if (busy.value) return;
- busy.value = true;
- try {
- const res = await bffClient.templateSync.syncAll(props.kind);
- await refreshConfigBundle();
- result.value = { synced: res.synced.length, failed: res.failed };
- if (res.failed.length === 0) {
- // Clean success — close shortly so the operator sees the count.
- setTimeout(() => {
- open.value = false;
- result.value = null;
- }, 1400);
- }
- } catch (err) {
- result.value = { synced: 0, failed: [{ name: '—', error: err instanceof Error ? err.message : String(err) }] };
- } finally {
- busy.value = false;
- }
-}
-</script>
-
-<template>
- <span class="sab__display" title="Which version this session renders for templates that differ from OAP.">
- <span class="sab__display-label">Showing</span>
- <button class="sab__seg" :class="{ on: renderMode === 'local' }" type="button" @click="setMode('local')">Local</button>
- <button class="sab__seg" :class="{ on: renderMode === 'remote' }" type="button" @click="setMode('remote')">Remote</button>
- </span>
- <button
- class="sw-btn"
- type="button"
- :disabled="disabled"
- :title="buttonTitle"
- @click="openConfirm"
- >
- Sync all to OAP<span v-if="diffNames.length" class="sab__count">{{ diffNames.length }}</span>
- </button>
-
- <Modal :open="open" title="Sync all to OAP" @close="open = false">
- <div v-if="!result" class="sab__body">
- <p class="sab__lede">
- Push the bundled copy of these <b>{{ diffNames.length }}</b> template(s) to OAP,
- overwriting what OAP currently stores. Already-synced templates are not touched.
- This affects every operator using this OAP.
- </p>
- <ul class="sab__list">
- <li v-for="n in diffNames" :key="n" class="mono">{{ n }}</li>
- </ul>
- </div>
- <div v-else class="sab__body">
- <p class="sab__lede">
- Pushed <b>{{ result.synced }}</b> template(s).
- <span v-if="result.failed.length" class="sab__err">{{ result.failed.length }} failed.</span>
- </p>
- <ul v-if="result.failed.length" class="sab__list">
- <li v-for="f in result.failed" :key="f.name" class="mono sab__err">{{ f.name }}: {{ f.error }}</li>
- </ul>
- </div>
-
- <template #footer>
- <button class="sw-btn ghost" type="button" @click="open = false">{{ result ? 'Close' : 'Cancel' }}</button>
- <button
- v-if="!result"
- class="sw-btn primary"
- type="button"
- :disabled="busy || diffNames.length === 0"
- @click="confirmSync"
- >
- {{ busy ? 'Pushing…' : `Push ${diffNames.length} to OAP` }}
- </button>
- </template>
- </Modal>
-</template>
-
-<style scoped>
-.sab__count {
- margin-left: 6px;
- padding: 0 6px;
- border-radius: 8px;
- background: var(--sw-accent);
- color: #1a1a1a;
- font-size: 10px;
- font-weight: 700;
-}
-.sab__display {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- margin-right: 8px;
- font-size: 11px;
-}
-.sab__display-label { color: var(--sw-fg-3); margin-right: 2px; }
-.sab__seg {
- padding: 2px 8px;
- border: 1px solid var(--sw-line);
- background: transparent;
- color: var(--sw-fg-2);
- font: inherit;
- font-size: 11px;
- cursor: pointer;
-}
-.sab__seg:first-of-type { border-radius: 5px 0 0 5px; }
-.sab__seg:last-of-type { border-radius: 0 5px 5px 0; border-left: none; }
-.sab__seg.on { background: var(--sw-accent); color: #1a1a1a; border-color: var(--sw-accent); font-weight: 600; }
-.sab__body { padding: 4px 2px; }
-.sab__lede { margin: 0 0 10px; font-size: 12px; color: var(--sw-fg-2); line-height: 1.5; }
-.sab__list {
- margin: 0;
- padding: 8px 10px;
- list-style: none;
- max-height: 40vh;
- overflow: auto;
- border: 1px solid var(--sw-line);
- border-radius: 6px;
- background: var(--sw-bg-2);
-}
-.sab__list li { font-size: 11.5px; padding: 2px 0; color: var(--sw-fg-1); }
-.sab__err { color: var(--sw-err); }
-</style>
diff --git a/apps/ui/src/features/admin/_shared/useTemplateSources.ts b/apps/ui/src/features/admin/_shared/useTemplateSources.ts
new file mode 100644
index 0000000..c794ad9
--- /dev/null
+++ b/apps/ui/src/features/admin/_shared/useTemplateSources.ts
@@ -0,0 +1,94 @@
+/*
+ * 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.
+ */
+
+/**
+ * Fetches the BFF sync rows for one template kind and exposes, per
+ * template name, the BUNDLED and REMOTE content objects — the two
+ * server-side versions of a template. The admin pages combine these
+ * with the browser LOCAL draft (controls/localTemplateEdits) to drive
+ * the Local / Bundled / Remote source selector in the editor.
+ *
+ * The row's `configuration` is the canonical envelope
+ * `{ name, kind, version, content }`; we return `content` (the actual
+ * template object).
+ */
+
+import { computed } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import { bff } from '@/api/client';
+import type { TemplateKind } from '@/api/scopes/configs';
+
+function envelopeContent<T>(configuration: string | undefined | null): T | null {
+ if (!configuration) return null;
+ try {
+ const env = JSON.parse(configuration) as { content?: unknown };
+ return (env?.content ?? null) as T | null;
+ } catch {
+ return null;
+ }
+}
+
+export function useTemplateSources(kind: TemplateKind) {
+ const q = useQuery({
+ queryKey: ['admin/template-sources', kind],
+ queryFn: () => bff.templateSync.syncStatus(),
+ staleTime: 30_000,
+ });
+
+ const rowsByName = computed(() => {
+ const m = new Map<string, { bundled: string | null; remote: string | null }>();
+ for (const r of q.data.value?.rows ?? []) {
+ if (r.kind !== kind) continue;
+ m.set(r.name, {
+ bundled: r.bundled?.configuration ?? null,
+ remote: r.remote?.configuration ?? null,
+ });
+ }
+ return m;
+ });
+
+ return {
+ isLoading: q.isLoading,
+ refetch: q.refetch,
+ /** Bundled (shipped) content for a template name, or null. */
+ bundled<T>(name: string): T | null {
+ return envelopeContent<T>(rowsByName.value.get(name)?.bundled);
+ },
+ /** Remote (OAP live) content for a template name, or null. */
+ remote<T>(name: string): T | null {
+ return envelopeContent<T>(rowsByName.value.get(name)?.remote);
+ },
+ /** True when OAP currently serves this template. */
+ hasRemote(name: string): boolean {
+ return !!rowsByName.value.get(name)?.remote;
+ },
+ /** Names of every template OAP currently serves (this kind). Lets an
+ * admin page surface remote-only templates — ones that exist on OAP
+ * with no bundled/disk base, e.g. a dashboard created + pushed here. */
+ remoteNames(): string[] {
+ const out: string[] = [];
+ for (const [name, row] of rowsByName.value) if (row.remote) out.push(name);
+ return out;
+ },
+ /** True when this template ships a bundled (built-in) default. Such a
+ * template can't be deleted — removing the OAP copy just falls back
+ * to the bundle, so the row would reappear. */
+ hasBundled(name: string): boolean {
+ return !!rowsByName.value.get(name)?.bundled;
+ },
+ };
+}
diff --git a/apps/ui/src/features/admin/_shared/useTemplateSync.ts b/apps/ui/src/features/admin/_shared/useTemplateSync.ts
index 76bb3e5..69370b8 100644
--- a/apps/ui/src/features/admin/_shared/useTemplateSync.ts
+++ b/apps/ui/src/features/admin/_shared/useTemplateSync.ts
@@ -37,6 +37,7 @@
import { computed, type ComputedRef } from 'vue';
import { useConfigBundle } from '@/controls/configBundle';
+import { useLocalTemplateEdits } from '@/controls/localTemplateEdits';
import type {
BundleSyncStatus,
TemplateBadge,
@@ -54,6 +55,8 @@ export interface SyncBanner {
detail?: string;
/** Per-status counts for the kinds owned by this page. */
counts: Partial<Record<TemplateStatus, number>>;
+ /** Unpublished local browser drafts for this page's kind. */
+ localCount: number;
}
export interface UseTemplateSyncOptions {
@@ -71,6 +74,12 @@ export interface UseTemplateSyncReturn {
export function useTemplateSync(opts: UseTemplateSyncOptions): UseTemplateSyncReturn {
const { bundle } = useConfigBundle();
+ const localEdits = useLocalTemplateEdits();
+
+ // Unpublished local browser drafts for this kind (`horizon.<kind>.*`).
+ const localCount = computed<number>(
+ () => localEdits.names().filter((n) => n.startsWith(`horizon.${opts.kind}.`)).length,
+ );
const status = computed<BundleSyncStatus | null>(() => bundle.value?.syncStatus ?? null);
@@ -82,14 +91,21 @@ export function useTemplateSync(opts: UseTemplateSyncOptions): UseTemplateSyncRe
const readOnly = computed<boolean>(() => status.value?.unreachable === true);
+ // Shown on diverged + clean banners so the operator always knows what
+ // the two axes mean.
+ const GLOSSARY =
+ 'Diverged = the bundled (shipped) default differs from the version live on OAP — OAP wins at render time. ' +
+ 'Local = unpublished edits saved only in this browser; publish with “Check diff & push”.';
+ const localSuffix = computed(() =>
+ localCount.value > 0
+ ? ` · ${localCount.value} local draft${localCount.value === 1 ? '' : 's'} in this browser`
+ : '',
+ );
+
const banner = computed<SyncBanner>(() => {
const s = status.value;
if (!s) {
- return {
- severity: 'unknown',
- message: 'Loading template sync status…',
- counts: {},
- };
+ return { severity: 'unknown', message: 'Loading template sync status…', counts: {}, localCount: localCount.value };
}
const counts: Partial<Record<TemplateStatus, number>> = {};
for (const b of ownBadges.value) counts[b.status] = (counts[b.status] ?? 0) + 1;
@@ -106,28 +122,32 @@ export function useTemplateSync(opts: UseTemplateSyncOptions): UseTemplateSyncRe
? `Last successful sync: ${last}`
: 'No successful sync yet since this BFF started.',
counts,
+ localCount: localCount.value,
};
}
const diverged = counts.diverged ?? 0;
const remoteOnly = counts['remote-only'] ?? 0;
const disabled = counts.disabled ?? 0;
- if (diverged + remoteOnly + disabled > 0) {
+ if (diverged + remoteOnly + disabled > 0 || localCount.value > 0) {
const parts: string[] = [];
if (diverged > 0) parts.push(`${diverged} diverged`);
if (remoteOnly > 0) parts.push(`${remoteOnly} remote-only`);
if (disabled > 0) parts.push(`${disabled} disabled`);
+ if (localCount.value > 0) parts.push(`${localCount.value} local`);
return {
- severity: 'diverged',
- message: `Synced from OAP — ${parts.join(', ')}.`,
- detail:
- 'Diverged rows can be inspected and reconciled inline. Bundled is the seed; OAP is the source of truth at runtime.',
+ severity: localCount.value > 0 || diverged > 0 ? 'diverged' : 'clean',
+ message: `Synced from OAP — ${parts.length ? parts.join(', ') : 'all match bundled'}.`,
+ detail: GLOSSARY,
counts,
+ localCount: localCount.value,
};
}
return {
severity: 'clean',
- message: `Synced from OAP — all ${ownBadges.value.length} templates match bundled.`,
+ message: `Synced from OAP — all ${ownBadges.value.length} templates match bundled.${localSuffix.value}`,
+ detail: GLOSSARY,
counts,
+ localCount: localCount.value,
};
});
diff --git a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index 9e2d24a..81ffedf 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -46,16 +46,21 @@ import type {
* ProcessRelation MQE. */
type AdminScope = DashboardScope | 'networkProfiling';
import { bff, bffClient } from '@/api/client';
+import { useLocalTemplateEdits, layerEditName } from '@/controls/localTemplateEdits';
+import { useTemplateSources } from '@/features/admin/_shared/useTemplateSources';
+import { usePreviewOverride } from '@/controls/previewOverride';
import TimeChart from '@/components/charts/TimeChart.vue';
import TopList from '@/components/charts/TopList.vue';
+import Sparkline from '@/components/charts/Sparkline.vue';
import { fmtMetric } from '@/utils/formatters';
+import { stableStringify } from '@/utils/stableJson';
import { mockCardValue, mockLineSeries, mockRecordRows, mockTopGroups } from './widget-mock';
import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue';
-import SyncAllButton from '@/features/admin/_shared/SyncAllButton.vue';
import { refreshConfigBundle } from '@/controls/configBundle';
import TemplateStatusBadge from '@/features/admin/_shared/TemplateStatusBadge.vue';
-import TemplateDiffModal from '@/features/admin/_shared/TemplateDiffModal.vue';
import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
+import Modal from '@/features/operate/_shared/Modal.vue';
+import MonacoDiff from '@/features/operate/_shared/MonacoDiff.vue';
// OAP UI-template sync status for layer dashboards. Drives the banner +
// Save gating + per-row badge below.
@@ -100,25 +105,29 @@ const selectedKey = ref<string>('');
const activeScope = ref<AdminScope>('service');
const isSaving = ref(false);
const saveMsg = ref<string | null>(null);
-/** When false the layers rail collapses to a thin dot-strip so the
- * editor can claim the full width. The toggle in the rail header
- * flips this; layer switching still works via dot click. */
-const layerListOpen = ref(true);
+/** When false the browse rail is hidden entirely and layer switching
+ * moves to the top dropdown inside the editor — the editor claims the
+ * full width. Defaults collapsed; the rail is an opt-in browse mode.
+ * Selecting a layer from the rail re-collapses it (see `selectLayer`). */
+const layerListOpen = ref(false);
/** Free-text filter for the layers rail — matches alias or key. */
const layerSearch = ref('');
// When on, the list shows only layers whose bundled copy differs from
// OAP (diverged) or isn't on OAP yet (bundled-fallback) — i.e. the set
// "Sync all to OAP" would push. Off shows every layer.
const divergedOnly = ref(false);
+const localOnly = ref(false);
function isDivergedRow(key: string): boolean {
const s = sync.badgeFor(`horizon.layer.${key}`);
return s === 'diverged' || s === 'bundled-fallback';
}
const divergedCount = computed(() => templates.value.filter((t) => isDivergedRow(t.key)).length);
+const localCount = computed(() => templates.value.filter((t) => localEdits.has(layerEditName(t.key))).length);
const filteredTemplates = computed<AdminLayerTemplate[]>(() => {
const q = layerSearch.value.trim().toLowerCase();
return templates.value.filter((t) => {
if (divergedOnly.value && !isDivergedRow(t.key)) return false;
+ if (localOnly.value && !localEdits.has(layerEditName(t.key))) return false;
if (!q) return true;
return (t.alias ?? '').toLowerCase().includes(q) || t.key.toLowerCase().includes(q);
});
@@ -128,6 +137,15 @@ const filteredTemplates = computed<AdminLayerTemplate[]>(() => {
* the Save / Reset state. */
const draft = reactive<{ template: AdminLayerTemplate | null }>({ template: null });
+// Unpublished edits live in THIS browser (localStorage), not on the BFF
+// disk. The editor seeds from the browser draft when one exists, and the
+// config bundle overlays it on live pages — see controls/localTemplateEdits.
+const localEdits = useLocalTemplateEdits();
+// Server-side bundled + remote content for the Reset-to / Preview editor
+// sources. Local (browser draft) comes from `localEdits`.
+const sources = useTemplateSources('layer');
+const previewOverride = usePreviewOverride();
+
async function loadAll(): Promise<void> {
isLoading.value = true;
error.value = null;
@@ -179,11 +197,125 @@ watch(
},
);
-function syncDraft(): void {
- const tpl = templates.value.find((t) => t.key === selectedKey.value);
- draft.template = tpl ? JSON.parse(JSON.stringify(tpl)) : null;
+/* ── Editor sources ───────────────────────────────────────────────
+ * The editor loads from one of three sources: LOCAL (browser draft),
+ * BUNDLED (shipped default), or REMOTE (OAP live). `editorSource` tracks
+ * which is loaded; `loadedSnapshot` is the content at load/save time and
+ * is the baseline for `dirty` (= unsaved edits since the last load/save).
+ * Saving always writes LOCAL; you then publish LOCAL → OAP.
+ * ─────────────────────────────────────────────────────────────────── */
+const editorSource = ref<'local' | 'bundled' | 'remote'>('bundled');
+const loadedSnapshot = ref<string>('');
+const editName = computed(() => layerEditName(selectedKey.value));
+const hasLocalDraft = computed(() => localEdits.has(editName.value));
+const remoteAvailable = computed(() => sources.hasRemote(editName.value));
+// Ships a bundled default → not deletable (delete would fall back to the
+// bundle, so the row reappears). Drives the disabled delete button + the
+// guard in deleteCurrentLayer. Effectively true for every shipped layer.
+const bundledExists = computed(() => sources.hasBundled(editName.value));
+/** Whether a given layer key has an unpublished local browser draft —
+ * drives the "local" badge in the picker (the sync badge only reflects
+ * bundled-vs-remote, not your local draft). */
+function hasLocalDraftFor(key: string): boolean {
+ return localEdits.has(layerEditName(key));
+}
+
+function bundledContent(): AdminLayerTemplate | undefined {
+ return (
+ sources.bundled<AdminLayerTemplate>(editName.value) ??
+ templates.value.find((t) => t.key === selectedKey.value)
+ );
+}
+function sourceContent(src: 'local' | 'bundled' | 'remote'): AdminLayerTemplate | null | undefined {
+ if (src === 'local') return localEdits.get<AdminLayerTemplate>(editName.value);
+ if (src === 'remote') return sources.remote<AdminLayerTemplate>(editName.value);
+ return bundledContent();
+}
+function loadFrom(src: 'local' | 'bundled' | 'remote'): void {
+ const c = sourceContent(src);
+ draft.template = c ? JSON.parse(JSON.stringify(c)) : null;
+ loadedSnapshot.value = draft.template ? JSON.stringify(draft.template) : '';
+ editorSource.value = src;
saveMsg.value = null;
}
+/** Seed the editor when the selected layer changes: your local draft if
+ * you have one, else the bundled default (always available; "Reset to →
+ * remote" pulls the live version). */
+function syncDraft(): void {
+ loadFrom(hasLocalDraft.value ? 'local' : 'bundled');
+}
+
+/** Write the editor content to the local draft. If it equals remote, the
+ * draft is cleared instead (no point keeping a draft identical to live) —
+ * this is what makes "Reset to remote" + an edit back to remote disable
+ * Push. Drives `hasLocalDraft` / `localDiffersFromRemote` reactively. */
+function persistLocal(content: AdminLayerTemplate): void {
+ const remote = sources.remote<AdminLayerTemplate>(editName.value);
+ if (remote && stableStringify(content) === stableStringify(remote)) {
+ localEdits.remove(editName.value);
+ } else {
+ localEdits.set(editName.value, JSON.parse(JSON.stringify(content)));
+ }
+ loadedSnapshot.value = JSON.stringify(content);
+}
+
+// "Reset to ▾" dropdown — reload the editor from a source AND adopt it as
+// the local draft (so the choice persists + Push/diff status updates).
+const resetDropdownOpen = ref(false);
+function resetTo(src: 'bundled' | 'remote'): void {
+ loadFrom(src);
+ if (draft.template) persistLocal(draft.template);
+ resetDropdownOpen.value = false;
+}
+
+/** Rail click: switch layer and collapse the rail so the editor reclaims
+ * the full width (subsequent switching uses the top dropdown). */
+function selectLayer(key: string): void {
+ selectedKey.value = key;
+ layerListOpen.value = false;
+}
+
+/** Collapsed-mode switcher dropdown. Reuses the same `layerSearch` +
+ * `filteredTemplates` as the rail so the two browse surfaces filter
+ * identically — no native <select>. */
+const layerDropdownOpen = ref(false);
+function pickFromDropdown(key: string): void {
+ selectedKey.value = key;
+ layerDropdownOpen.value = false;
+}
+
+/** First per-layer tab segment for the live route, derived from enabled
+ * components (mirrors the sidebar's firstLayerTab fallback order). */
+function firstTabFor(tpl: AdminLayerTemplate | null): string {
+ const c = tpl?.components ?? {};
+ if (c.service) return 'service';
+ if (c.instances) return 'instance';
+ if (c.endpoints) return 'endpoint';
+ if (c.topology) return 'topology';
+ if (c.endpointDependency) return 'dependency';
+ if (c.traces) return 'trace';
+ if (c.logs) return 'logs';
+ if (c.traceProfiling) return 'trace-profiling';
+ if (c.ebpfProfiling) return 'ebpf-profiling';
+ if (c.asyncProfiling) return 'async-profiling';
+ return 'service';
+}
+// "Preview ▾" dropdown — open the REAL layer page in a new tab rendering
+// the chosen source (local / bundled / remote). Writes the content to the
+// preview-override store so the new tab's overlay renders exactly it
+// (?mode=preview also lets LayerShell render layers OAP doesn't list).
+const previewDropdownOpen = ref(false);
+function previewLive(src: 'local' | 'bundled' | 'remote'): void {
+ const content = sourceContent(src);
+ if (!content) return;
+ previewOverride.set(editName.value, content);
+ const href = router.resolve({
+ path: `/layer/${selectedKey.value}/${firstTabFor(content)}`,
+ query: { mode: 'preview', source: src },
+ }).href;
+ previewDropdownOpen.value = false;
+ window.open(href, '_blank', 'noopener');
+}
watch(selectedKey, syncDraft);
onMounted(loadAll);
@@ -222,10 +354,10 @@ watch(visibleScopes, (scopes) => {
}
});
+// Unsaved keystrokes: editor differs from the content last loaded/saved.
const dirty = computed(() => {
- const original = templates.value.find((t) => t.key === selectedKey.value);
- if (!original || !draft.template) return false;
- return JSON.stringify(original) !== JSON.stringify(draft.template);
+ if (!draft.template) return false;
+ return JSON.stringify(draft.template) !== loadedSnapshot.value;
});
function widgetsFor(scope: AdminScope): DashboardWidget[] {
@@ -486,39 +618,49 @@ function textToExpressions(s: string): string[] {
.filter((x) => x.length > 0);
}
-// Diverged-row diff & reset: when OAP's copy of the selected layer
-// differs from bundled, opening the diff modal shows a side-by-side
-// and lets the operator reset OAP back to bundled (with
-// destructive-confirm — must type the layer key).
-const selectedStatus = computed(() =>
- selectedKey.value ? sync.badgeFor(`horizon.layer.${selectedKey.value}`) : null,
-);
-const isDiverged = computed(() => selectedStatus.value === 'diverged');
-const diffModalOpen = ref(false);
-function openDiffModal(): void { diffModalOpen.value = true; }
-function onDiffReset(): void {
- saveMsg.value = 'OAP reset to bundled. Reload to see widget changes.';
- setTimeout(() => (saveMsg.value = null), 2400);
+
+/** Save the current editor state to the browser local draft. This is the
+ * only "save" — it never writes OAP. Publish later with "Push local → OAP". */
+function save(): void {
+ if (!draft.template) return;
+ persistLocal(draft.template);
+ editorSource.value = 'local';
+ saveMsg.value = 'Saved to your browser (local). Publish with “Check diff & push”.';
+ setTimeout(() => (saveMsg.value = null), 6000);
}
-async function save(): Promise<void> {
- if (!draft.template || isSaving.value) return;
+// Push = publish local → remote. Available only when a local draft exists
+// AND differs from remote; the confirm modal shows the remote→local diff.
+const pushDiffOpen = ref(false);
+// Key-stable so a one-field edit doesn't read as "everything changed" —
+// OAP stores keys alphabetically; the local draft is in authored order.
+function prettyJson(o: unknown): string {
+ return o ? stableStringify(o, 2) : '';
+}
+const localDiffersFromRemote = computed<boolean>(() => {
+ const local = localEdits.get<AdminLayerTemplate>(editName.value);
+ if (!local) return false;
+ return stableStringify(local) !== stableStringify(sources.remote<AdminLayerTemplate>(editName.value) ?? null);
+});
+const pushLocalPretty = computed(() => prettyJson(localEdits.get(editName.value)));
+const pushRemotePretty = computed(() => prettyJson(sources.remote(editName.value)));
+
+/** Publish the local draft to OAP (live for everyone), then clear it and
+ * reload from remote. */
+async function pushToOap(): Promise<void> {
+ const local = localEdits.get<AdminLayerTemplate>(editName.value);
+ if (!local || isSaving.value) return;
isSaving.value = true;
saveMsg.value = null;
try {
- // Save writes the LOCAL bundled template (not OAP) — the edit
- // renders locally immediately and shows as diverged until it's
- // published to OAP via "Sync all to OAP". Works even when OAP is
- // unreachable.
- await bff.templateSync.saveLocal(`horizon.layer.${draft.template.key}`, draft.template);
- const idx = templates.value.findIndex((t) => t.key === selectedKey.value);
- if (idx >= 0) {
- templates.value[idx] = JSON.parse(JSON.stringify(draft.template));
- }
- syncDraft();
- await refreshConfigBundle(); // refresh diverged badges + sidebar warning
- saveMsg.value = 'Saved locally — not yet live for others. Use “Sync all to OAP” to publish this change.';
- setTimeout(() => (saveMsg.value = null), 7000);
+ await bff.templateSync.save(editName.value, local);
+ localEdits.remove(editName.value);
+ previewOverride.clear(editName.value); // drop the now-stale preview snapshot
+ await Promise.all([sources.refetch(), refreshConfigBundle()]);
+ loadFrom('remote');
+ pushDiffOpen.value = false;
+ saveMsg.value = 'Published your local draft to OAP — now live for everyone.';
+ setTimeout(() => (saveMsg.value = null), 6000);
} catch (err) {
saveMsg.value = err instanceof Error ? err.message : String(err);
} finally {
@@ -526,8 +668,105 @@ async function save(): Promise<void> {
}
}
-function reset(): void {
- syncDraft();
+// ── Disable / reactivate (OAP has no hard DELETE) ──────────────────
+// Disabling soft-disables the layer on OAP: a disabled template drops out
+// of the bundle AND the menu, so the layer disappears from the sidebar.
+// Reactivating re-pushes the bundled default to OAP, which clears the
+// disabled flag and the layer reappears. A purely local draft (no OAP
+// presence) is just removed from the browser.
+//
+// Styled confirm (replaces window.confirm — native box breaks the dark
+// design). One modal serves both actions via a stored `confirmFn`.
+const isLayerDisabled = computed(() => sync.badgeFor(editName.value) === 'disabled');
+const deleteOpen = ref(false);
+const confirmTitle = ref('');
+const confirmMessage = ref('');
+const confirmLabel = ref('');
+const confirmIsDanger = ref(true);
+let confirmFn: (() => void | Promise<void>) | null = null;
+function runConfirm(): void {
+ deleteOpen.value = false;
+ const fn = confirmFn;
+ confirmFn = null;
+ if (fn) void fn();
+}
+function askDeleteLayer(): void {
+ const key = selectedKey.value;
+ if (!key || !editName.value) return;
+ const onOap = remoteAvailable.value || bundledExists.value;
+ if (!onOap) {
+ confirmTitle.value = 'Remove local draft?';
+ confirmMessage.value = `Remove the local draft for “${key}”? It was never published — this clears it from this browser only.`;
+ confirmLabel.value = 'Remove draft';
+ } else if (bundledExists.value) {
+ confirmTitle.value = 'Disable built-in layer?';
+ confirmMessage.value = `Disable the built-in “${key}” layer? It's soft-disabled on OAP and disappears from the sidebar for everyone. You can bring it back later with Reactivate.`;
+ confirmLabel.value = 'Disable';
+ } else {
+ confirmTitle.value = 'Delete layer template?';
+ confirmMessage.value = `Delete the “${key}” layer template? OAP has no hard delete, so it's soft-disabled — hidden from everyone.`;
+ confirmLabel.value = 'Delete';
+ }
+ confirmIsDanger.value = true;
+ confirmFn = doDeleteLayer;
+ deleteOpen.value = true;
+}
+function askReactivateLayer(): void {
+ const key = selectedKey.value;
+ if (!key || !editName.value) return;
+ confirmTitle.value = 'Reactivate layer?';
+ confirmMessage.value = `Reactivate the “${key}” layer? This re-enables it on OAP from the bundled default — it reappears in the sidebar for everyone.`;
+ confirmLabel.value = 'Reactivate';
+ confirmIsDanger.value = false;
+ confirmFn = doReactivateLayer;
+ deleteOpen.value = true;
+}
+async function doDeleteLayer(): Promise<void> {
+ const key = selectedKey.value;
+ const name = editName.value;
+ if (!key || !name) return;
+ const onOap = remoteAvailable.value || bundledExists.value;
+ if (!onOap) {
+ localEdits.remove(name);
+ saveMsg.value = `Removed local draft for "${key}".`;
+ setTimeout(() => (saveMsg.value = null), 6000);
+ return;
+ }
+ isSaving.value = true;
+ saveMsg.value = null;
+ try {
+ await bff.templateSync.disable(name);
+ localEdits.remove(name);
+ await Promise.all([sources.refetch(), refreshConfigBundle()]);
+ saveMsg.value = `Disabled "${key}" on OAP.`;
+ setTimeout(() => (saveMsg.value = null), 6000);
+ } catch (err) {
+ saveMsg.value = err instanceof Error ? err.message : String(err);
+ } finally {
+ isSaving.value = false;
+ }
+}
+async function doReactivateLayer(): Promise<void> {
+ const key = selectedKey.value;
+ const name = editName.value;
+ if (!key || !name) return;
+ // Re-push the bundled default; the BFF update clears OAP's disabled flag.
+ const content = bundledContent();
+ if (!content) return;
+ isSaving.value = true;
+ saveMsg.value = null;
+ try {
+ await bff.templateSync.save(name, content);
+ localEdits.remove(name);
+ await Promise.all([sources.refetch(), refreshConfigBundle()]);
+ loadFrom('remote');
+ saveMsg.value = `Reactivated "${key}".`;
+ setTimeout(() => (saveMsg.value = null), 6000);
+ } catch (err) {
+ saveMsg.value = err instanceof Error ? err.message : String(err);
+ } finally {
+ isSaving.value = false;
+ }
}
const selectedTpl = computed(() => draft.template);
@@ -729,6 +968,55 @@ function deleteMetricColumn(i: number): void {
draft.template.metrics.columns.splice(i, 1);
}
+/** Sample rows for the service-list preview — three fake services with
+ * deterministic per-column values so the author sees how the configured
+ * columns render (label, scale, precision, unit, default-sort marker)
+ * without firing any MQE. */
+const SAMPLE_SERVICES = ['checkout', 'inventory', 'gateway'];
+function previewBase(seed: number): number {
+ return [42.37, 1280.5, 0.918][seed % 3] * (1 + (seed % 5) * 0.35);
+}
+function previewCell(col: { scale?: number; precision?: number; unit?: string }, seed: number): string {
+ const v = previewBase(seed) * (col.scale ?? 1);
+ const num = col.precision != null ? v.toFixed(col.precision) : fmtMetric(v);
+ return col.unit ? `${num} ${col.unit}` : num;
+}
+/** The metric the service list sorts by — explicit `orderBy`, else the
+ * first column (mirrors the renderer's fallback). */
+const effectiveOrderBy = computed(
+ () => metricsModel.value?.orderBy ?? metricsColumns.value[0]?.metric,
+);
+
+type MetricColumn = NonNullable<NonNullable<AdminLayerTemplate['metrics']>['columns']>[number];
+/** Headline column for the landing KPI tile: explicit `overview.throughput`,
+ * else the default-sort column. */
+const kpiHeadlineCol = computed<MetricColumn | undefined>(() => {
+ const key = overviewModel.value?.throughput ?? effectiveOrderBy.value;
+ return metricsColumns.value.find((c) => c.metric === key) ?? metricsColumns.value[0];
+});
+/** Trend column for the sparkline: explicit `overview.spark`, else the
+ * headline column. */
+const kpiSparkCol = computed<MetricColumn | undefined>(() => {
+ const key = overviewModel.value?.spark ?? overviewModel.value?.throughput ?? effectiveOrderBy.value;
+ return metricsColumns.value.find((c) => c.metric === key) ?? kpiHeadlineCol.value;
+});
+/** Headline number — fmtMetric of a mock aggregate for the headline
+ * column (the real tile shows the value bare; unit lives in the label). */
+const kpiHeadlineValue = computed(() =>
+ kpiHeadlineCol.value ? fmtMetric(previewBase(1) * (kpiHeadlineCol.value.scale ?? 1)) : '—',
+);
+/** Mock sparkline series for the real Sparkline component — deterministic
+ * per spark-column so the trend reads as real movement without MQE. */
+const kpiSparkValues = computed<number[]>(() => {
+ const seed = (kpiSparkCol.value?.metric ?? 'x').length;
+ const n = 24;
+ const a: number[] = [];
+ for (let i = 0; i < n; i++) a.push(14 + Math.sin(i * 0.7 + seed) * 7 + Math.sin(i * 0.23 + seed) * 4);
+ return a;
+});
+/** Two-letter mark for the header icon tile (mirrors the live header). */
+const previewInitials = computed(() => (selectedTpl.value?.key ?? '??').slice(0, 2).toUpperCase());
+
/**
* Scope-aware `visibleWhen` placeholder + hover hint. Two supported
* predicate forms:
@@ -800,6 +1088,105 @@ function toggleComponent(key: ComponentKey): void {
const c = ensureComponents();
c[key] = !c[key];
}
+
+/** Inverse of SCOPE_COMPONENT, narrowed to the toggle keys — maps each
+ * Components checkbox to the editor scope its config lives under, so a
+ * menu-preview click can jump straight to that scope's widget editor. */
+const COMPONENT_SCOPE: Record<ComponentKey, AdminScope> = {
+ service: 'service',
+ instances: 'instance',
+ endpoints: 'endpoint',
+ endpointDependency: 'dependency',
+ topology: 'topology',
+ traces: 'trace',
+ logs: 'logs',
+ traceProfiling: 'traceProfiling',
+ ebpfProfiling: 'ebpfProfiling',
+ asyncProfiling: 'asyncProfiling',
+ // Legacy umbrella flag — no checkbox of its own (the three granular
+ // profiling toggles drive the menu), so this entry only satisfies the
+ // exhaustive Record; it is never surfaced as a menu item.
+ profiling: 'traceProfiling',
+};
+/** Component → slot-alias key. The layer's `slots` carry per-layer term
+ * aliases (services → "ClickHouse clusters", instances → "Nodes", …);
+ * the menu preview shows these instead of the generic component label,
+ * matching what the real sidebar renders. */
+const COMPONENT_SLOT: Partial<Record<ComponentKey, keyof NonNullable<AdminLayerTemplate['slots']>>> = {
+ service: 'services',
+ instances: 'instances',
+ endpoints: 'endpoints',
+ endpointDependency: 'endpointDependency',
+};
+/** The layer's sidebar menu as the operator would see it — only the
+ * enabled components, in COMPONENT_TOGGLES order, labelled with the
+ * layer's slot aliases where defined. Drives the menu preview. */
+const menuItems = computed(() => {
+ const slots = draft.template?.slots ?? {};
+ return COMPONENT_TOGGLES.filter((t) => !!draft.template?.components?.[t.key]).map((t) => {
+ const slotKey = COMPONENT_SLOT[t.key];
+ return {
+ key: t.key,
+ label: (slotKey && slots[slotKey]) || t.label,
+ scope: COMPONENT_SCOPE[t.key],
+ };
+ });
+});
+/** Menu-preview click: focus the component's scope (if surfaced) and
+ * scroll the scope editor into view so config + preview follow the
+ * selection. */
+function jumpToComponent(scope: AdminScope): void {
+ if (visibleScopes.value.includes(scope)) activeScope.value = scope;
+ void nextTick(() => {
+ document.getElementById('scope-editor')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ });
+}
+
+type SlotKey = keyof NonNullable<AdminLayerTemplate['slots']>;
+/** Scope → slot-alias key, for the scopes that carry a configurable
+ * noun. Drives alias-aware scope-tab + section-heading labels so the
+ * admin reads in the layer's own vocabulary (e.g. "Nodes" not
+ * "instance"). */
+const SCOPE_SLOT: Partial<Record<AdminScope, SlotKey>> = {
+ service: 'services',
+ instance: 'instances',
+ endpoint: 'endpoints',
+ dependency: 'endpointDependency',
+};
+function scopeLabel(s: AdminScope): string {
+ const sk = SCOPE_SLOT[s];
+ return (sk && draft.template?.slots?.[sk]) || SCOPE_LABELS[s];
+}
+
+/** The four configurable slot aliases. Shown for the components the
+ * layer actually exposes so the editor mirrors the menu. */
+const ALIAS_FIELDS: Array<{ slot: SlotKey; label: string; comp: ComponentKey; def: string }> = [
+ { slot: 'services', label: 'Services', comp: 'service', def: 'Service' },
+ { slot: 'instances', label: 'Instances', comp: 'instances', def: 'Instance' },
+ { slot: 'endpoints', label: 'Endpoints', comp: 'endpoints', def: 'Endpoint' },
+ { slot: 'endpointDependency', label: 'API dependency', comp: 'endpointDependency', def: 'Dependency' },
+];
+const visibleAliasFields = computed(() =>
+ ALIAS_FIELDS.filter((f) => !!draft.template?.components?.[f.comp]),
+);
+function ensureSlots(): NonNullable<AdminLayerTemplate['slots']> {
+ if (!draft.template) throw new Error('no template selected');
+ if (!draft.template.slots) (draft.template as AdminLayerTemplate).slots = {};
+ return draft.template.slots;
+}
+/** Write a slot alias. `slots` is the canonical field the loader reads;
+ * mirror to the JSON's legacy `aliases` so the saved file stays
+ * internally consistent (the loader prefers `slots`, but keeping both
+ * in step avoids a confusing stale `aliases` block in the bundle). */
+function setSlot(slot: SlotKey, value: string): void {
+ const s = ensureSlots();
+ const v = value.trim();
+ if (v) s[slot] = v;
+ else delete s[slot];
+ const a = ((draft.template as { aliases?: Record<string, string> }).aliases ??= {});
+ if (v) a[slot] = v;
+ else delete a[slot];
+}
function setVisibility(v: 'public' | 'operate'): void {
if (!draft.template) return;
// `public` is the implicit default — drop the field rather than
@@ -922,116 +1309,243 @@ const namingTest = computed<NamingTestResult>(() => {
<SyncStatusBanner :banner="sync.banner.value" />
- <div class="top-actions">
- <label class="diverged-filter" :class="{ on: divergedOnly }" :title="divergedCount === 0 ? 'No layers differ from OAP' : `${divergedCount} layer(s) differ from OAP`">
- <input v-model="divergedOnly" type="checkbox" :disabled="divergedCount === 0" />
- Diverged only<span v-if="divergedCount" class="diverged-count">{{ divergedCount }}</span>
- </label>
- <SyncAllButton kind="layer" />
- </div>
-
<div v-if="error" class="banner err">{{ error }}</div>
<div v-if="isLoading" class="empty">Loading templates…</div>
<div v-else-if="templates.length === 0" class="empty">No layer templates loaded.</div>
<div v-else class="grid" :class="{ 'list-collapsed': !layerListOpen }">
- <aside class="sw-card layer-list" :class="{ collapsed: !layerListOpen }">
+ <!-- Browse rail. Shown only while expanded; selecting a layer
+ collapses it so the editor claims the full width. Once
+ collapsed, layer switching moves to the top dropdown inside
+ the detail pane (no left zone consumed). -->
+ <aside v-if="layerListOpen" class="sw-card layer-list">
<div class="list-head">
<button
class="list-toggle"
type="button"
- :title="layerListOpen ? 'Collapse the layers list' : 'Expand the layers list'"
- @click="layerListOpen = !layerListOpen"
+ title="Collapse the layers list"
+ @click="layerListOpen = false"
>
- <span class="caret" :class="{ open: layerListOpen }">›</span>
+ <span class="caret open">›</span>
</button>
- <h4 v-if="layerListOpen">Layers</h4>
- <span v-if="layerListOpen" class="sub">
+ <h4>Layers</h4>
+ <span class="sub">
{{ layerSearch.trim()
? `${filteredTemplates.length} / ${templates.length}`
: `${templates.length} template${templates.length === 1 ? '' : 's'}` }}
</span>
</div>
- <template v-if="layerListOpen">
- <div class="list-search">
- <input
- v-model="layerSearch"
- type="text"
- class="list-search-input"
- placeholder="Search layers…"
- autocomplete="off"
- spellcheck="false"
- />
- </div>
- <button
- v-for="t in filteredTemplates"
- :key="t.key"
- class="layer-row"
- :class="{ active: selectedKey === t.key }"
- @click="selectedKey = t.key"
- >
- <span class="dot" :style="{ background: t.color || 'var(--sw-fg-3)' }" />
- <span class="name">{{ t.alias || t.key }}</span>
- <TemplateStatusBadge :status="sync.badgeFor(`horizon.layer.${t.key}`)" />
- </button>
- <p v-if="filteredTemplates.length === 0" class="list-empty">
- {{ divergedOnly && !layerSearch.trim() ? 'No layers differ from OAP.' : `No layers match “${layerSearch}”.` }}
- </p>
- </template>
- <!-- Collapsed mode shows just colored dots for navigation; click
- a dot to switch layers without expanding. -->
- <template v-else>
- <button
- v-for="t in templates"
- :key="t.key"
- class="layer-row collapsed-row"
- :class="{ active: selectedKey === t.key }"
- :title="t.alias || t.key"
- @click="selectedKey = t.key"
- >
- <span class="dot" :style="{ background: t.color || 'var(--sw-fg-3)' }" />
- </button>
- </template>
+ <div class="list-search">
+ <input
+ v-model="layerSearch"
+ type="text"
+ class="list-search-input"
+ placeholder="Search layers…"
+ autocomplete="off"
+ spellcheck="false"
+ />
+ </div>
+ <div class="dd-filters">
+ <label class="diverged-filter" :class="{ on: divergedOnly }" :title="divergedCount === 0 ? 'No layers differ from OAP' : `${divergedCount} layer(s) differ from the OAP-stored copy`">
+ <input v-model="divergedOnly" type="checkbox" :disabled="divergedCount === 0" />
+ Diverged<span v-if="divergedCount" class="diverged-count">{{ divergedCount }}</span>
+ </label>
+ <label class="diverged-filter local" :class="{ on: localOnly }" :title="localCount === 0 ? 'No unpublished local drafts in this browser' : `${localCount} layer(s) with an unpublished local draft`">
+ <input v-model="localOnly" type="checkbox" :disabled="localCount === 0" />
+ Local<span v-if="localCount" class="diverged-count local">{{ localCount }}</span>
+ </label>
+ </div>
+ <button
+ v-for="t in filteredTemplates"
+ :key="t.key"
+ class="layer-row"
+ :class="{ active: selectedKey === t.key }"
+ @click="selectLayer(t.key)"
+ >
+ <span class="dot" :style="{ background: t.color || 'var(--sw-fg-3)' }" />
+ <span class="name">{{ t.alias || t.key }}</span>
+ <code v-if="t.alias && t.alias !== t.key" class="key-tag">{{ t.key }}</code>
+ <span v-if="hasLocalDraftFor(t.key)" class="local-badge" title="Unpublished local draft">local</span>
+ <TemplateStatusBadge :status="sync.badgeFor(`horizon.layer.${t.key}`)" />
+ </button>
+ <p v-if="filteredTemplates.length === 0" class="list-empty">
+ {{ divergedOnly && !layerSearch.trim() ? 'No layers differ from OAP.' : `No layers match “${layerSearch}”.` }}
+ </p>
</aside>
<main v-if="selectedTpl" class="detail">
+ <!-- Collapsed-rail layer switcher: a filterable dropdown at the
+ top of the editor so the left zone stays free. Same search +
+ filter as the rail (not a native select). -->
+ <div v-if="!layerListOpen" class="layer-switch-bar">
+ <div class="layer-dd">
+ <button
+ class="layer-dd-btn"
+ type="button"
+ @click="layerDropdownOpen = !layerDropdownOpen"
+ >
+ <span class="dot inline" :style="{ background: selectedTpl.color || 'var(--sw-fg-3)' }" />
+ <span class="layer-dd-name">{{ selectedTpl.alias || selectedTpl.key }}</span>
+ <code v-if="selectedTpl.alias && selectedTpl.alias !== selectedTpl.key" class="key-tag">{{ selectedTpl.key }}</code>
+ <span v-if="hasLocalDraftFor(selectedTpl.key)" class="local-badge" title="Unpublished local draft in this browser">local</span>
+ <TemplateStatusBadge :status="sync.badgeFor(`horizon.layer.${selectedTpl.key}`)" />
+ <span class="caret dd-caret" :class="{ open: layerDropdownOpen }">›</span>
+ </button>
+ <template v-if="layerDropdownOpen">
+ <div class="layer-dd-backdrop" @click="layerDropdownOpen = false" />
+ <div class="layer-dd-pop sw-card">
+ <div class="list-search">
+ <input
+ v-model="layerSearch"
+ type="text"
+ class="list-search-input"
+ placeholder="Search layers…"
+ autocomplete="off"
+ spellcheck="false"
+ />
+ </div>
+ <div class="dd-filters">
+ <label class="diverged-filter" :class="{ on: divergedOnly }" :title="divergedCount === 0 ? 'No layers differ from OAP' : `${divergedCount} layer(s) differ from the OAP-stored copy`">
+ <input v-model="divergedOnly" type="checkbox" :disabled="divergedCount === 0" />
+ Diverged<span v-if="divergedCount" class="diverged-count">{{ divergedCount }}</span>
+ </label>
+ <label class="diverged-filter local" :class="{ on: localOnly }" :title="localCount === 0 ? 'No unpublished local drafts in this browser' : `${localCount} layer(s) with an unpublished local draft`">
+ <input v-model="localOnly" type="checkbox" :disabled="localCount === 0" />
+ Local<span v-if="localCount" class="diverged-count local">{{ localCount }}</span>
+ </label>
+ </div>
+ <div class="layer-dd-list">
+ <button
+ v-for="t in filteredTemplates"
+ :key="t.key"
+ class="layer-row"
+ :class="{ active: selectedKey === t.key }"
+ @click="pickFromDropdown(t.key)"
+ >
+ <span class="dot" :style="{ background: t.color || 'var(--sw-fg-3)' }" />
+ <span class="name">{{ t.alias || t.key }}</span>
+ <code v-if="t.alias && t.alias !== t.key" class="key-tag">{{ t.key }}</code>
+ <span v-if="hasLocalDraftFor(t.key)" class="local-badge" title="Unpublished local draft">local</span>
+ <TemplateStatusBadge :status="sync.badgeFor(`horizon.layer.${t.key}`)" />
+ </button>
+ <p v-if="filteredTemplates.length === 0" class="list-empty">
+ {{ divergedOnly && !layerSearch.trim() ? 'No layers differ from OAP.' : `No layers match “${layerSearch}”.` }}
+ </p>
+ </div>
+ </div>
+ </template>
+ </div>
+ <span class="sub">{{ templates.length }} layer{{ templates.length === 1 ? '' : 's' }}</span>
+ </div>
<!-- Identity strip + save controls -->
<section class="sw-card identity-card">
<div class="identity-row">
<span class="dot inline" :style="{ background: selectedTpl.color || 'var(--sw-fg-3)' }" />
- <div>
+ <!-- Single-line identity: Layer: <name> <key> <local> <status>. -->
+ <div class="identity-title">
+ <span class="identity-label">Layer:</span>
<h2>{{ selectedTpl.alias || selectedTpl.key }}</h2>
- <div class="meta">
- <code>{{ selectedTpl.key }}</code>
- </div>
+ <code>{{ selectedTpl.key }}</code>
+ <!-- Same sync status the picker shows, so the editor and the
+ dropdown agree (e.g. both read DISABLED, not one BUNDLED). -->
+ <span v-if="hasLocalDraft" class="local-badge" title="Unpublished local draft in this browser">local</span>
+ <TemplateStatusBadge :status="sync.badgeFor(editName)" />
</div>
- <div class="actions">
- <span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
+ <!-- Disable / Reactivate. Sits by the title, away from the
+ save/push cluster. A disabled layer offers Reactivate
+ (re-enable on OAP); otherwise Disable/Delete. -->
+ <div class="identity-delete">
<button
+ v-if="isLayerDisabled"
class="sw-btn"
type="button"
- :disabled="!dirty || isSaving"
- @click="reset"
+ :disabled="isSaving || sync.readOnly.value"
+ :title="sync.readOnly.value
+ ? 'OAP unreachable — cannot reactivate'
+ : `Reactivate the ${selectedTpl.key} layer (re-enable on OAP)`"
+ @click="askReactivateLayer"
>
- Reset
+ Reactivate
</button>
<button
- v-if="isDiverged && !sync.readOnly.value"
+ v-else
+ class="sw-btn danger"
+ type="button"
+ :disabled="isSaving || (sync.readOnly.value && (remoteAvailable || bundledExists))"
+ :title="(sync.readOnly.value && (remoteAvailable || bundledExists))
+ ? 'OAP unreachable — cannot delete'
+ : bundledExists
+ ? `Disable the built-in ${selectedTpl.key} layer (hidden from the sidebar; Reactivate to bring it back)`
+ : remoteAvailable
+ ? `Delete the ${selectedTpl.key} layer template (soft-disabled on OAP)`
+ : `Remove the local draft for ${selectedTpl.key}`"
+ @click="askDeleteLayer"
+ >
+ {{ bundledExists ? 'Disable' : 'Delete' }}
+ </button>
+ </div>
+ <div class="actions">
+ <span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
+ <!-- Where the editor content was loaded from — distinct from
+ the sync-status chip by the title (prefixed "from "). -->
+ <span class="src-tag" :title="`Editing from: ${editorSource}`">from {{ editorSource }}</span>
+ <!-- Reset the editor to a source (discard current content). -->
+ <div class="reset-dd">
+ <button class="sw-btn" type="button" @click="resetDropdownOpen = !resetDropdownOpen">
+ Reset to <span class="caret" :class="{ open: resetDropdownOpen }">›</span>
+ </button>
+ <template v-if="resetDropdownOpen">
+ <div class="reset-dd-backdrop" @click="resetDropdownOpen = false" />
+ <div class="reset-dd-pop">
+ <button class="reset-dd-item" type="button" title="Discard current edits and reload the bundled (shipped) default." @click="resetTo('bundled')">
+ Bundled
+ </button>
+ <button
+ class="reset-dd-item"
+ type="button"
+ :disabled="!remoteAvailable"
+ :title="remoteAvailable ? 'Discard current edits and reload OAP\'s live version.' : 'OAP has no copy of this template yet.'"
+ @click="resetTo('remote')"
+ >
+ Remote
+ </button>
+ </div>
+ </template>
+ </div>
+ <!-- Preview the real page rendering a chosen source. -->
+ <div class="reset-dd">
+ <button class="sw-btn" type="button" @click="previewDropdownOpen = !previewDropdownOpen">
+ Preview <span class="caret" :class="{ open: previewDropdownOpen }">›</span>
+ </button>
+ <template v-if="previewDropdownOpen">
+ <div class="reset-dd-backdrop" @click="previewDropdownOpen = false" />
+ <div class="reset-dd-pop">
+ <button class="reset-dd-item" type="button" :disabled="!hasLocalDraft" title="Preview your unpublished local draft." @click="previewLive('local')">Local</button>
+ <button class="reset-dd-item" type="button" title="Preview the bundled (shipped) default." @click="previewLive('bundled')">Bundled</button>
+ <button class="reset-dd-item" type="button" :disabled="!remoteAvailable" title="Preview OAP's live version." @click="previewLive('remote')">Remote</button>
+ </div>
+ </template>
+ </div>
+ <!-- Publish local → remote. Enabled only when local differs
+ from remote; click shows the diff before pushing. -->
+ <button
+ v-if="!sync.readOnly.value"
class="sw-btn"
type="button"
- title="Show side-by-side diff vs OAP, and reset OAP back to bundled (with confirmation)."
- @click="openDiffModal"
+ :disabled="!localDiffersFromRemote || isSaving"
+ :title="localDiffersFromRemote ? 'Review the local → remote diff, then publish to OAP.' : 'No local changes to publish — local matches remote.'"
+ @click="pushDiffOpen = true"
>
- Show diff & reset
+ Check diff & push
</button>
<button
class="sw-btn is-primary"
type="button"
:disabled="!dirty || isSaving"
- title="Save this edit to the local bundled template. Publish it to OAP for everyone with “Sync all to OAP”."
+ title="Save the editor to your browser (local). Publish later with “Push local → OAP”."
@click="save"
>
- {{ isSaving ? 'Saving…' : 'Save locally' }}
+ {{ isSaving ? 'Saving…' : 'Save (local)' }}
</button>
</div>
</div>
@@ -1081,30 +1595,92 @@ const namingTest = computed<NamingTestResult>(() => {
</div>
</section>
- <!-- Components editor: which per-layer views exist. Each
- toggle controls a sidebar entry + a route + (where the
- component is service / instance / endpoint) the matching
- dashboards.<scope> widget set. -->
- <section class="sw-card components-card">
- <div class="card-head">
- <h4>Components</h4>
- <span class="sub">which sub-views this layer exposes</span>
- </div>
- <div class="comp-grid">
- <label
- v-for="t in COMPONENT_TOGGLES"
- :key="t.key"
- class="comp-toggle"
- :class="{ on: !!selectedTpl.components?.[t.key] }"
- :title="t.hint"
- >
- <input
- type="checkbox"
- :checked="!!selectedTpl.components?.[t.key]"
- @change="toggleComponent(t.key)"
- />
- <span class="comp-label">{{ t.label }}</span>
- </label>
+ <!-- Layer setup: left = live menu preview (alias header + the
+ enabled components, in checkbox order — clicking an item
+ jumps to that component's config + preview below). Right =
+ alias edit (before Components, per request) + the Components
+ toggles that drive which menu entries exist. -->
+ <section class="sw-card setup-card">
+ <div class="setup-grid">
+ <div class="menu-preview">
+ <div class="menu-preview-head">
+ <span class="dot inline" :style="{ background: selectedTpl.color || 'var(--sw-fg-3)' }" />
+ <span class="menu-preview-title">{{ selectedTpl.alias || selectedTpl.key }}</span>
+ <code v-if="selectedTpl.alias && selectedTpl.alias !== selectedTpl.key" class="key-tag">{{ selectedTpl.key }}</code>
+ </div>
+ <p v-if="menuItems.length === 0" class="menu-preview-empty">
+ No components enabled — toggle one on the right to populate the menu.
+ </p>
+ <button
+ v-for="m in menuItems"
+ :key="m.key"
+ type="button"
+ class="menu-item"
+ :class="{ on: activeScope === m.scope }"
+ :title="`Jump to ${m.label} config`"
+ @click="jumpToComponent(m.scope)"
+ >
+ <span class="menu-item-label">{{ m.label }}</span>
+ <span class="menu-item-arrow">›</span>
+ </button>
+ </div>
+ <div class="setup-right">
+ <label class="alias-field">
+ <span>Alias</span>
+ <input
+ v-model="selectedTpl.alias"
+ type="text"
+ class="alias-input"
+ :placeholder="selectedTpl.key"
+ spellcheck="false"
+ />
+ <span class="alias-hint">Display name in the sidebar, layer list, and landing KPI tile. Defaults to the layer key.</span>
+ </label>
+ <div class="setup-section-head">
+ <h4>Components</h4>
+ <span class="sub">which sub-views this layer exposes</span>
+ </div>
+ <div class="comp-grid">
+ <label
+ v-for="t in COMPONENT_TOGGLES"
+ :key="t.key"
+ class="comp-toggle"
+ :class="{ on: !!selectedTpl.components?.[t.key] }"
+ :title="t.hint"
+ >
+ <input
+ type="checkbox"
+ :checked="!!selectedTpl.components?.[t.key]"
+ @change="toggleComponent(t.key)"
+ />
+ <span class="comp-label">{{ t.label }}</span>
+ </label>
+ </div>
+ <!-- Menu labels (slot aliases): rename the per-component
+ nouns the way the layer's own vocabulary reads (e.g.
+ services → "ClickHouse clusters", instances → "Nodes").
+ Shown only for enabled components; drives the menu
+ preview, scope tabs, and section headings live. -->
+ <template v-if="visibleAliasFields.length > 0">
+ <div class="setup-section-head">
+ <h4>Menu labels</h4>
+ <span class="sub">rename the nouns shown in the menu & tabs (optional)</span>
+ </div>
+ <div class="alias-grid">
+ <label v-for="f in visibleAliasFields" :key="f.slot" class="alias-grid-field">
+ <span>{{ f.label }}</span>
+ <input
+ type="text"
+ class="alias-input sm"
+ :value="selectedTpl.slots?.[f.slot] ?? ''"
+ :placeholder="f.def"
+ spellcheck="false"
+ @input="setSlot(f.slot, ($event.target as HTMLInputElement).value)"
+ />
+ </label>
+ </div>
+ </template>
+ </div>
</div>
</section>
@@ -1118,7 +1694,11 @@ const namingTest = computed<NamingTestResult>(() => {
Live tester at the bottom evaluates the current draft
against a sample name so the operator can see the result
before saving. -->
- <section id="topology-cluster" class="sw-card components-card">
+ <section
+ v-if="selectedTpl.components?.topology"
+ id="topology-cluster"
+ class="sw-card components-card"
+ >
<div class="card-head">
<h4>Topology cluster setup</h4>
<span class="sub">parse service name → display label + cluster dimension (k8s/mesh namespace, tenant, fleet, …)</span>
@@ -1278,41 +1858,84 @@ const namingTest = computed<NamingTestResult>(() => {
<td><input type="number" step="any" v-model.number="c.scale" placeholder="1" /></td>
<td><input type="number" min="0" max="6" v-model.number="c.precision" placeholder="auto" /></td>
<td>
- <button class="sw-btn danger" type="button" @click="deleteMetricColumn(i)">✕</button>
+ <button class="sw-btn is-icon danger" type="button" title="Remove column" @click="deleteMetricColumn(i)">✕</button>
</td>
</tr>
</tbody>
</table>
- </section>
-
- <!-- Overview-tile settings: the per-layer compact tile on the
- Overview's top strip. Only these two settings live here;
- they reference metric keys from the service-list columns. -->
- <section v-if="overviewModel" class="sw-card overview-card">
- <div class="card-head">
- <h4>Overview tile</h4>
- <span class="sub">per-layer compact tile on the Overview's top strip</span>
- </div>
- <div class="metrics-keys">
- <label>
- <span>Headline (throughput)</span>
- <select v-model="overviewModel.throughput">
- <option :value="undefined">(orderBy)</option>
- <option v-for="c in metricsColumns" :key="c.metric" :value="c.metric">{{ c.metric }}</option>
- </select>
- </label>
- <label>
- <span>Trend line (spark)</span>
- <select v-model="overviewModel.spark">
- <option :value="undefined">(throughput)</option>
- <option v-for="c in metricsColumns" :key="c.metric" :value="c.metric">{{ c.metric }}</option>
- </select>
- </label>
+ <!-- Preview: the per-layer landing KPI tile (headline + trend)
+ at the head — its config picks WHICH service-list column
+ feeds the tile, no new metrics — then the service-list
+ sample table. Mock values, no MQE fired. -->
+ <div v-if="metricsColumns.length > 0" class="metrics-preview">
+ <div class="metrics-preview-head">
+ Preview <span class="sub">how this layer’s landing KPI tile + service list render (sample data)</span>
+ </div>
+ <div v-if="overviewModel" class="kpi-block">
+ <div class="kpi-config">
+ <div class="kpi-config-title">Landing KPI tile <span class="sub">which column is the headline + trend</span></div>
+ <label>
+ <span>Headline (throughput)</span>
+ <select v-model="overviewModel.throughput">
+ <option :value="undefined">(default sort)</option>
+ <option v-for="c in metricsColumns" :key="c.metric" :value="c.metric">{{ c.label || c.metric }}</option>
+ </select>
+ </label>
+ <label>
+ <span>Trend line (spark)</span>
+ <select v-model="overviewModel.spark">
+ <option :value="undefined">(headline)</option>
+ <option v-for="c in metricsColumns" :key="c.metric" :value="c.metric">{{ c.label || c.metric }}</option>
+ </select>
+ </label>
+ </div>
+ <!-- Faithful copy of the real layer header (LayerShell):
+ icon tile + identity + the kpi-strip. Reuses the same
+ class names + the Sparkline component so the preview
+ matches the live page. -->
+ <header class="sw-card preview-layer-head">
+ <div class="layer-id-row">
+ <div class="icon-tile" :style="{ background: selectedTpl.color || 'var(--sw-fg-3)' }">{{ previewInitials }}</div>
+ <div class="identity-text">
+ <div class="title-row"><h1>{{ selectedTpl.alias || selectedTpl.key }}</h1></div>
+ <div class="sub">{{ SAMPLE_SERVICES.length }} {{ (selectedTpl.slots?.services || 'services').toLowerCase() }}</div>
+ </div>
+ <div class="kpi-strip">
+ <div class="kpi">
+ <div class="kpi-label">
+ {{ kpiHeadlineCol?.label || kpiHeadlineCol?.metric || '—' }}<span v-if="kpiHeadlineCol?.unit" class="unit">({{ kpiHeadlineCol.unit }})</span>
+ </div>
+ <div class="kpi-value">{{ kpiHeadlineValue }}</div>
+ <Sparkline class="kpi-spark" :values="kpiSparkValues" :width="84" :height="18" color="var(--sw-accent)" :stroke="1.25" />
+ </div>
+ </div>
+ </div>
+ </header>
+ </div>
+ <div class="metrics-preview-scroll">
+ <table class="sw-table preview-table">
+ <thead>
+ <tr>
+ <th>{{ scopeLabel('service') }}</th>
+ <th v-for="c in metricsColumns" :key="c.metric" class="num">
+ {{ c.label || c.metric }}
+ <span v-if="effectiveOrderBy === c.metric" class="sort-ind" title="default sort">↓</span>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="(svc, r) in SAMPLE_SERVICES" :key="svc">
+ <td class="svc">{{ svc }}</td>
+ <td v-for="(c, ci) in metricsColumns" :key="c.metric" class="num">{{ previewCell(c, r + ci) }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
</div>
</section>
<!-- Scope tabs -->
- <nav class="scope-tabs sw-card">
+ <nav id="scope-editor" class="scope-tabs sw-card">
<button
v-for="s in visibleScopes"
:key="s"
@@ -1321,7 +1944,7 @@ const namingTest = computed<NamingTestResult>(() => {
type="button"
@click="activeScope = s"
>
- {{ SCOPE_LABELS[s] }}
+ {{ scopeLabel(s) }}
<span class="count">{{ widgetsFor(s).length }}</span>
</button>
</nav>
@@ -1690,7 +2313,7 @@ const namingTest = computed<NamingTestResult>(() => {
class="sw-card editor-card topo-cfg-card"
>
<div class="card-head">
- <h4>{{ SCOPE_LABELS[activeScope] }} tab</h4>
+ <h4>{{ scopeLabel(activeScope) }} tab</h4>
<span class="sub">
{{ activeScope === 'trace'
? 'Pick the trace backend this layer reads from.'
@@ -1720,7 +2343,7 @@ const namingTest = computed<NamingTestResult>(() => {
</div>
</div>
<p v-else class="topo-cfg-help">
- The <b>{{ SCOPE_LABELS[activeScope] }}</b> tab is a built-in view that uses
+ The <b>{{ scopeLabel(activeScope) }}</b> tab is a built-in view that uses
SkyWalking-native query-protocol APIs directly. Operators configure filters
and time range at runtime from the page itself; nothing to wire up here.
</p>
@@ -1729,7 +2352,7 @@ const namingTest = computed<NamingTestResult>(() => {
<section v-else class="sw-card editor-card">
<div class="card-head">
- <h4>{{ SCOPE_LABELS[activeScope] }} widgets</h4>
+ <h4>{{ scopeLabel(activeScope) }} widgets</h4>
<span class="sub">
click a widget to edit · drag corner to resize · drag header to reorder
</span>
@@ -1847,7 +2470,7 @@ const namingTest = computed<NamingTestResult>(() => {
<aside v-if="selectedWidget" class="drawer">
<div class="drawer-head">
<h4>Edit widget</h4>
- <span class="sub">{{ SCOPE_LABELS[activeScope] }} · #{{ (selectedIdx ?? 0) + 1 }}</span>
+ <span class="sub">{{ scopeLabel(activeScope) }} · #{{ (selectedIdx ?? 0) + 1 }}</span>
<button class="sw-btn ghost close" type="button" title="Close" @click="selectedIdx = null">✕</button>
</div>
<div class="drawer-body">
@@ -1984,14 +2607,33 @@ const namingTest = computed<NamingTestResult>(() => {
</main>
</div>
- <TemplateDiffModal
- v-if="selectedKey"
- :open="diffModalOpen"
- :name="`horizon.layer.${selectedKey}`"
- :confirm-key="selectedKey"
- @close="diffModalOpen = false"
- @reset="onDiffReset"
- />
+ <!-- Push confirm: shows the remote → local diff before publishing. -->
+ <Modal :open="pushDiffOpen" title="Publish local → OAP?" width="min(1100px, 94vw)" @close="pushDiffOpen = false">
+ <p class="push-lede">
+ This replaces the live (remote) version with your local draft — live for everyone. Review the
+ diff (left = remote, right = your local):
+ </p>
+ <div class="push-diff">
+ <MonacoDiff :original="pushRemotePretty" :modified="pushLocalPretty" language="json" />
+ </div>
+ <template #footer>
+ <button class="sw-btn" type="button" @click="pushDiffOpen = false">Cancel</button>
+ <button class="sw-btn is-primary" type="button" :disabled="isSaving" @click="pushToOap">
+ {{ isSaving ? 'Pushing…' : 'Confirm push' }}
+ </button>
+ </template>
+ </Modal>
+
+ <!-- Disable / delete / reactivate confirm — styled (not native). -->
+ <Modal :open="deleteOpen" :title="confirmTitle" width="min(520px, 92vw)" @close="deleteOpen = false">
+ <p class="confirm-msg">{{ confirmMessage }}</p>
+ <template #footer>
+ <button class="sw-btn" type="button" @click="deleteOpen = false">Cancel</button>
+ <button class="sw-btn" :class="{ danger: confirmIsDanger, 'is-primary': !confirmIsDanger }" type="button" :disabled="isSaving" @click="runConfirm">
+ {{ confirmLabel }}
+ </button>
+ </template>
+ </Modal>
</div>
</template>
@@ -2050,7 +2692,7 @@ const namingTest = computed<NamingTestResult>(() => {
transition: grid-template-columns 160ms ease;
}
.grid.list-collapsed {
- grid-template-columns: 36px 1fr;
+ grid-template-columns: 1fr;
}
.layer-list {
padding: 8px;
@@ -2061,9 +2703,6 @@ const namingTest = computed<NamingTestResult>(() => {
position: sticky;
top: 16px;
}
-.layer-list.collapsed {
- padding: 6px 4px;
-}
.list-toggle {
flex: 0 0 auto;
width: 22px;
@@ -2091,21 +2730,83 @@ const namingTest = computed<NamingTestResult>(() => {
.list-toggle .caret.open {
transform: rotate(90deg);
}
-.collapsed-row {
- justify-content: center;
- padding: 6px 4px;
+/* Collapsed-rail layer switcher — a compact dropdown bar at the top of
+ the editor pane so layer switching costs no left-zone width. */
+.layer-switch-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
}
-.collapsed-row .dot {
- width: 10px;
- height: 10px;
+.layer-switch-bar .list-toggle {
+ margin-right: 0;
}
-.layer-list.collapsed .list-head {
- border-bottom: 1px solid var(--sw-line);
- padding: 4px 0 6px;
- justify-content: center;
+.layer-switch-bar .caret {
+ display: inline-block;
+ font-size: 13px;
+ line-height: 1;
}
-.layer-list.collapsed .list-toggle {
- margin-right: 0;
+/* Filterable layer dropdown — same search + rows as the pinned rail.
+ The sole layer switcher (no separate expand toggle). */
+.layer-dd { position: relative; display: flex; }
+.layer-dd-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ height: 30px;
+ padding: 0 10px;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 6px;
+ color: var(--sw-fg-0);
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ min-width: 200px;
+ cursor: pointer;
+}
+.layer-dd-btn:hover { background: var(--sw-bg-3); }
+.layer-dd-name {
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.dd-caret {
+ margin-left: auto;
+ transform: rotate(90deg);
+ transition: transform 0.15s;
+}
+.dd-caret.open { transform: rotate(-90deg); }
+.layer-dd-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+}
+.layer-dd-pop {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ z-index: 41;
+ /* Wide enough to read alias + key + badge on one line per row. */
+ width: min(80vw, 760px);
+ max-height: 360px;
+ display: flex;
+ flex-direction: column;
+ padding: 8px 0;
+ box-shadow: 0 12px 32px -8px rgba(0, 0, 0, 0.5);
+}
+.layer-dd-pop .list-search { padding: 0 10px 8px; }
+.layer-dd-list {
+ overflow-y: auto;
+ padding: 0 6px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.layer-switch-bar .sub {
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+ margin-left: auto;
}
.list-head {
display: flex;
@@ -2150,14 +2851,18 @@ const namingTest = computed<NamingTestResult>(() => {
gap: 8px;
padding: 0 10px 8px;
}
-/* Page-level display/sync controls, directly under the sync banner. */
-.top-actions {
+/* Diverged / Local filters, co-located with the search inside the layer
+ picker (rail + dropdown), same as the overview dashboards admin —
+ keeps filter + selector + count read as one connected control instead
+ of floating in a separate page-level row. */
+.dd-filters {
display: flex;
- align-items: center;
- gap: 10px;
- margin: 8px 0 4px;
+ gap: 14px;
+ padding: 0 10px 8px;
+ margin-bottom: 6px;
+ border-bottom: 1px solid var(--sw-line);
}
-.top-actions .diverged-filter { margin-right: auto; }
+.layer-dd-pop .dd-filters { padding: 0 10px 8px; }
.diverged-filter {
display: inline-flex;
align-items: center;
@@ -2238,16 +2943,25 @@ const namingTest = computed<NamingTestResult>(() => {
font-weight: 600;
color: var(--sw-fg-0);
}
-.identity-row .meta {
+/* Single-line identity strip: label + name + key + badges all inline. */
+.identity-title {
display: flex;
align-items: center;
- gap: 6px;
- flex-wrap: wrap;
- margin-top: 4px;
- font-size: 10.5px;
+ gap: 8px;
+ flex-wrap: nowrap;
+ min-width: 0;
+}
+.identity-label {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sw-fg-3);
+ font-weight: 600;
}
-.identity-row .meta code {
+.identity-title h2 { white-space: nowrap; }
+.identity-title code {
font-family: var(--sw-mono);
+ font-size: 10.5px;
background: var(--sw-bg-2);
padding: 1px 6px;
border-radius: 3px;
@@ -2306,11 +3020,59 @@ const namingTest = computed<NamingTestResult>(() => {
gap: 8px;
}
.actions .sw-btn { font-size: 11.5px; }
+.src-tag {
+ font-size: 9.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--sw-fg-3);
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ padding: 2px 7px;
+}
+/* "Reset to ▾" merged dropdown (Bundled / Remote sources). */
+.reset-dd { position: relative; display: inline-flex; }
+.reset-dd .caret { display: inline-block; transform: rotate(90deg); font-size: 11px; }
+.reset-dd .caret.open { transform: rotate(-90deg); }
+.reset-dd-backdrop { position: fixed; inset: 0; z-index: 40; }
+.reset-dd-pop {
+ position: absolute;
+ top: calc(100% + 4px);
+ right: 0;
+ z-index: 41;
+ min-width: 130px;
+ padding: 4px;
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 6px;
+ box-shadow: 0 10px 28px -8px rgba(0, 0, 0, 0.5);
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.reset-dd-item {
+ text-align: left;
+ padding: 6px 10px;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ color: var(--sw-fg-1);
+ font: inherit;
+ font-size: 12px;
+ cursor: pointer;
+}
+.reset-dd-item:hover:not(:disabled) { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.reset-dd-item:disabled { opacity: 0.4; cursor: not-allowed; }
+.push-lede { margin: 0 0 10px; font-size: 12px; color: var(--sw-fg-2); line-height: 1.5; }
+.confirm-msg { margin: 0; font-size: 13px; line-height: 1.55; color: var(--sw-fg-1); }
+.push-diff { height: 50vh; min-height: 320px; border: 1px solid var(--sw-line); border-radius: 6px; overflow: hidden; }
.actions .sw-btn[disabled] { opacity: 0.4; pointer-events: none; }
.save-msg {
font-size: 11px;
color: var(--sw-ok);
}
+.identity-delete { display: flex; align-items: center; gap: 8px; }
+.del-note { font-size: 10.5px; color: var(--sw-fg-3); font-style: italic; }
.scope-tabs {
display: flex;
@@ -2347,6 +3109,140 @@ const namingTest = computed<NamingTestResult>(() => {
}
.scope-tab.on .count { color: var(--sw-accent-2); }
+/* Layer setup card — menu preview (left) + alias/components (right). */
+.setup-card { padding: 0; }
+.setup-grid {
+ display: grid;
+ grid-template-columns: 240px 1fr;
+ align-items: stretch;
+}
+.menu-preview {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 12px 12px 14px;
+ border-right: 1px solid var(--sw-line);
+ background: var(--sw-bg-1);
+ border-radius: var(--sw-radius, 8px) 0 0 var(--sw-radius, 8px);
+}
+.menu-preview-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 8px 10px;
+ margin-bottom: 4px;
+ border-bottom: 1px solid var(--sw-line);
+}
+.menu-preview-title {
+ font-size: 12.5px;
+ font-weight: 600;
+ color: var(--sw-fg-0);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.menu-preview-empty {
+ margin: 4px 8px;
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+ line-height: 1.5;
+}
+/* Amber "local" chip — surfaces an unpublished local browser draft in the
+ * picker (the sync badge only reflects bundled-vs-remote). */
+.local-badge {
+ flex: 0 0 auto;
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #1a1a1a;
+ background: var(--sw-warn, #f59e0b);
+ padding: 1px 6px;
+ border-radius: 8px;
+}
+/* Dim mono chip for the raw layer key, shown next to the alias so the
+ * operator sees both the display name and the OAP layer identity. */
+.key-tag {
+ flex: 0 0 auto;
+ font-family: var(--sw-mono);
+ font-size: 9.5px;
+ letter-spacing: 0.02em;
+ color: var(--sw-fg-3);
+ background: var(--sw-bg-2);
+ padding: 1px 5px;
+ border-radius: 3px;
+}
+.menu-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 10px;
+ border-radius: 5px;
+ background: transparent;
+ border: none;
+ color: var(--sw-fg-1);
+ font: inherit;
+ font-size: 12px;
+ cursor: pointer;
+ text-align: left;
+}
+.menu-item:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.menu-item.on {
+ background: var(--sw-accent-soft);
+ color: var(--sw-accent-2);
+ font-weight: 600;
+ box-shadow: inset 2px 0 0 var(--sw-accent);
+}
+.menu-item-label { flex: 1; }
+.menu-item-arrow { color: var(--sw-fg-3); font-size: 13px; }
+.menu-item.on .menu-item-arrow { color: var(--sw-accent-2); }
+.setup-right {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 12px 14px 14px;
+ min-width: 0;
+}
+.alias-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.alias-field > span:first-child {
+ font-size: 10.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sw-fg-3);
+}
+.alias-input {
+ height: 30px;
+ padding: 0 10px;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 5px;
+ color: var(--sw-fg-0);
+ font: inherit;
+ font-size: 13px;
+ max-width: 320px;
+}
+.alias-input:focus { outline: none; border-color: var(--sw-accent); }
+.alias-hint { font-size: 10.5px; color: var(--sw-fg-3); line-height: 1.4; }
+.setup-section-head {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ padding-top: 4px;
+ border-top: 1px dashed var(--sw-line);
+}
+.setup-section-head h4 {
+ margin: 0;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--sw-fg-0);
+}
+.setup-section-head .sub { font-size: 10.5px; color: var(--sw-fg-3); }
+.setup-right .comp-grid { padding: 0; }
+
.components-card { padding: 0; }
.components-card .card-head {
display: flex;
@@ -2618,6 +3514,191 @@ const namingTest = computed<NamingTestResult>(() => {
color: #f87171;
}
+/* Sample-data preview of the service list. */
+.metrics-preview {
+ border-top: 1px solid var(--sw-line);
+ padding: 12px 10px 6px;
+}
+.metrics-preview-head {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sw-fg-3);
+ padding: 0 4px 8px;
+}
+.metrics-preview-head .sub {
+ margin-left: 8px;
+ font-weight: 500;
+ text-transform: none;
+ letter-spacing: 0;
+ color: var(--sw-fg-3);
+}
+.metrics-preview-scroll { overflow-x: auto; }
+.preview-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 11.5px;
+}
+.preview-table th {
+ text-align: left;
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--sw-fg-2);
+ padding: 6px 10px;
+ border-bottom: 1px solid var(--sw-line);
+ white-space: nowrap;
+}
+.preview-table th.num,
+.preview-table td.num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+.preview-table td {
+ padding: 6px 10px;
+ border-bottom: 1px solid var(--sw-line);
+ color: var(--sw-fg-1);
+}
+.preview-table td.svc {
+ color: var(--sw-fg-0);
+ font-weight: 500;
+}
+.preview-table tbody tr:last-child td { border-bottom: none; }
+.sort-ind {
+ margin-left: 3px;
+ color: var(--sw-accent-2);
+ font-size: 10px;
+}
+
+/* Landing KPI tile preview: config head + a faithful copy of the real
+ * layer header. The header classes below mirror LayerShell so the
+ * preview matches the live page (kept in sync intentionally). */
+.kpi-block {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 4px 4px 14px;
+}
+.kpi-config {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 10px;
+}
+.kpi-config-title {
+ flex: 1 0 100%;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--sw-fg-1);
+}
+.kpi-config-title .sub {
+ margin-left: 6px;
+ font-weight: 400;
+ font-size: 10px;
+ color: var(--sw-fg-3);
+}
+.kpi-config label {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--sw-fg-3);
+}
+.kpi-config select {
+ height: 28px;
+ padding: 0 8px;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ color: var(--sw-fg-0);
+ font: inherit;
+ font-size: 11.5px;
+}
+.kpi-config select:focus { outline: none; border-color: var(--sw-accent); }
+/* ↓ mirrors LayerShell.vue .layer-head / .layer-id-row / .kpi-strip. */
+.preview-layer-head { padding: 14px; }
+.preview-layer-head .layer-id-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+.preview-layer-head .icon-tile {
+ width: 40px;
+ height: 40px;
+ border-radius: 10px;
+ display: grid;
+ place-items: center;
+ color: #fff;
+ font-weight: 700;
+ font-size: 14px;
+ letter-spacing: -0.02em;
+ flex: 0 0 40px;
+ background-blend-mode: multiply;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
+}
+.preview-layer-head .identity-text { min-width: 0; }
+.preview-layer-head .title-row h1 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--sw-fg-0);
+ letter-spacing: -0.02em;
+}
+.preview-layer-head .sub {
+ margin-top: 4px;
+ font-size: 11.5px;
+ color: var(--sw-fg-3);
+}
+.preview-layer-head .kpi-strip {
+ display: flex;
+ gap: 22px;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ margin-left: auto;
+}
+.preview-layer-head .kpi { text-align: right; min-width: 80px; }
+.preview-layer-head .kpi-label {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sw-fg-3);
+ margin-bottom: 2px;
+}
+.preview-layer-head .kpi-label .unit {
+ text-transform: none;
+ letter-spacing: 0;
+ margin-left: 2px;
+ font-size: 9.5px;
+}
+.preview-layer-head .kpi-value {
+ font-size: 18px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.02em;
+ color: var(--sw-fg-1);
+}
+.preview-layer-head .kpi-spark { display: block; margin-top: 4px; margin-left: auto; }
+
+/* Menu-label (slot alias) editor grid. */
+.alias-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 8px;
+}
+.alias-grid-field {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--sw-fg-3);
+}
+.alias-input.sm { height: 28px; font-size: 12px; max-width: none; }
+
.editor-card { padding: 0; }
/* Topology / endpoint-dep config preview — read-only JSON view. */
.topo-cfg-body { padding: 12px 14px 16px; }
diff --git a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
index d2ecb21..5a4d55a 100644
--- a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
+++ b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
@@ -36,7 +36,8 @@
decisions, not config tweaks.
-->
<script setup lang="ts">
-import { computed, ref, watch, watchEffect } from 'vue';
+import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, watchEffect } from 'vue';
+import { useRouter } from 'vue-router';
import { useQuery } from '@tanstack/vue-query';
import type {
OverviewDashboard,
@@ -44,13 +45,23 @@ import type {
OverviewWidget,
} from '@skywalking-horizon-ui/api-client';
import { bff } from '@/api/client';
+import type { OverviewTemplateSummary } from '@/api/scopes/overview';
+import { useLocalTemplateEdits, overviewEditName } from '@/controls/localTemplateEdits';
+import { usePreviewOverride } from '@/controls/previewOverride';
+import { useTemplateSources } from '@/features/admin/_shared/useTemplateSources';
import { useLayers } from '@/shell/useLayers';
import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue';
-import SyncAllButton from '@/features/admin/_shared/SyncAllButton.vue';
import { refreshConfigBundle } from '@/controls/configBundle';
+import { stableStringify } from '@/utils/stableJson';
import TemplateStatusBadge from '@/features/admin/_shared/TemplateStatusBadge.vue';
-import TemplateDiffModal from '@/features/admin/_shared/TemplateDiffModal.vue';
import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
+// Real overview widget primitives — the preview renders these with mock
+// data so it matches the live page pixel-for-pixel (no bespoke copy).
+import KpiTileWidget from '@/render/widgets/KpiTileWidget.vue';
+import MetricWidget from '@/render/widgets/MetricWidget.vue';
+import MetricCompositeWidget from '@/render/widgets/MetricCompositeWidget.vue';
+import Modal from '@/features/operate/_shared/Modal.vue';
+import MonacoDiff from '@/features/operate/_shared/MonacoDiff.vue';
// OAP UI-template sync status for the Overview kind. Drives the
// page-level banner + read-only mode + per-row badge lookup.
@@ -61,9 +72,115 @@ const listQuery = useQuery({
queryFn: () => bff.overview.adminList(),
staleTime: 60_000,
});
-const dashboards = computed(() => listQuery.data.value?.dashboards ?? []);
+
+// Template sources + local-draft store. Declared here (not lower with the
+// editor-source helpers) because the picker computeds below — read
+// synchronously by the auto-select watchEffect during setup — depend on
+// them; declaring them later would hit a temporal-dead-zone ReferenceError.
+const router = useRouter();
+const localEdits = useLocalTemplateEdits();
+const previewOverride = usePreviewOverride();
+const sources = useTemplateSources('overview');
+
+const OV_PREFIX = 'horizon.overview.';
+function summaryFrom(
+ id: string,
+ content: OverviewDashboard | null | undefined,
+): OverviewTemplateSummary | null {
+ if (!content) return null;
+ return {
+ id,
+ title: content.title || id,
+ ...(content.description ? { description: content.description } : {}),
+ widgetCount: content.widgets?.length ?? 0,
+ editable: true,
+ };
+}
+
+// On-disk dashboards (bundled + overlaid remote), from the BFF.
+const serverDashboards = computed(() => listQuery.data.value?.dashboards ?? []);
+
+// Remote-only: dashboards that live on OAP with no on-disk base — created
+// in this admin and pushed. The server list (disk) can't see them, so
+// surface them from the OAP sync rows.
+const remoteOnlyDashboards = computed<OverviewTemplateSummary[]>(() => {
+ const serverIds = new Set(serverDashboards.value.map((d) => d.id));
+ const out: OverviewTemplateSummary[] = [];
+ for (const name of sources.remoteNames()) {
+ if (!name.startsWith(OV_PREFIX)) continue;
+ const id = name.slice(OV_PREFIX.length);
+ if (serverIds.has(id)) continue;
+ const s = summaryFrom(id, sources.remote<OverviewDashboard>(name));
+ if (s) out.push(s);
+ }
+ return out;
+});
+
+// Local-only drafts: dashboards created in THIS browser that aren't on the
+// server or OAP yet. They surface in the picker like any dashboard —
+// flagged `local` — so create mirrors edit: draft locally, preview, then
+// "Check diff & push" publishes them. Once pushed they become remote-only
+// (then server-backed once OAP serves them) and drop out of this list.
+const localOnlyDrafts = computed<OverviewTemplateSummary[]>(() => {
+ const known = new Set([
+ ...serverDashboards.value.map((d) => d.id),
+ ...remoteOnlyDashboards.value.map((d) => d.id),
+ ]);
+ const out: OverviewTemplateSummary[] = [];
+ for (const name of localEdits.names()) {
+ if (!name.startsWith(OV_PREFIX)) continue;
+ const id = name.slice(OV_PREFIX.length);
+ if (known.has(id)) continue;
+ const s = summaryFrom(id, localEdits.get<OverviewDashboard>(name));
+ if (s) out.push(s);
+ }
+ return out;
+});
+const dashboards = computed(() => [
+ ...serverDashboards.value,
+ ...remoteOnlyDashboards.value,
+ ...localOnlyDrafts.value,
+]);
const selectedId = ref<string>('');
+const selectedDash = computed(() => dashboards.value.find((d) => d.id === selectedId.value) ?? null);
+// True when the selected dashboard exists on the server (disk/remote) — a
+// local-only draft has no server detail to fetch, so the detail query must
+// stay disabled for it (it would 404).
+const isServerBacked = computed(() =>
+ serverDashboards.value.some((d) => d.id === selectedId.value),
+);
+
+// Top dropdown picker (replaces the left list — same pattern as the layer
+// dashboards switcher). Filterable; "+ New dashboard" lives inside it.
+const listDropdownOpen = ref(false);
+const listSearch = ref('');
+const divergedOnly = ref(false);
+const localOnly = ref(false);
+function isDivergedRow(id: string): boolean {
+ const s = sync.badgeFor(`horizon.overview.${id}`);
+ return s === 'diverged' || s === 'bundled-fallback';
+}
+const divergedCount = computed(() => dashboards.value.filter((d) => isDivergedRow(d.id)).length);
+const localDraftCount = computed(() => dashboards.value.filter((d) => hasLocalDraftFor(d.id)).length);
+const filteredDashboards = computed(() => {
+ const q = listSearch.value.trim().toLowerCase();
+ return dashboards.value.filter((d) => {
+ if (divergedOnly.value && !isDivergedRow(d.id)) return false;
+ if (localOnly.value && !hasLocalDraftFor(d.id)) return false;
+ if (!q) return true;
+ return d.title.toLowerCase().includes(q) || d.id.toLowerCase().includes(q);
+ });
+});
+function pickDashboard(id: string): void {
+ selectedId.value = id;
+ listDropdownOpen.value = false;
+}
+/** Whether a dashboard id has an unpublished local browser draft — drives
+ * the "local" badge in the picker. */
+function hasLocalDraftFor(id: string): boolean {
+ return localEdits.has(overviewEditName(id));
+}
/* Auto-select rule: if nothing is selected (cold start), pick the
* first dashboard. If the currently-selected id disappears from the
* list (just deleted by the operator), fall back to the new first.
@@ -103,7 +220,7 @@ function cancelNewDash(): void {
newDashOpen.value = false;
newDashError.value = null;
}
-async function createDash(): Promise<void> {
+function createDash(): void {
const id = newDashId.value.trim();
const title = newDashTitle.value.trim();
if (!id) {
@@ -118,25 +235,28 @@ async function createDash(): Promise<void> {
newDashError.value = 'title is required';
return;
}
- if (dashboards.value.some((d) => d.id === id)) {
- newDashError.value = `dashboard "${id}" already exists`;
+ // The id IS the template name (horizon.overview.<id>) — must be unique
+ // across server, remote-only, and local drafts. Case-insensitive since
+ // OAP keys on the name string.
+ const idLc = id.toLowerCase();
+ if (dashboards.value.some((d) => d.id.toLowerCase() === idLc)) {
+ newDashError.value = `a dashboard with id "${id}" already exists`;
return;
}
- try {
- const description = newDashDescription.value.trim();
- await bff.overview.adminCreate({
- id,
- title,
- ...(description ? { description } : {}),
- widgets: [],
- });
- await listQuery.refetch();
- selectedId.value = id;
- newDashOpen.value = false;
- setFlash(`created · ${id}`);
- } catch (err) {
- newDashError.value = err instanceof Error ? err.message : 'create failed';
- }
+ // Create mirrors edit: write a LOCAL browser draft, don't touch the
+ // server. The dashboard is immediately selectable + previewable; "Check
+ // diff & push" publishes it to OAP (create-or-update, server-side).
+ const description = newDashDescription.value.trim();
+ const dash: OverviewDashboard = {
+ id,
+ title,
+ ...(description ? { description } : {}),
+ widgets: [],
+ };
+ localEdits.set(overviewEditName(id), dash);
+ selectedId.value = id;
+ newDashOpen.value = false;
+ setFlash(`created local draft · ${id} — edit, preview, then “Check diff & push”.`);
}
// ── Preview pane ──────────────────────────────────────────────────
@@ -148,19 +268,96 @@ async function createDash(): Promise<void> {
* 0..100 for percent / progress-bar). Not pixel-fidelity vs the real
* widget components — that would require running the orchestrator
* against mock OAP data — but enough to validate placement + scope. */
-/* Detail-pane view mode — `config` (the per-widget editor) or
- * `preview` (the layout + mock-data sanity board). Tabs in the
- * detail head switch between them. Defaults to config so an
- * operator who lands on the admin sees the editor immediately. */
-const viewMode = ref<'config' | 'preview'>('config');
+// Canvas selection: the editor is one page now — a mock-data widget grid
+// (canvas, left) + a drawer (right) that edits the clicked widget. Mirrors
+// the layer dashboards editor.
+// Sentinel id for the dashboard-meta "widget" (title + description). Real
+// widget ids never collide with it.
+const META_SEL = '__meta__';
+const selectedWidgetId = ref<string | null>(null);
+function selectWidget(id: string): void {
+ selectedWidgetId.value = id;
+}
+// Drop the selection when the dashboard changes.
+watch(selectedId, () => {
+ selectedWidgetId.value = null;
+});
+// Esc closes the editor drawer (deselects) — unless a modal owns Esc.
+function onEditorKey(e: KeyboardEvent): void {
+ if (e.key === 'Escape' && selectedWidgetId.value && !pushDiffOpen.value && !newDashOpen.value) {
+ selectedWidgetId.value = null;
+ }
+}
+onMounted(() => window.addEventListener('keydown', onEditorKey));
+onBeforeUnmount(() => window.removeEventListener('keydown', onEditorKey));
+
+// ── Canvas drag-reorder (HTML5 DnD over the flat widgets array) ─────
+const dragId = ref<string | null>(null);
+function onWidgetDragStart(e: DragEvent, id: string): void {
+ dragId.value = id;
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
+}
+function onWidgetDrop(targetId: string): void {
+ const d = draft.value;
+ if (!d || !dragId.value || dragId.value === targetId) {
+ dragId.value = null;
+ return;
+ }
+ const ws = [...d.widgets];
+ const from = ws.findIndex((w) => w.id === dragId.value);
+ const to = ws.findIndex((w) => w.id === targetId);
+ dragId.value = null;
+ if (from < 0 || to < 0) return;
+ const [moved] = ws.splice(from, 1);
+ ws.splice(to, 0, moved!);
+ draft.value = { ...d, widgets: ws };
+}
+
+// ── Canvas corner-resize (drag updates span / rowSpan) ──────────────
+const resize = reactive({ active: false, id: '', startX: 0, startY: 0, startSpan: 4, startRowSpan: 1, cellW: 1, cellH: 96, cols: 12 });
+function onResizeStart(e: MouseEvent, w: OverviewWidget, cols: number): void {
+ e.preventDefault();
+ e.stopPropagation();
+ const grid = (e.target as HTMLElement).closest('.ot__pv-grid') as HTMLElement | null;
+ if (!grid) return;
+ const gap = 6;
+ const cellW = (grid.getBoundingClientRect().width - gap * (cols - 1)) / cols;
+ resize.active = true;
+ resize.id = w.id;
+ resize.startX = e.clientX;
+ resize.startY = e.clientY;
+ resize.startSpan = w.span ?? 4;
+ resize.startRowSpan = w.rowSpan ?? 1;
+ resize.cellW = cellW + gap;
+ resize.cellH = 96 + gap;
+ resize.cols = cols;
+ selectWidget(w.id);
+ window.addEventListener('mousemove', onResizeMove);
+ window.addEventListener('mouseup', onResizeEnd);
+}
+function onResizeMove(e: MouseEvent): void {
+ if (!resize.active || !draft.value) return;
+ const span = Math.max(1, Math.min(resize.cols, resize.startSpan + Math.round((e.clientX - resize.startX) / resize.cellW)));
+ const rowSpan = Math.max(1, Math.min(8, resize.startRowSpan + Math.round((e.clientY - resize.startY) / resize.cellH)));
+ const w = draft.value.widgets.find((x) => x.id === resize.id);
+ if (w && (w.span !== span || w.rowSpan !== rowSpan)) {
+ w.span = span;
+ w.rowSpan = rowSpan;
+ }
+}
+function onResizeEnd(): void {
+ resize.active = false;
+ window.removeEventListener('mousemove', onResizeMove);
+ window.removeEventListener('mouseup', onResizeEnd);
+}
interface PreviewSection {
cols: number;
- widgets: typeof draft.value extends infer T
- ? T extends { widgets: infer W }
- ? W
- : never
- : never;
+ /** The section-break widget that opened this section (its id + title),
+ * or null for the implicit first section. Lets the canvas render the
+ * section-break as a selectable header. */
+ sb: OverviewWidget | null;
+ widgets: OverviewWidget[];
}
const previewSections = computed<PreviewSection[]>(() => {
if (!draft.value) return [];
@@ -168,15 +365,15 @@ const previewSections = computed<PreviewSection[]>(() => {
let cur: PreviewSection | null = null;
for (const w of draft.value.widgets) {
if (w.type === 'section-break') {
- cur = { cols: w.cols ?? 12, widgets: [] as never };
+ cur = { cols: w.cols ?? 12, sb: w, widgets: [] };
out.push(cur);
continue;
}
if (!cur) {
- cur = { cols: 12, widgets: [] as never };
+ cur = { cols: 12, sb: null, widgets: [] };
out.push(cur);
}
- (cur.widgets as unknown as OverviewWidget[]).push(w);
+ cur.widgets.push(w);
}
return out;
});
@@ -225,41 +422,79 @@ function mockAlarms(seed: string, n: number): Array<{
return out;
}
-/** Split a kpi-tile / metric-composite widget's rows into the two
- * visual partitions the real renderer uses: number tiles (count
- * grid) vs progress-bar rows. Same rule the runtime uses —
- * `style === 'progress-bar'` OR `unit === '%'` go to bars. */
-function kpiCountRows(w: OverviewWidget): OverviewKpi[] {
- return (w.kpis ?? []).filter((k) => k.style !== 'progress-bar' && k.unit !== '%');
+/** Mock value per KPI label for the real widget components — percent /
+ * progress-bar rows get a 0-100 value, everything else a plain number.
+ * Keyed by widget id + label so it's stable across re-renders. */
+function mockKpiValues(w: OverviewWidget): Record<string, number | null> {
+ const out: Record<string, number | null> = {};
+ for (const k of w.kpis ?? []) {
+ out[k.label] =
+ k.unit === '%' || k.style === 'progress-bar'
+ ? mockPercent(w.id + k.label)
+ : mockNumber(w.id + k.label, k.max ?? 999);
+ }
+ return out;
}
-function kpiBarRows(w: OverviewWidget): OverviewKpi[] {
- return (w.kpis ?? []).filter((k) => k.style === 'progress-bar' || k.unit === '%');
+/** Grid placement for one preview cell — span/rowSpan within the
+ * section's column count. */
+function previewCellStyle(w: OverviewWidget, cols: number): Record<string, string> {
+ // Same clamps as the live renderer (OverviewDashboardView.widgetStyle):
+ // span ∈ [1, cols], rowSpan ∈ [1, 8].
+ return {
+ gridColumn: `span ${Math.min(cols, Math.max(1, w.span ?? cols))}`,
+ gridRow: `span ${Math.min(8, Math.max(1, w.rowSpan ?? 1))}`,
+ };
}
-function previewSectionTitle(idx: number): string {
- if (!draft.value) return '';
- /* Walk the original widget array up to the idx-th section-break
- * to recover its title (the preview groups widgets under each
- * break but only stores cols + widget rows in the section). */
- let n = 0;
- for (const w of draft.value.widgets) {
- if (w.type !== 'section-break') continue;
- if (n === idx) return w.title;
- n += 1;
+// ── Delete current dashboard ───────────────────────────────────────
+// A local-only draft (never pushed) is truly removed from the browser —
+// a real hard delete, it was never on OAP. Anything on OAP (bundled or
+// remote-only) is soft-disabled, because OAP has no hard DELETE; a
+// disabled template drops out of the bundle, so it vanishes from the UI.
+// Either way it's irreversible from the UI (no re-enable entrance).
+// Styled confirm (replaces window.confirm — native box breaks the dark
+// design). `askDeleteDash` opens it with the right copy; `confirmDeleteDash`
+// runs the actual delete/disable.
+const deleteOpen = ref(false);
+const deleteTitle = ref('');
+const deleteMessage = ref('');
+const deleteConfirmLabel = ref('Delete');
+function askDeleteDash(): void {
+ const id = selectedId.value;
+ if (!id || !editName.value) return;
+ const onOap = remoteAvailable.value || bundledExists.value;
+ if (!onOap) {
+ deleteTitle.value = 'Delete local draft?';
+ deleteMessage.value = `Delete the local draft “${id}”? It was never published, so it's removed from this browser only. This can't be undone.`;
+ deleteConfirmLabel.value = 'Delete draft';
+ } else if (bundledExists.value) {
+ deleteTitle.value = 'Disable built-in dashboard?';
+ deleteMessage.value = `Disable the built-in dashboard “${id}”? OAP has no hard delete, so it's soft-disabled — hidden from everyone. This can't be undone from the UI.`;
+ deleteConfirmLabel.value = 'Disable';
+ } else {
+ deleteTitle.value = 'Delete dashboard?';
+ deleteMessage.value = `Delete the dashboard “${id}”? OAP has no hard delete, so it's soft-disabled — hidden from everyone. This can't be undone from the UI.`;
+ deleteConfirmLabel.value = 'Delete';
}
- return '';
+ deleteOpen.value = true;
}
-
-// ── Delete current dashboard ───────────────────────────────────────
-async function deleteCurrentDash(): Promise<void> {
+async function confirmDeleteDash(): Promise<void> {
const id = selectedId.value;
- if (!id) return;
- if (!window.confirm(`Delete dashboard "${id}"? This removes the JSON file from disk.`)) {
+ const name = editName.value;
+ deleteOpen.value = false;
+ if (!id || !name) return;
+ const onOap = remoteAvailable.value || bundledExists.value;
+ if (!onOap) {
+ localEdits.remove(name);
+ previewOverride.clear(name);
+ setFlash(`deleted local draft · ${id}`);
return;
}
try {
- await bff.overview.adminDelete(id);
- await listQuery.refetch();
+ await bff.templateSync.disable(name);
+ localEdits.remove(name);
+ previewOverride.clear(name);
+ await Promise.all([listQuery.refetch(), sources.refetch(), refreshConfigBundle()]);
setFlash(`deleted · ${id}`);
} catch (err) {
setFlash(err instanceof Error ? `error: ${err.message}` : 'delete failed');
@@ -269,7 +504,9 @@ async function deleteCurrentDash(): Promise<void> {
const detailQuery = useQuery({
queryKey: computed(() => ['admin/overview-templates', selectedId.value]),
queryFn: () => bff.overview.adminGet(selectedId.value),
- enabled: computed(() => selectedId.value.length > 0),
+ // Don't fetch detail for a local-only draft — there's no server copy
+ // (it would 404); the editor loads it straight from the local draft.
+ enabled: computed(() => selectedId.value.length > 0 && isServerBacked.value),
staleTime: 60_000,
});
@@ -285,17 +522,73 @@ const layerOptions = computed<string[]>(() => {
return Array.from(live).sort();
});
+/* ── Editor sources ───────────────────────────────────────────────
+ * Load from LOCAL (browser draft), BUNDLED (shipped), or REMOTE (OAP).
+ * `editorSource` tracks which is loaded; `loadedSnapshot` is the baseline
+ * for `isDirty`. Save always writes LOCAL; publish LOCAL → OAP after.
+ * ─────────────────────────────────────────────────────────────────── */
+const editName = computed(() => (selectedId.value ? overviewEditName(selectedId.value) : ''));
+const hasLocalDraft = computed<boolean>(() => !!editName.value && localEdits.has(editName.value));
+const remoteAvailable = computed<boolean>(() => !!editName.value && sources.hasRemote(editName.value));
+// Ships a bundled default → not deletable (delete would fall back to the
+// bundle). Drives both the disabled delete button and the guard above.
+const bundledExists = computed<boolean>(() => !!editName.value && sources.hasBundled(editName.value));
+
const draft = ref<OverviewDashboard | null>(null);
-watch(
- () => detailQuery.data.value,
- (resp) => {
- if (!resp) {
- draft.value = null;
- return;
- }
- draft.value = JSON.parse(JSON.stringify(resp.dashboard)) as OverviewDashboard;
- },
-);
+const editorSource = ref<'local' | 'bundled' | 'remote'>('bundled');
+const loadedSnapshot = ref<string>('');
+
+function bundledContent(): OverviewDashboard | null {
+ return (
+ sources.bundled<OverviewDashboard>(editName.value) ??
+ detailQuery.data.value?.dashboard ??
+ null
+ );
+}
+function sourceContent(src: 'local' | 'bundled' | 'remote'): OverviewDashboard | null {
+ if (src === 'local') return localEdits.get<OverviewDashboard>(editName.value) ?? null;
+ if (src === 'remote') return sources.remote<OverviewDashboard>(editName.value);
+ return bundledContent();
+}
+function loadFrom(src: 'local' | 'bundled' | 'remote'): void {
+ const c = sourceContent(src);
+ draft.value = c ? (JSON.parse(JSON.stringify(c)) as OverviewDashboard) : null;
+ loadedSnapshot.value = draft.value ? JSON.stringify(draft.value) : '';
+ editorSource.value = src;
+}
+
+/** Write the editor content to the local draft (cleared if it equals
+ * remote). Drives hasLocalDraft / localDiffersFromRemote reactively. */
+function persistLocal(content: OverviewDashboard): void {
+ const remote = sources.remote<OverviewDashboard>(editName.value);
+ if (remote && stableStringify(content) === stableStringify(remote)) {
+ localEdits.remove(editName.value);
+ } else {
+ localEdits.set(editName.value, JSON.parse(JSON.stringify(content)));
+ }
+ loadedSnapshot.value = JSON.stringify(content);
+}
+
+// "Reset to ▾" dropdown — reload from a source AND adopt it as the local
+// draft (so the choice persists + Push/diff status updates).
+const resetDropdownOpen = ref(false);
+function resetTo(src: 'bundled' | 'remote'): void {
+ loadFrom(src);
+ if (draft.value) persistLocal(draft.value);
+ resetDropdownOpen.value = false;
+}
+
+// "Preview ▾" dropdown — open the real overview page in a new tab
+// rendering the chosen source (writes it to the preview-override store).
+const previewDropdownOpen = ref(false);
+function previewLive(src: 'local' | 'bundled' | 'remote'): void {
+ const content = sourceContent(src);
+ if (!content || !selectedId.value) return;
+ previewOverride.set(editName.value, content);
+ const href = router.resolve({ path: `/overview/${selectedId.value}`, query: { mode: 'preview', source: src } }).href;
+ previewDropdownOpen.value = false;
+ window.open(href, '_blank', 'noopener');
+}
const flash = ref<string | null>(null);
const saving = ref(false);
@@ -306,51 +599,76 @@ function setFlash(msg: string): void {
}, 4000);
}
-const isDirty = computed<boolean>(() => {
- const cur = draft.value;
- const orig = detailQuery.data.value?.dashboard;
- if (!cur || !orig) return false;
- return JSON.stringify(cur) !== JSON.stringify(orig);
-});
-
-// Diverged-row controls: shown only when OAP's copy differs from
-// bundled for the currently-selected dashboard. The "show diff &
-// reset" modal opens a Monaco side-by-side and the destructive
-// confirm UI (operator types the dashboard id to arm Reset).
-const selectedStatus = computed(() =>
- selectedId.value ? sync.badgeFor(`horizon.overview.${selectedId.value}`) : null,
+const isDirty = computed<boolean>(() =>
+ draft.value ? JSON.stringify(draft.value) !== loadedSnapshot.value : false,
);
-const isDiverged = computed(() => selectedStatus.value === 'diverged');
-const diffModalOpen = ref(false);
-function openDiffModal(): void { diffModalOpen.value = true; }
-async function onDiffReset(): Promise<void> {
- await detailQuery.refetch();
- setFlash('OAP reset to bundled · reload the overview to see widget changes');
+
+// Which source to seed the editor from for the current selection. Local
+// draft wins; then honour an explicit "remote" choice; then bundled if it
+// exists; else remote (a remote-only dashboard has no bundled base, so
+// bundled would load nothing). Falls back to bundled when nothing exists.
+function defaultEditorSource(): 'local' | 'bundled' | 'remote' {
+ if (hasLocalDraft.value) return 'local';
+ if (editorSource.value === 'remote' && remoteAvailable.value) return 'remote';
+ if (bundledContent()) return 'bundled';
+ if (remoteAvailable.value) return 'remote';
+ return 'bundled';
}
-async function onSave(): Promise<void> {
- if (!draft.value || !selectedId.value) return;
+// Seed the editor when the selected overview (or a background refetch)
+// changes — but never clobber unsaved keystrokes.
+watch(
+ [selectedId, () => detailQuery.data.value, hasLocalDraft, remoteAvailable],
+ () => {
+ if (isDirty.value) return;
+ loadFrom(defaultEditorSource());
+ },
+ { immediate: true },
+);
+
+/** Save the editor to the browser local draft. Never writes OAP. */
+function onSave(): void {
+ if (!draft.value || !editName.value) return;
+ persistLocal(draft.value);
+ editorSource.value = 'local';
+ setFlash('Saved to your browser (local). Publish with “Check diff & push”.');
+}
+
+// Push = publish local → remote. Available only when a local draft exists
+// AND differs from remote. The confirm modal shows the remote→local diff.
+const pushDiffOpen = ref(false);
+// Key-stable so a one-field edit doesn't read as "everything changed".
+function prettyJson(o: unknown): string {
+ return o ? stableStringify(o, 2) : '';
+}
+const localDiffersFromRemote = computed<boolean>(() => {
+ const local = localEdits.get<OverviewDashboard>(editName.value);
+ if (!local) return false;
+ return stableStringify(local) !== stableStringify(sources.remote<OverviewDashboard>(editName.value) ?? null);
+});
+const pushLocalPretty = computed(() => prettyJson(localEdits.get(editName.value)));
+const pushRemotePretty = computed(() => prettyJson(sources.remote(editName.value)));
+
+/** Publish the local draft to OAP, clear it, reload remote. */
+async function pushToOap(): Promise<void> {
+ const local = localEdits.get<OverviewDashboard>(editName.value);
+ if (!local || saving.value) return;
saving.value = true;
try {
- // Save writes the LOCAL bundled template (not OAP). The edit renders
- // locally and shows as diverged until published to OAP via "Sync all
- // to OAP". Works even when OAP is unreachable.
- await bff.templateSync.saveLocal(`horizon.overview.${selectedId.value}`, draft.value);
- await detailQuery.refetch();
- await refreshConfigBundle(); // refresh diverged badges + sidebar warning
- setFlash('Saved locally — not yet live for others. Use “Sync all to OAP” to publish.');
+ await bff.templateSync.save(editName.value, local);
+ localEdits.remove(editName.value);
+ previewOverride.clear(editName.value); // drop the now-stale preview snapshot
+ await Promise.all([sources.refetch(), detailQuery.refetch(), refreshConfigBundle()]);
+ loadFrom('remote');
+ pushDiffOpen.value = false;
+ setFlash('Published your local draft to OAP — now live for everyone.');
} catch (err) {
- setFlash(err instanceof Error ? `error: ${err.message}` : 'save failed');
+ setFlash(err instanceof Error ? `error: ${err.message}` : 'push failed');
} finally {
saving.value = false;
}
}
-function onReset(): void {
- const orig = detailQuery.data.value?.dashboard;
- if (orig) draft.value = JSON.parse(JSON.stringify(orig)) as OverviewDashboard;
-}
-
// ── KPI row helpers (kpi-tile only) ────────────────────────────────
function addKpi(w: OverviewWidget): void {
const next = [...(w.kpis ?? []), { label: 'new KPI', mqe: '' } as OverviewKpi];
@@ -387,8 +705,10 @@ function moveWidget(idx: number, dir: -1 | 1): void {
}
function removeWidget(idx: number): void {
if (!draft.value) return;
+ const removed = draft.value.widgets[idx];
const next = draft.value.widgets.filter((_, i) => i !== idx);
draft.value = { ...draft.value, widgets: next };
+ if (removed && removed.id === selectedWidgetId.value) selectedWidgetId.value = null;
}
// ── Add-widget composer ────────────────────────────────────────────
@@ -458,6 +778,7 @@ function createWidget(): void {
};
draft.value = { ...draft.value, widgets: [...draft.value.widgets, widget] };
composerOpen.value = false;
+ selectedWidgetId.value = widget.id; // open the drawer to finish editing it
}
// ── Widget meta ────────────────────────────────────────────────────
@@ -501,78 +822,94 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
<SyncStatusBanner :banner="sync.banner.value" />
- <div class="ot__toolbar">
- <SyncAllButton kind="overview" />
- </div>
-
<div v-if="listQuery.isPending.value" class="ot__empty">loading…</div>
- <div v-else class="ot__split">
- <ul class="ot__list">
- <li
- v-for="d in dashboards"
- :key="d.id"
- class="ot__list-item"
- :class="{ active: d.id === selectedId, readonly: !d.editable }"
- @click="selectedId = d.id"
- >
- <div class="ot__list-title">{{ d.title }}</div>
- <div v-if="d.description" class="ot__list-desc">{{ d.description }}</div>
- <div class="ot__list-meta">
- <code>{{ d.id }}</code>
- <span>{{ d.widgetCount }} widget{{ d.widgetCount === 1 ? '' : 's' }}</span>
- <span v-if="!d.editable" class="ot__readonly-tag">no source file</span>
- <TemplateStatusBadge :status="sync.badgeFor(`horizon.overview.${d.id}`)" />
- </div>
- </li>
- <li v-if="dashboards.length === 0" class="ot__list-empty">No overview templates loaded.</li>
- <li class="ot__list-add">
- <button
- v-if="!newDashOpen"
- type="button"
- class="ot__add-trigger ot__add-trigger--list"
- :disabled="sync.readOnly.value"
- :title="sync.readOnly.value ? 'OAP unreachable — cannot create' : ''"
- @click="openNewDash"
- >+ New dashboard</button>
- <div v-else class="ot__newdash">
- <label class="ot__field">
- <span>Id</span>
- <input
- v-model="newDashId"
- type="text"
- class="ot__in"
- placeholder="my-overview"
- @keyup.enter="createDash"
- />
- </label>
- <label class="ot__field">
- <span>Title</span>
- <input
- v-model="newDashTitle"
- type="text"
- class="ot__in"
- placeholder="My overview"
- @keyup.enter="createDash"
- />
- </label>
- <label class="ot__field ot__field--wide">
- <span>Description (optional)</span>
- <textarea
- v-model="newDashDescription"
- class="ot__in ot__in--ta"
- rows="2"
- placeholder="Short, one-paragraph description shown under the dashboard title."
- />
- </label>
- <div v-if="newDashError" class="ot__newdash-err">{{ newDashError }}</div>
- <div class="ot__newdash-foot">
- <button type="button" class="ot__btn" @click="cancelNewDash">cancel</button>
- <button type="button" class="ot__btn ot__btn--primary" @click="createDash">create</button>
+ <div v-else class="ot__main">
+ <!-- Top dashboard picker — filterable dropdown (same pattern as the
+ layer dashboards switcher); "+ New" lives inside it. -->
+ <div class="ot__picker-bar">
+ <div class="ot__dd">
+ <button type="button" class="ot__dd-btn" @click="listDropdownOpen = !listDropdownOpen">
+ <span class="ot__dd-title">{{ selectedDash?.title || 'Select dashboard' }}</span>
+ <code v-if="selectedDash" class="ot__dd-id">{{ selectedDash.id }}</code>
+ <span v-if="selectedId && hasLocalDraftFor(selectedId)" class="ot__local-badge" title="Unpublished local draft">local</span>
+ <TemplateStatusBadge v-if="selectedId" :status="sync.badgeFor(`horizon.overview.${selectedId}`)" />
+ <span class="caret" :class="{ open: listDropdownOpen }">›</span>
+ </button>
+ <template v-if="listDropdownOpen">
+ <div class="ot__dd-backdrop" @click="listDropdownOpen = false" />
+ <div class="ot__dd-pop">
+ <!-- Browse mode: search + filters + list. Hidden while the
+ New-dashboard composer is open so the form stands alone. -->
+ <template v-if="!newDashOpen">
+ <div class="ot__dd-search">
+ <input v-model="listSearch" type="text" placeholder="Search dashboards…" autocomplete="off" spellcheck="false" />
+ </div>
+ <div class="ot__dd-filters">
+ <label class="ot__dd-filter" :class="{ on: divergedOnly }">
+ <input v-model="divergedOnly" type="checkbox" :disabled="divergedCount === 0" />
+ Diverged<span v-if="divergedCount" class="ot__dd-fc">{{ divergedCount }}</span>
+ </label>
+ <label class="ot__dd-filter" :class="{ on: localOnly }">
+ <input v-model="localOnly" type="checkbox" :disabled="localDraftCount === 0" />
+ Local<span v-if="localDraftCount" class="ot__dd-fc local">{{ localDraftCount }}</span>
+ </label>
+ </div>
+ <div class="ot__dd-list">
+ <button
+ v-for="d in filteredDashboards"
+ :key="d.id"
+ type="button"
+ class="ot__dd-item"
+ :class="{ active: d.id === selectedId }"
+ @click="pickDashboard(d.id)"
+ >
+ <span class="ot__dd-item-title">{{ d.title }}</span>
+ <code>{{ d.id }}</code>
+ <span class="ot__dd-item-count">{{ d.widgetCount }}w</span>
+ <span v-if="!d.editable" class="ot__readonly-tag">no source</span>
+ <span v-if="hasLocalDraftFor(d.id)" class="ot__local-badge" title="Unpublished local draft">local</span>
+ <TemplateStatusBadge :status="sync.badgeFor(`horizon.overview.${d.id}`)" />
+ </button>
+ <p v-if="filteredDashboards.length === 0" class="ot__dd-empty">No dashboards match.</p>
+ </div>
+ <div class="ot__dd-new">
+ <button
+ type="button"
+ class="ot__btn"
+ :disabled="sync.readOnly.value"
+ :title="sync.readOnly.value ? 'OAP unreachable — cannot create' : ''"
+ @click="openNewDash"
+ >+ New dashboard</button>
+ </div>
+ </template>
+
+ <!-- Create mode: self-contained form (replaces the list). -->
+ <div v-else class="ot__newdash">
+ <div class="ot__newdash-head">New dashboard</div>
+ <label class="ot__field ot__field--wide">
+ <span>Id (used as the name — must be unique)</span>
+ <input v-model="newDashId" type="text" class="ot__in ot__in--mono" placeholder="my-overview" @keyup.enter="createDash" />
+ </label>
+ <label class="ot__field ot__field--wide">
+ <span>Title</span>
+ <input v-model="newDashTitle" type="text" class="ot__in" placeholder="My overview" @keyup.enter="createDash" />
+ </label>
+ <label class="ot__field ot__field--wide">
+ <span>Description (optional)</span>
+ <textarea v-model="newDashDescription" class="ot__in ot__in--ta" rows="2" placeholder="Short, one-paragraph description shown under the dashboard title." />
+ </label>
+ <div v-if="newDashError" class="ot__newdash-err">{{ newDashError }}</div>
+ <div class="ot__newdash-foot">
+ <button type="button" class="ot__btn" @click="cancelNewDash">cancel</button>
+ <button type="button" class="ot__btn ot__btn--primary" @click="createDash">create</button>
+ </div>
+ </div>
</div>
- </div>
- </li>
- </ul>
+ </template>
+ </div>
+ <span class="ot__picker-count">{{ dashboards.length }} dashboard{{ dashboards.length === 1 ? '' : 's' }}</span>
+ </div>
<section class="ot__detail">
<div v-if="detailQuery.isPending.value && !draft" class="ot__empty">loading…</div>
@@ -582,40 +919,87 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
<span class="ot__count mono">
{{ draft.widgets.length }} widget{{ draft.widgets.length === 1 ? '' : 's' }}
</span>
- <!-- Tab strip — Config (editor) / Preview (mock board). -->
- <div class="ot__tabs" role="tablist">
+ <span v-if="flash" class="ot__flash">{{ flash }}</span>
+ <span v-else-if="isDirty" class="ot__dirty">unsaved changes</span>
+ <span v-else class="ot__clean">saved</span>
+ <!-- Delete. A local-only draft is removed from the browser; a
+ dashboard on OAP (bundled or remote-only) is soft-disabled
+ (OAP has no hard delete). Irreversible from the UI. -->
+ <button
+ type="button"
+ class="ot__head-btn ot__head-btn--danger"
+ :disabled="sync.readOnly.value && (remoteAvailable || bundledExists)"
+ :title="(sync.readOnly.value && (remoteAvailable || bundledExists))
+ ? 'OAP unreachable — cannot delete'
+ : bundledExists
+ ? `Disable built-in dashboard ${draft.id} (OAP has no hard delete — hidden, irreversible from the UI)`
+ : remoteAvailable
+ ? `Delete dashboard ${draft.id} (soft-disabled on OAP — irreversible from the UI)`
+ : `Delete local draft ${draft.id} (never published)`"
+ @click="askDeleteDash"
+ >{{ bundledExists ? 'disable' : 'delete' }}</button>
+ <!-- Source / save / publish actions, right-aligned (same row as
+ the title + tabs, mirroring the layer dashboards editor). -->
+ <div class="ot__head-actions">
+ <span class="ot__src" :title="`Editing from: ${editorSource}`">{{ editorSource }}</span>
+ <div class="reset-dd">
+ <button type="button" class="ot__btn" @click="resetDropdownOpen = !resetDropdownOpen">
+ reset to <span class="caret" :class="{ open: resetDropdownOpen }">›</span>
+ </button>
+ <template v-if="resetDropdownOpen">
+ <div class="reset-dd-backdrop" @click="resetDropdownOpen = false" />
+ <div class="reset-dd-pop">
+ <button class="reset-dd-item" type="button" title="Discard current edits and reload the bundled default." @click="resetTo('bundled')">Bundled</button>
+ <button class="reset-dd-item" type="button" :disabled="!remoteAvailable" :title="remoteAvailable ? 'Discard current edits and reload OAP\'s live version.' : 'OAP has no copy yet.'" @click="resetTo('remote')">Remote</button>
+ </div>
+ </template>
+ </div>
+ <div class="reset-dd">
+ <button type="button" class="ot__btn" @click="previewDropdownOpen = !previewDropdownOpen">
+ preview <span class="caret" :class="{ open: previewDropdownOpen }">›</span>
+ </button>
+ <template v-if="previewDropdownOpen">
+ <div class="reset-dd-backdrop" @click="previewDropdownOpen = false" />
+ <div class="reset-dd-pop">
+ <button class="reset-dd-item" type="button" :disabled="!hasLocalDraft" title="Preview your unpublished local draft." @click="previewLive('local')">Local</button>
+ <button class="reset-dd-item" type="button" title="Preview the bundled (shipped) default." @click="previewLive('bundled')">Bundled</button>
+ <button class="reset-dd-item" type="button" :disabled="!remoteAvailable" title="Preview OAP's live version." @click="previewLive('remote')">Remote</button>
+ </div>
+ </template>
+ </div>
<button
+ v-if="!sync.readOnly.value"
type="button"
- role="tab"
- class="ot__tab"
- :class="{ active: viewMode === 'config' }"
- :aria-selected="viewMode === 'config'"
- @click="viewMode = 'config'"
- >Config</button>
+ class="ot__btn"
+ :disabled="!localDiffersFromRemote || saving"
+ :title="localDiffersFromRemote ? 'Review the local → remote diff, then publish to OAP.' : 'No local changes to publish — local matches remote.'"
+ @click="pushDiffOpen = true"
+ >
+ check diff & push
+ </button>
<button
type="button"
- role="tab"
- class="ot__tab"
- :class="{ active: viewMode === 'preview' }"
- :aria-selected="viewMode === 'preview'"
- @click="viewMode = 'preview'"
- >Preview</button>
+ class="ot__btn ot__btn--primary"
+ :disabled="!isDirty || saving"
+ title="Save the editor to your browser (local). Publish later with “Check diff & push”."
+ @click="onSave"
+ >
+ {{ saving ? 'saving…' : 'save (local)' }}
+ </button>
</div>
- <button
- type="button"
- class="ot__head-btn ot__head-btn--danger"
- :disabled="sync.readOnly.value"
- :title="sync.readOnly.value ? 'OAP unreachable — cannot delete' : `Delete dashboard ${draft.id}`"
- @click="deleteCurrentDash"
- >delete</button>
</header>
- <div v-if="viewMode === 'config'" class="ot__widgets">
- <!-- Dashboard-level meta: title + description. These show
- on every page that references the dashboard (sidebar
- list, sub-heading on the page itself, admin list).
- Description is optional but recommended. -->
- <section class="ot__meta">
+ <!-- One-page editor: mock-data widget grid (canvas, left) +
+ drawer (right) that edits the clicked widget. -->
+ <div class="ot__editor-split" :class="{ 'has-sel': !!selectedWidgetId }">
+
+ <aside v-if="selectedWidgetId" class="ot__drawer-pane ot__widgets">
+ <div class="ot__drawer-head">
+ <span class="ot__drawer-title">{{ selectedWidgetId === META_SEL ? 'Dashboard meta' : 'Edit widget' }}</span>
+ <button type="button" class="ot__drawer-close" title="Close (Esc)" @click="selectedWidgetId = null">✕</button>
+ </div>
+ <!-- Dashboard-level meta: title + description. -->
+ <section v-if="selectedWidgetId === META_SEL" class="ot__meta">
<header class="ot__meta-head">
<span class="ot__meta-kicker">Dashboard meta</span>
<span class="ot__meta-hint">Shown in the sidebar and as the page sub-heading.</span>
@@ -637,6 +1021,7 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
<article
v-for="(w, wi) in draft.widgets"
+ v-show="w.id === selectedWidgetId"
:key="w.id"
class="ot__widget"
:class="`ot__widget--${w.type}`"
@@ -757,183 +1142,181 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
<div v-if="(w.kpis ?? []).length === 0" class="ot__kpis-empty">
No KPI rows. Add one to surface a metric on the tile.
</div>
- <table v-else class="ot__kpi-table">
- <thead>
- <tr>
- <th></th>
- <th>Label</th>
- <th>Source</th>
- <th>MQE</th>
- <th>Unit</th>
- <th>Aggr</th>
- <th>Style</th>
- <th>Max</th>
- <th></th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="(k, i) in w.kpis ?? []" :key="i">
- <td>
+ <!-- One card per KPI. The drawer is narrow (sidebar-only),
+ so fields stack vertically instead of a wide table
+ row that truncates every column. -->
+ <ul v-else class="ot__kpi-cards">
+ <li v-for="(k, i) in w.kpis ?? []" :key="i" class="ot__kpi-card">
+ <div class="ot__kpi-card-head">
+ <span class="ot__kpi-card-idx">#{{ i + 1 }}</span>
+ <div class="ot__kpi-card-ord">
<button
type="button"
class="ot__arrow"
+ title="Move up"
:disabled="i === 0"
@click="moveKpi(w, i, -1)"
- >‹</button>
+ >↑</button>
<button
type="button"
class="ot__arrow"
+ title="Move down"
:disabled="i === (w.kpis ?? []).length - 1"
@click="moveKpi(w, i, 1)"
- >›</button>
- </td>
- <td><input v-model="k.label" type="text" class="ot__in" /></td>
- <td>
- <select v-model="k.source" class="ot__in ot__in--narrow">
- <option :value="undefined">mqe</option>
- <option value="mqe">mqe</option>
- <option value="service-count">service-count</option>
- </select>
- </td>
- <td>
- <input
- v-if="(k.source ?? 'mqe') === 'mqe'"
- v-model="k.mqe"
- type="text"
- class="ot__in ot__in--mono"
- />
- <span v-else class="ot__none">— (listServices)</span>
- </td>
- <td><input v-model="k.unit" type="text" class="ot__in ot__in--xnarrow" /></td>
- <td>
- <select v-model="k.aggregation" class="ot__in ot__in--xnarrow">
+ >↓</button>
+ </div>
+ <button type="button" class="ot__del" title="Remove row" @click="removeKpi(w, i)">×</button>
+ </div>
+ <label class="ot__field ot__field--full">
+ <span>Label</span>
+ <input v-model="k.label" type="text" class="ot__in" />
+ </label>
+ <label class="ot__field ot__field--full">
+ <span>Source</span>
+ <select v-model="k.source" class="ot__in">
+ <option :value="undefined">mqe</option>
+ <option value="mqe">mqe</option>
+ <option value="service-count">service-count</option>
+ </select>
+ </label>
+ <label v-if="(k.source ?? 'mqe') === 'mqe'" class="ot__field ot__field--full">
+ <span>MQE</span>
+ <input v-model="k.mqe" type="text" class="ot__in ot__in--mono" />
+ </label>
+ <p v-else class="ot__none">Value comes from the service count (listServices) — no MQE.</p>
+ <div class="ot__kpi-card-grid">
+ <label class="ot__field">
+ <span>Unit</span>
+ <input v-model="k.unit" type="text" class="ot__in" />
+ </label>
+ <label class="ot__field">
+ <span>Aggr</span>
+ <select v-model="k.aggregation" class="ot__in">
<option :value="undefined">—</option>
<option value="avg">avg</option>
<option value="sum">sum</option>
</select>
- </td>
- <td>
- <select
- v-model="k.style"
- class="ot__in ot__in--narrow"
- @change="onKpiStyleChange(k)"
- >
+ </label>
+ <label class="ot__field">
+ <span>Style</span>
+ <select v-model="k.style" class="ot__in" @change="onKpiStyleChange(k)">
<option :value="undefined">number</option>
<option value="number">number</option>
<option value="progress-bar">progress-bar</option>
</select>
- </td>
- <td>
+ </label>
+ <label v-if="k.style === 'progress-bar'" class="ot__field">
+ <span>Max</span>
<input
- v-if="k.style === 'progress-bar'"
v-model.number="k.max"
type="number"
min="0"
step="any"
- class="ot__in ot__in--xnum"
+ class="ot__in"
placeholder="100"
/>
- <span v-else class="ot__none">—</span>
- </td>
- <td>
- <button type="button" class="ot__del" @click="removeKpi(w, i)">×</button>
- </td>
- </tr>
- </tbody>
- </table>
+ </label>
+ </div>
+ </li>
+ </ul>
</div>
</template>
</article>
- <!-- Add-widget composer. Operator picks type + size FIRST
- (per the spec); on Create the widget is appended with
- sensible defaults and the inline editor handles the
- rest (title / layer / MQE / KPI rows / etc.). -->
- <div class="ot__add-widget">
- <button
- v-if="!composerOpen"
- type="button"
- class="ot__add-trigger"
- @click="openComposer"
- >
- + Add widget
- </button>
- <article v-else class="ot__widget ot__composer">
- <header class="ot__widget-head">
- <span class="ot__widget-kind">New widget</span>
- </header>
- <div class="ot__row">
- <label class="ot__field">
- <span>Type</span>
- <select v-model="composerType" class="ot__in ot__in--narrow">
- <option v-for="opt in WIDGET_TYPE_OPTIONS" :key="opt.type" :value="opt.type">
- {{ opt.label }}
- </option>
- </select>
- </label>
- <label v-if="composerType !== 'section-break'" class="ot__field">
- <span>Width (span)</span>
- <input v-model.number="composerSpan" type="number" min="1" max="12" class="ot__in ot__in--num" />
- </label>
- <label v-if="composerType !== 'section-break'" class="ot__field">
- <span>Height (rows)</span>
- <input v-model.number="composerRowSpan" type="number" min="1" max="12" class="ot__in ot__in--num" />
- </label>
- </div>
- <div class="ot__composer-foot">
- <span class="ot__composer-hint">
- A default title + content scaffold is generated; you can edit everything below after creating.
- </span>
- <button type="button" class="ot__btn" @click="cancelComposer">cancel</button>
- <button type="button" class="ot__btn ot__btn--primary" @click="createWidget">
- create
- </button>
- </div>
- </article>
- </div>
- </div>
-
- <!-- Layout + mock-data preview. Sits on its own tab so it
- gets the full pane width, no scroll-past-editor needed. -->
- <section v-if="viewMode === 'preview'" class="ot__preview">
- <header class="ot__preview-head">
- <h3>Preview</h3>
- <span class="ot__preview-hint">layout + mock data · not live OAP values</span>
+ </aside>
+
+ <!-- Canvas: mock-data widget grid (left). Click a widget to edit
+ it in the drawer; only the live "Preview ▾" page uses real
+ OAP data. -->
+ <div class="ot__canvas-pane ot__preview">
+ <div class="ot__canvas-hint">layout + mock data · click a widget to edit · the live page uses real data</div>
+ <!-- Page heading preview — the dashboard title + description as
+ they render on the live overview page. Click to edit the
+ dashboard meta in the drawer, like any widget. -->
+ <header
+ class="ot__canvas-title"
+ :class="{ 'ot__cw--sel': selectedWidgetId === META_SEL }"
+ @click="selectWidget(META_SEL)"
+ >
+ <h2>{{ draft.title || draft.id }}</h2>
+ <p v-if="draft.description" class="ot__canvas-desc">{{ draft.description }}</p>
</header>
<div
v-for="(sec, si) in previewSections"
:key="si"
class="ot__pv-section"
>
- <div v-if="previewSectionTitle(si)" class="ot__pv-section-head">
- {{ previewSectionTitle(si) }}
+ <!-- Section break (a.k.a. line-break / text header) — a real
+ selectable widget; click to edit its title + columns. -->
+ <div
+ v-if="sec.sb"
+ class="ot__pv-section-head ot__pv-sb"
+ :class="{ 'ot__cw--sel': selectedWidgetId === sec.sb.id, 'ot__cw--drag': dragId === sec.sb.id }"
+ draggable="true"
+ @click="selectWidget(sec.sb.id)"
+ @dragstart="onWidgetDragStart($event, sec.sb.id)"
+ @dragend="dragId = null"
+ @dragover.prevent
+ @drop.prevent="onWidgetDrop(sec.sb.id)"
+ >
+ <span class="ot__pv-sb-tag">section</span>
+ <span class="ot__pv-sb-title">{{ sec.sb.title || '(untitled section)' }}</span>
+ <span class="ot__pv-sb-cols">{{ sec.cols }} cols</span>
</div>
<div
class="ot__pv-grid"
:style="{ gridTemplateColumns: `repeat(${sec.cols}, minmax(0, 1fr))` }"
>
- <article
+ <!-- Each widget is wrapped in a selectable cell. The inner
+ preview (the real overview component, mock data) is
+ pointer-events:none so a click selects the widget for
+ editing instead of following KpiTileWidget's link. -->
+ <div
v-for="w in sec.widgets"
:key="w.id"
- class="ot__pv-card"
- :style="{
- gridColumn: `span ${Math.min(w.span ?? 12, sec.cols)}`,
- gridRow: `span ${w.rowSpan ?? 1}`,
- }"
+ class="ot__cw"
+ :class="{ 'ot__cw--sel': selectedWidgetId === w.id, 'ot__cw--drag': dragId === w.id }"
+ :style="previewCellStyle(w, sec.cols)"
+ draggable="true"
+ @click="selectWidget(w.id)"
+ @dragstart="onWidgetDragStart($event, w.id)"
+ @dragend="dragId = null"
+ @dragover.prevent
+ @drop.prevent="onWidgetDrop(w.id)"
>
- <div class="ot__pv-card-head">
- <span class="ot__pv-kind">{{ widgetKindLabel(w.type) }}</span>
- <span v-if="w.layer" class="ot__pv-layer">{{ w.layer }}</span>
- </div>
- <div class="ot__pv-title">{{ w.title }}</div>
- <!-- Per-type mock body. -->
- <template v-if="w.type === 'metric'">
- <div class="ot__pv-bignum">
- {{ mockNumber(w.id) }}<span v-if="w.unit" class="ot__pv-unit">{{ w.unit }}</span>
+ <MetricWidget
+ v-if="w.type === 'metric'"
+ :title="w.title"
+ :tip="w.tip"
+ :value="mockNumber(w.id)"
+ :unit="w.unit"
+ />
+ <KpiTileWidget
+ v-else-if="w.type === 'kpi-tile'"
+ :title="w.title"
+ :tip="w.tip"
+ :layer="w.layer"
+ :show-count="w.showCount ?? false"
+ :count="mockNumber(w.id, 30)"
+ :kpis="w.kpis ?? []"
+ :kpi-values="mockKpiValues(w)"
+ />
+ <MetricCompositeWidget
+ v-else-if="w.type === 'metric-composite'"
+ :title="w.title"
+ :tip="w.tip"
+ :layer="w.layer"
+ :kpis="w.kpis ?? []"
+ :kpi-values="mockKpiValues(w)"
+ />
+ <article v-else class="ot__pv-card">
+ <div class="ot__pv-card-head">
+ <span class="ot__pv-kind">{{ widgetKindLabel(w.type) }}</span>
+ <span v-if="w.layer" class="ot__pv-layer">{{ w.layer }}</span>
</div>
- <code v-if="w.mqe" class="ot__pv-mqe">{{ w.mqe }}</code>
- </template>
- <template v-else-if="w.type === 'alarms'">
+ <div class="ot__pv-title">{{ w.title }}</div>
+ <!-- Per-type mock body. -->
+ <template v-if="w.type === 'alarms'">
<!-- Mock alarm rows — same shape as AlarmsWidget so
the operator sees the rail layout, not just a
count. Severity / message / scope all mocked. -->
@@ -973,103 +1356,135 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
</svg>
<div class="ot__pv-sub">topology · {{ w.layer ?? '—' }}</div>
</template>
- <template v-else-if="w.type === 'kpi-tile' || w.type === 'metric-composite'">
- <!-- Split layout: number-style → count tiles grid;
- progress-bar style → bars grid. Mirrors how the
- real MetricCompositeWidget partitions its KPIs. -->
- <div v-if="(w.kpis ?? []).length === 0" class="ot__pv-empty">
- no KPI rows configured
- </div>
- <template v-else>
- <div
- v-if="kpiCountRows(w).length > 0"
- class="ot__pv-num-grid"
- >
- <div
- v-for="k in kpiCountRows(w)"
- :key="k.label"
- class="ot__pv-num-tile"
- >
- <span class="ot__pv-num-val">
- {{ mockNumber(w.id + k.label) }}<span v-if="k.unit" class="ot__pv-num-unit">{{ k.unit }}</span>
- </span>
- <span class="ot__pv-num-label">{{ k.label }}</span>
- </div>
- </div>
- <div
- v-if="kpiBarRows(w).length > 0"
- class="ot__pv-bar-grid"
- >
- <div
- v-for="k in kpiBarRows(w)"
- :key="k.label"
- class="ot__pv-bar-row"
- >
- <div class="ot__pv-bar-head">
- <span class="ot__pv-bar-label">{{ k.label }}</span>
- <span class="ot__pv-bar-val">
- {{ mockPercent(w.id + k.label) }}{{ k.unit ?? '%' }}
- </span>
- </div>
- <div class="ot__pv-bar">
- <span
- class="ot__pv-bar-fill"
- :style="{ width: mockPercent(w.id + k.label) + '%' }"
- />
- </div>
- </div>
- </div>
- </template>
- </template>
- </article>
+ </article>
+ <!-- Corner resize handle (drag to change span / rowSpan). -->
+ <span
+ class="ot__cw-resize"
+ title="Drag to resize"
+ @mousedown="onResizeStart($event, w, sec.cols)"
+ />
+ </div>
</div>
</div>
<div v-if="previewSections.length === 0" class="ot__pv-empty">
- No widgets yet — add one above to see the preview.
+ No widgets yet — add one below.
+ </div>
+ <!-- Add-widget composer, on the canvas (the drawer only shows
+ when a widget is selected). -->
+ <div class="ot__add-widget">
+ <button
+ v-if="!composerOpen"
+ type="button"
+ class="ot__add-trigger"
+ @click="openComposer"
+ >
+ + Add widget
+ </button>
+ <article v-else class="ot__widget ot__composer">
+ <header class="ot__widget-head">
+ <span class="ot__widget-kind">New widget</span>
+ </header>
+ <div class="ot__row">
+ <label class="ot__field">
+ <span>Type</span>
+ <select v-model="composerType" class="ot__in ot__in--narrow">
+ <option v-for="opt in WIDGET_TYPE_OPTIONS" :key="opt.type" :value="opt.type">
+ {{ opt.label }}
+ </option>
+ </select>
+ </label>
+ <label v-if="composerType !== 'section-break'" class="ot__field">
+ <span>Width (span)</span>
+ <input v-model.number="composerSpan" type="number" min="1" max="12" class="ot__in ot__in--num" />
+ </label>
+ <label v-if="composerType !== 'section-break'" class="ot__field">
+ <span>Height (rows)</span>
+ <input v-model.number="composerRowSpan" type="number" min="1" max="12" class="ot__in ot__in--num" />
+ </label>
+ </div>
+ <div class="ot__composer-foot">
+ <span class="ot__composer-hint">
+ A default title + content scaffold is generated; you can edit everything after creating.
+ </span>
+ <button type="button" class="ot__btn" @click="cancelComposer">cancel</button>
+ <button type="button" class="ot__btn ot__btn--primary" @click="createWidget">create</button>
+ </div>
+ </article>
</div>
- </section>
-
- <div class="ot__actions">
- <span v-if="flash" class="ot__flash">{{ flash }}</span>
- <span v-else-if="isDirty" class="ot__dirty">unsaved changes</span>
- <span v-else class="ot__clean">saved</span>
- <button type="button" class="ot__btn" :disabled="!isDirty || saving" @click="onReset">
- reset
- </button>
- <button
- v-if="isDiverged && !sync.readOnly.value"
- type="button"
- class="ot__btn"
- title="Show side-by-side diff vs OAP, and reset OAP back to bundled (with confirmation)."
- @click="openDiffModal"
- >
- show diff & reset
- </button>
- <button
- type="button"
- class="ot__btn ot__btn--primary"
- :disabled="!isDirty || saving"
- title="Save this edit to the local bundled template. Publish it to OAP for everyone with “Sync all to OAP”."
- @click="onSave"
- >
- {{ saving ? 'saving…' : 'save locally' }}
- </button>
+ </div>
</div>
</template>
</section>
</div>
- <TemplateDiffModal
- v-if="selectedId"
- :open="diffModalOpen"
- :name="`horizon.overview.${selectedId}`"
- :confirm-key="selectedId"
- @close="diffModalOpen = false"
- @reset="onDiffReset"
- />
+ <!-- Push confirm: shows the remote → local diff before publishing. -->
+ <Modal :open="pushDiffOpen" title="Publish local → OAP?" width="min(1100px, 94vw)" @close="pushDiffOpen = false">
+ <p class="ot__push-lede">
+ This replaces the live (remote) version with your local draft — live for everyone. Review the
+ diff (left = remote, right = your local):
+ </p>
+ <div class="ot__push-diff">
+ <MonacoDiff :original="pushRemotePretty" :modified="pushLocalPretty" language="json" />
+ </div>
+ <template #footer>
+ <button class="sw-btn" type="button" @click="pushDiffOpen = false">Cancel</button>
+ <button class="sw-btn is-primary" type="button" :disabled="saving" @click="pushToOap">
+ {{ saving ? 'Pushing…' : 'Confirm push' }}
+ </button>
+ </template>
+ </Modal>
+
+ <!-- Delete / disable confirm — styled (not the native confirm box). -->
+ <Modal :open="deleteOpen" :title="deleteTitle" width="min(520px, 92vw)" @close="deleteOpen = false">
+ <p class="ot__confirm-msg">{{ deleteMessage }}</p>
+ <template #footer>
+ <button class="sw-btn" type="button" @click="deleteOpen = false">Cancel</button>
+ <button class="sw-btn is-danger" type="button" @click="confirmDeleteDash">{{ deleteConfirmLabel }}</button>
+ </template>
+ </Modal>
</div>
</template>
+<style scoped>
+.ot__confirm-msg { margin: 0; font-size: 13px; line-height: 1.55; color: var(--sw-fg-1); }
+.sw-btn.is-danger { border-color: rgba(239, 68, 68, 0.4); color: #f87171; }
+.sw-btn.is-danger:hover { background: var(--sw-err-soft, rgba(239, 68, 68, 0.12)); }
+.reset-dd { position: relative; display: inline-flex; }
+.reset-dd .caret { display: inline-block; transform: rotate(90deg); font-size: 11px; }
+.reset-dd .caret.open { transform: rotate(-90deg); }
+.reset-dd-backdrop { position: fixed; inset: 0; z-index: 40; }
+.reset-dd-pop {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ z-index: 41;
+ min-width: 130px;
+ padding: 4px;
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 6px;
+ box-shadow: 0 10px 28px -8px rgba(0, 0, 0, 0.5);
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.reset-dd-item {
+ text-align: left;
+ padding: 6px 10px;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ color: var(--sw-fg-1);
+ font: inherit;
+ font-size: 12px;
+ cursor: pointer;
+}
+.reset-dd-item:hover:not(:disabled) { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.reset-dd-item:disabled { opacity: 0.4; cursor: not-allowed; }
+.ot__push-lede { margin: 0 0 10px; font-size: 12px; color: var(--sw-fg-2); line-height: 1.5; }
+.ot__push-diff { height: 50vh; min-height: 320px; border: 1px solid var(--sw-line); border-radius: 6px; overflow: hidden; }
+</style>
+
<style scoped>
.ot { padding: 20px 20px 60px; max-width: 1400px; margin: 0 auto; }
.ot__head { margin-bottom: 18px; }
@@ -1086,6 +1501,57 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
.ot__empty { padding: 32px; text-align: center; color: var(--sw-fg-3); font-size: 12px; }
.ot__toolbar { display: flex; justify-content: flex-end; margin: 8px 0; }
+.ot__main { display: flex; flex-direction: column; gap: 12px; }
+.ot__picker-bar { display: flex; align-items: center; gap: 10px; }
+.ot__picker-count { font-size: 10.5px; color: var(--sw-fg-3); }
+.ot__dd { position: relative; display: flex; }
+.ot__dd-btn {
+ display: inline-flex; align-items: center; gap: 8px;
+ height: 32px; padding: 0 12px;
+ background: var(--sw-bg-2); border: 1px solid var(--sw-line-2); border-radius: 6px;
+ color: var(--sw-fg-0); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; min-width: 240px;
+}
+.ot__dd-btn:hover { background: var(--sw-bg-3); }
+.ot__dd-btn .ot__dd-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px; }
+.ot__dd-btn .ot__dd-id { font-family: var(--sw-mono); font-size: 10px; color: var(--sw-fg-3); background: var(--sw-bg-1); padding: 1px 5px; border-radius: 3px; }
+.ot__dd-btn .caret { margin-left: auto; transform: rotate(90deg); transition: transform 0.15s; font-size: 12px; }
+.ot__dd-btn .caret.open { transform: rotate(-90deg); }
+.ot__dd-backdrop { position: fixed; inset: 0; z-index: 40; }
+.ot__dd-pop {
+ position: absolute; top: calc(100% + 4px); left: 0; z-index: 41;
+ width: min(80vw, 560px); max-height: 60vh; display: flex; flex-direction: column;
+ background: var(--sw-bg-1); border: 1px solid var(--sw-line); border-radius: 8px;
+ box-shadow: 0 12px 32px -8px rgba(0,0,0,0.5); padding: 6px;
+}
+.ot__dd-filters { display: flex; gap: 12px; padding: 2px 4px 8px; border-bottom: 1px solid var(--sw-line); margin-bottom: 4px; }
+.ot__dd-filter { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--sw-fg-2); cursor: pointer; }
+.ot__dd-filter.on { color: var(--sw-fg-0); }
+.ot__dd-filter input:disabled { cursor: not-allowed; }
+.ot__dd-fc { padding: 0 5px; border-radius: 8px; background: var(--sw-warn, var(--sw-accent)); color: #1a1a1a; font-size: 9.5px; font-weight: 700; }
+.ot__dd-search { padding: 2px 2px 6px; }
+.ot__dd-search input {
+ width: 100%; box-sizing: border-box; height: 30px; padding: 0 8px;
+ background: var(--sw-bg-2); border: 1px solid var(--sw-line-2); border-radius: 5px; color: var(--sw-fg-0); font: inherit; font-size: 12px;
+}
+.ot__dd-search input:focus { outline: none; border-color: var(--sw-accent); }
+.ot__dd-list { overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
+.ot__dd-item {
+ display: flex; align-items: center; gap: 8px; width: 100%;
+ padding: 7px 9px; border: none; background: transparent; border-radius: 5px;
+ color: var(--sw-fg-1); font: inherit; font-size: 12px; text-align: left; cursor: pointer;
+}
+.ot__dd-item:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.ot__dd-item.active { background: var(--sw-accent-soft); color: var(--sw-accent-2); box-shadow: inset 2px 0 0 var(--sw-accent); }
+.ot__dd-item-title { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.ot__dd-item code { font-family: var(--sw-mono); font-size: 10px; color: var(--sw-fg-3); }
+.ot__dd-item-count { font-family: var(--sw-mono); font-size: 10px; color: var(--sw-fg-3); }
+.ot__dd-empty { padding: 10px; font-size: 11px; color: var(--sw-fg-3); }
+.ot__local-badge {
+ flex: 0 0 auto; font-size: 9px; font-weight: 700; letter-spacing: 0.06em;
+ text-transform: uppercase; color: #1a1a1a; background: var(--sw-warn, #f59e0b);
+ padding: 1px 6px; border-radius: 8px;
+}
+.ot__dd-new { border-top: 1px solid var(--sw-line); margin-top: 4px; padding: 8px 2px 2px; }
.ot__split { display: grid; grid-template-columns: 280px 1fr; gap: 16px; align-items: start; }
.ot__list {
list-style: none; margin: 0; padding: 0;
@@ -1103,9 +1569,16 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
.ot__list-empty { padding: 24px; text-align: center; font-size: 12px; color: var(--sw-fg-3); }
.ot__list-add { padding: 8px; border-top: 1px solid var(--sw-line); }
.ot__add-trigger--list { font-size: 11.5px; padding: 6px; }
-.ot__newdash { display: flex; flex-direction: column; gap: 6px; padding: 6px; }
+.ot__newdash { display: flex; flex-direction: column; gap: 10px; padding: 10px 12px; }
+.ot__newdash-head {
+ font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
+ color: var(--sw-fg-2); padding-bottom: 8px; border-bottom: 1px solid var(--sw-line);
+}
.ot__newdash-err { font-size: 11px; color: var(--sw-err); }
-.ot__newdash-foot { display: flex; gap: 6px; justify-content: flex-end; }
+.ot__newdash-foot {
+ display: flex; gap: 8px; justify-content: flex-end;
+ padding-top: 8px; border-top: 1px solid var(--sw-line);
+}
.ot__head-btn {
background: transparent;
border: 1px solid var(--sw-line-2);
@@ -1183,7 +1656,14 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
border-bottom: 1px dashed var(--sw-line);
margin-bottom: 6px;
}
-.ot__pv-grid { display: grid; gap: 6px; }
+/* Mirror the live renderer's section grid (OverviewDashboardView): fixed
+ 72px auto-rows so `rowSpan` is real height and `span` columns sit
+ side-by-side, instead of collapsing to content height (which stacks a
+ span-9 topology above a span-3 alarms rather than beside it). */
+.ot__pv-grid { display: grid; grid-auto-rows: 72px; gap: 8px; }
+/* Inner widget fills its spanned cell, like the live page (grid items
+ stretch by default; the inner component must take the full height). */
+.ot__cw > *:not(.ot__cw-resize) { height: 100%; box-sizing: border-box; }
.ot__pv-card {
background: var(--sw-bg-1);
border: 1px solid var(--sw-line);
@@ -1317,10 +1797,89 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
}
.ot__detail { background: var(--sw-bg-1); border: 1px solid var(--sw-line); border-radius: 8px; padding: 16px; }
-.ot__detail-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; }
+/* One-page editor: canvas (left, mock-data grid) + drawer (right, editor).
+ DOM order is drawer-then-canvas; `order` flips them visually. */
+.ot__editor-split { display: flex; gap: 14px; align-items: flex-start; }
+.ot__canvas-pane { order: 1; flex: 1 1 0; min-width: 0; }
+.ot__drawer-pane {
+ order: 2;
+ flex: 0 0 380px;
+ max-width: 380px;
+ position: sticky;
+ top: 12px;
+ max-height: calc(100vh - 90px);
+ overflow-y: auto;
+ border-left: 1px solid var(--sw-line);
+ padding-left: 14px;
+}
+.ot__canvas-hint { font-size: 10.5px; color: var(--sw-fg-3); margin-bottom: 8px; }
+.ot__canvas-title {
+ margin-bottom: 12px; padding: 4px 6px 10px; cursor: pointer; border-radius: 6px;
+ /* Same selectable hint as the canvas cells. */
+ outline: 1px dashed var(--sw-line-2); outline-offset: 2px;
+}
+.ot__canvas-title:hover { background: var(--sw-bg-2); outline-color: var(--sw-fg-3); }
+.ot__canvas-title h2 { margin: 0; font-size: 17px; font-weight: 600; color: var(--sw-fg-0); }
+.ot__canvas-desc { margin: 4px 0 0; font-size: 12px; color: var(--sw-fg-2); line-height: 1.5; max-width: 760px; }
+.ot__drawer-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--sw-line); }
+.ot__drawer-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--sw-fg-2); font-weight: 600; }
+.ot__drawer-close { margin-left: auto; background: transparent; border: none; color: var(--sw-fg-3); font-size: 14px; cursor: pointer; width: 22px; height: 22px; border-radius: 4px; }
+.ot__drawer-close:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+/* Canvas cell: selectable wrapper. The inner preview is pointer-events:none
+ so clicking selects the widget (instead of following its link). */
+.ot__cw {
+ position: relative;
+ min-width: 0;
+ cursor: grab;
+ border-radius: 8px;
+ /* Dashed hint that the tile is selectable; firms up on hover, becomes a
+ solid accent outline when selected. */
+ outline: 1px dashed var(--sw-line-2);
+ outline-offset: 2px;
+}
+.ot__cw:hover { outline-color: var(--sw-fg-3); }
+.ot__cw:active { cursor: grabbing; }
+.ot__cw > * { pointer-events: none; }
+.ot__cw--sel { outline: 2px solid var(--sw-accent) !important; outline-offset: 1px; border-radius: 8px; }
+.ot__cw--drag { opacity: 0.45; }
+/* Corner resize handle — overrides the cell's pointer-events:none so it
+ can capture its own mousedown. */
+.ot__cw-resize {
+ pointer-events: auto;
+ position: absolute;
+ right: 2px;
+ bottom: 2px;
+ width: 14px;
+ height: 14px;
+ cursor: nwse-resize;
+ border-right: 2px solid var(--sw-fg-3);
+ border-bottom: 2px solid var(--sw-fg-3);
+ border-bottom-right-radius: 3px;
+ opacity: 0.5;
+}
+.ot__cw:hover .ot__cw-resize { opacity: 0.9; }
+/* Section-break rendered as a selectable header in the canvas. */
+.ot__pv-sb {
+ display: flex; align-items: center; gap: 8px; cursor: pointer;
+ border-radius: 5px; padding: 5px 8px; margin-bottom: 6px;
+ /* Same selectable hint as the canvas cells. */
+ outline: 1px dashed var(--sw-line-2); outline-offset: 2px;
+}
+.ot__pv-sb:hover { background: var(--sw-bg-2); outline-color: var(--sw-fg-3); }
+.ot__pv-sb-tag {
+ font-size: 8.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
+ color: var(--sw-accent); border: 1px solid var(--sw-accent-line, var(--sw-accent));
+ border-radius: 3px; padding: 0 4px;
+}
+.ot__pv-sb-title { flex: 1; font-weight: 600; color: var(--sw-fg-0); text-transform: none; letter-spacing: 0; }
+.ot__pv-sb-cols { font-size: 9.5px; color: var(--sw-fg-3); font-family: var(--sw-mono); }
+.ot__detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
+.ot__head-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
+.ot__detail-subhead { display: flex; align-items: center; justify-content: flex-end; gap: 10px; margin-bottom: 12px; }
+.ot__del-note { font-size: 10.5px; color: var(--sw-fg-3); font-style: italic; }
.ot__detail-head h2 { margin: 0; font-size: 13px; font-weight: 600; }
.ot__detail-head h2 code { font-family: var(--sw-mono); color: var(--sw-fg-0); }
-.ot__count { font-size: 11px; color: var(--sw-fg-3); margin-left: auto; }
+.ot__count { font-size: 11px; color: var(--sw-fg-3); }
/* Per-widget card */
.ot__widgets { display: flex; flex-direction: column; gap: 10px; }
@@ -1451,17 +2010,28 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
padding: 2px 10px; border-radius: 4px; cursor: pointer;
}
.ot__add-btn:hover { color: var(--sw-fg-0); border-style: solid; }
-.ot__kpi-table { width: 100%; border-collapse: collapse; }
-.ot__kpi-table thead th {
- text-align: left; font-size: 9px;
- text-transform: uppercase; letter-spacing: 0.06em;
- color: var(--sw-fg-3); font-weight: 600;
- padding: 4px 6px;
- border-bottom: 1px solid var(--sw-line);
+/* KPI rows as stacked cards (the drawer is sidebar-narrow — a wide table
+ row truncates every column). One bordered card per row; full-width
+ Label/Source/MQE, then a 2-col Unit/Aggr/Style/Max grid. */
+.ot__kpi-cards { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
+.ot__kpi-card {
+ background: var(--sw-bg-2); border: 1px solid var(--sw-line);
+ border-radius: 5px; padding: 8px 10px;
+ display: flex; flex-direction: column; gap: 8px;
+}
+.ot__kpi-card-head { display: flex; align-items: center; gap: 6px; }
+.ot__kpi-card-idx {
+ font-size: 10px; font-weight: 700; font-family: var(--sw-mono);
+ color: var(--sw-fg-3);
}
-.ot__kpi-table tbody td { padding: 4px; vertical-align: middle; }
-.ot__kpi-table input.ot__in,
-.ot__kpi-table select.ot__in { width: 100%; min-width: 0; }
+.ot__kpi-card-ord { display: inline-flex; gap: 2px; margin-left: auto; }
+.ot__kpi-card-grid {
+ display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
+}
+.ot__kpi-card .ot__field { min-width: 0; }
+.ot__kpi-card .ot__field--full { grid-column: 1 / -1; }
+.ot__kpi-card .ot__in { width: 100%; box-sizing: border-box; }
+.ot__kpi-card .ot__none { margin: 0; }
.ot__arrow {
background: transparent; border: 0;
color: var(--sw-fg-2); font: inherit; font-size: 12px;
@@ -1508,10 +2078,19 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
font-style: italic;
}
-.ot__actions { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
-.ot__flash { font-size: 11px; color: var(--sw-ok); margin-right: auto; }
-.ot__dirty { font-size: 11px; color: var(--sw-warn); margin-right: auto; }
-.ot__clean { font-size: 11px; color: var(--sw-fg-3); margin-right: auto; }
+.ot__flash { font-size: 11px; color: var(--sw-ok); }
+.ot__dirty { font-size: 11px; color: var(--sw-warn); }
+.ot__src {
+ font-size: 9.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--sw-fg-3);
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ padding: 2px 7px;
+}
+.ot__clean { font-size: 11px; color: var(--sw-fg-3); }
.ot__btn {
background: var(--sw-bg-1); border: 1px solid var(--sw-line-2);
color: var(--sw-fg-0); font: inherit; font-size: 12px;
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index c1adf7c..372a132 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -27,8 +27,11 @@
-->
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
import type { LayerDef } from '@skywalking-horizon-ui/api-client';
+import { bffClient } from '@/api/client';
+import { useAuthStore } from '@/state/auth';
import Icon from '@/components/icons/Icon.vue';
import Sparkline from '@/components/charts/Sparkline.vue';
import LayerServiceSelector from './LayerServiceSelector.vue';
@@ -36,6 +39,7 @@ import { metricMeta } from '@/utils/metricCatalog';
import { colorForMetric } from '@/utils/metricColor';
import { useLayerLanding } from '@/layer/useLayerLanding';
import { useLayers, firstLayerTab } from '@/shell/useLayers';
+import { layerContentToDef, type LayerTemplateContent } from '@/shell/layerFromTemplate';
import { useSelectedService } from '@/layer/useSelectedService';
import { useLayerSelectionStore } from '@/state/layerSelection';
import { useSetupStore } from '@/state/setup';
@@ -78,21 +82,55 @@ watch(
{ immediate: true },
);
const { layers, isLoading: layersLoading } = useLayers();
-const layer = computed<LayerDef | null>(() => {
- const found = layers.value.find((l) => l.key === layerKey.value);
- return found ?? null;
+// Case-insensitive: URL layer keys are lowercase, registry keys UPPER_SNAKE.
+// In preview mode `useLayers` overlays/injects the previewed layer here, so
+// this already reflects the draft's components (tab visibility).
+const menuLayer = computed<LayerDef | null>(
+ () => layers.value.find((l) => l.key.toUpperCase() === layerKey.value.toUpperCase()) ?? null,
+);
+
+// Preview mode (`?mode=preview`) — render a configured-but-inactive
+// layer (OAP reports no services, so it's absent from the live menu)
+// instead of the "Layer not found" gate. Admin-only: the fallback is
+// synthesized from the layer template (admin data), so operators /
+// viewers still hit the normal gate. Empty metrics are expected — there
+// are no services to query.
+const auth = useAuthStore();
+const isAdmin = computed<boolean>(() => !!auth.user?.roles?.includes('admin'));
+// Single canonical form: `?mode=preview`. The admin "Open live page"
+// link generates exactly this.
+const previewAllowed = computed<boolean>(() => route.query.mode === 'preview' && isAdmin.value);
+const wantPreviewFallback = computed<boolean>(
+ () => previewAllowed.value && !menuLayer.value && !!layerKey.value,
+);
+const tplQuery = useQuery({
+ queryKey: ['layer-preview-templates'],
+ queryFn: () => bffClient.layerTemplates.list(),
+ enabled: wantPreviewFallback,
+ staleTime: 60_000,
});
+const previewLoading = computed<boolean>(() => wantPreviewFallback.value && tplQuery.isLoading.value);
+const previewLayer = computed<LayerDef | null>(() => {
+ if (!wantPreviewFallback.value) return null;
+ const t = tplQuery.data.value?.templates.find((x) => x.key.toUpperCase() === layerKey.value.toUpperCase());
+ return t ? layerContentToDef(t as unknown as LayerTemplateContent) : null;
+});
+const layer = computed<LayerDef | null>(() => menuLayer.value ?? previewLayer.value);
+
// Distinguishes "still loading" from "truly absent" so the "Layer
// not found" card doesn't flash during a login → layer-URL redirect
// (the menu fetch is in-flight and `layers.value` is briefly []).
// We treat the layer as missing only after the menu request has
-// settled AND the layer key still isn't in the result.
-const layerMissing = computed<boolean>(
- () => !layersLoading.value && layers.value.length > 0 && layer.value === null,
-);
+// settled AND — in preview mode — the template fetch has settled too.
const menuStillLoading = computed<boolean>(
- () => !layer.value && (layersLoading.value || layers.value.length === 0),
+ () => !menuLayer.value && (layersLoading.value || layers.value.length === 0),
);
+const layerMissing = computed<boolean>(() => {
+ if (layer.value) return false;
+ if (menuStillLoading.value) return false;
+ if (previewLoading.value) return false;
+ return true;
+});
// Auto-redirect when the URL targets a sub-route the layer doesn't
// support — e.g. `/layer/mesh_dp/service` on a layer with
@@ -211,6 +249,17 @@ const selectedName = computed(() => {
// say) can flip the same meta flag without touching this file.
const viewOwnsServiceSelector = computed(() => Boolean(route.meta?.ownsServiceSelector));
+// Zipkin trace mode is a self-contained, cross-service explorer (its
+// own service/duration/annotation filters), so the layer identity +
+// service KPI header adds nothing and crowds it out — hide the shell
+// header there. Mirrors LayerTracesEntry's zipkin decision: the
+// `/zipkin-trace` route always, plus the `/trace` route on a pure-
+// zipkin-source layer (mesh / k8s).
+const isZipkinTrace = computed<boolean>(() => {
+ if (/\/zipkin-trace(\/|$|\?)/.test(route.path)) return true;
+ return scopeSegment.value === 'trace' && layer.value?.traces?.source === 'zipkin';
+});
+
// Keep the URL-backed service selection honest for every page that
// uses the shell picker. A stale `?service=` can survive navigation or
// manual URL entry; the switch label used to fall back visually to the
@@ -373,7 +422,7 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
<template>
<div class="layer-shell">
- <header v-if="layer" class="sw-card layer-head">
+ <header v-if="layer && !isZipkinTrace" class="sw-card layer-head">
<!-- Row 1: layer identity (left) + LAYER aggregate KPI strip
(right). The aggregates use sum/avg per the JSON columns'
aggregation field — sum cpm across services, avg p99 etc. -->
@@ -491,7 +540,7 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
<!-- Loading state for the menu fetch — appears in the login →
layer-URL redirect window where the menu is still in flight
and we can't yet tell whether the layer exists. -->
- <div v-if="menuStillLoading" class="missing">
+ <div v-if="menuStillLoading || previewLoading" class="missing">
<div class="sw-card missing-card">
<Icon name="event" :size="18" />
<div>
diff --git a/apps/ui/src/shell/AppShell.vue b/apps/ui/src/shell/AppShell.vue
index 6a9bf18..b5f941a 100644
--- a/apps/ui/src/shell/AppShell.vue
+++ b/apps/ui/src/shell/AppShell.vue
@@ -21,6 +21,7 @@ import AppSidebar from './AppSidebar.vue';
import AppTopbar from './AppTopbar.vue';
import DebugEventPanel from './DebugEventPanel.vue';
import GlobalConnectivityBanner from './GlobalConnectivityBanner.vue';
+import PreviewModeBanner from './PreviewModeBanner.vue';
import TracePopout from '@/layer/traces/TracePopout.vue';
import ZipkinTracePopout from '@/layer/traces/ZipkinTracePopout.vue';
import TemplateConflictPrompt from './TemplateConflictPrompt.vue';
@@ -28,6 +29,7 @@ import { ensureConfigBundle, useConfigBundle } from '@/controls/configBundle';
import { useClickTracking } from '@/controls/useClickTracking';
import { useLayers } from '@/shell/useLayers';
import { useDebugPanel } from '@/controls/debugPanel';
+import { useSidebar } from '@/controls/sidebar';
import { useThemeStore } from '@/state/theme';
import { useTimeDefaultsStore } from '@/state/timeDefaults';
import { useTimeRangeStore } from '@/controls/timeRange';
@@ -100,10 +102,12 @@ const initReady = computed<boolean>(
// transient overlay (operator dismisses it explicitly) — reserving
// 260px permanently would waste real estate.
const { enabled: debugPanelEnabled } = useDebugPanel();
+// Drives the `.sw` grid column width — see `.sw.side-collapsed` below.
+const { collapsed: sidebarCollapsed } = useSidebar();
</script>
<template>
- <div class="sw">
+ <div class="sw" :class="{ 'side-collapsed': sidebarCollapsed }">
<AppSidebar />
<AppTopbar />
<main class="sw-main" :class="{ 'has-debug-panel': debugPanelEnabled }">
@@ -111,6 +115,8 @@ const { enabled: debugPanelEnabled } = useDebugPanel();
(`:12800`) poll reports unreachable. Admin-port (`:17128`)
failures render per-page via AdminFeatureWarning, not here. -->
<GlobalConnectivityBanner />
+ <!-- Notice shown only while a page is in ?mode=preview. -->
+ <PreviewModeBanner />
<!-- Shell-level init placeholder. Visible until the layer
registry + config bundle have both loaded. Per-page code
runs against fully-populated state from the first paint. -->
@@ -149,6 +155,13 @@ const { enabled: debugPanelEnabled } = useDebugPanel();
* vertical real estate. */
.sw-main.has-debug-panel { padding-bottom: 26px; }
+/* Folded sidebar — collapse the grid's side column to a thin rail that
+ * holds just the expand affordance. The sidebar's own scoped styles
+ * hide the nav/labels at this width (see AppSidebar `.sw-side.collapsed`).
+ * Animates in step with the sidebar's own collapse transition. */
+.sw.side-collapsed { grid-template-columns: 48px 1fr; }
+.sw { transition: grid-template-columns 160ms ease; }
+
.sw-init {
padding: 48px 20px;
display: flex;
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index 5f862fe..8a97d59 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -33,10 +33,13 @@ import { useLayers, firstLayerTab } from '@/shell/useLayers';
import { useLandingOrder } from '@/shell/useLandingOrder';
import { useOverviewDashboards } from '@/render/overview/useOverviewDashboards';
import { useDebugPanel } from '@/controls/debugPanel';
+import { useSidebar } from '@/controls/sidebar';
import { useAlarmCount } from '@/shell/useAlarmCount';
import { useConfigBundle } from '@/controls/configBundle';
const { enabled: debugPanelEnabled, toggle: toggleDebugPanel } = useDebugPanel();
+// Fold the whole menu to a thin rail; the shell grid follows suit.
+const { collapsed: sidebarCollapsed, toggle: toggleSidebar } = useSidebar();
/* Shares the same composable as the topbar badge — one query feeds
* both, so the two surfaces never disagree and the cache is warm
* regardless of which renders first. */
@@ -132,14 +135,20 @@ const operateLayers = computed(() =>
const sidebarEntries = computed<SidebarEntry[]>(() => bucket(publicLayers.value));
const route = useRoute();
+// Case-insensitive: layer keys are UPPER_SNAKE in the menu/links but a
+// URL may arrive lowercased (overview KPI tiles link lowercased, hand-typed
+// URLs, etc.). Without normalising, the active row wouldn't get the accent
+// colour and the auto-scroll couldn't find it.
+const routePathLc = computed(() => route.path.toLowerCase());
function isActive(path: string): boolean {
- return route.path === path || route.path.startsWith(path + '/');
+ const p = path.toLowerCase();
+ return routePathLc.value === p || routePathLc.value.startsWith(p + '/');
}
/** Use this — not {@link isActive} — for sibling routes where one is
* a prefix of another (e.g. `/operate/live-debug` vs
* `/operate/live-debug/history`); prefix-match would light both up. */
function isActiveExact(path: string): boolean {
- return route.path === path;
+ return routePathLc.value === path.toLowerCase();
}
// Only the layer the route currently points at is auto-expanded. We do
@@ -147,29 +156,39 @@ function isActiveExact(path: string): boolean {
// expanding a section is an explicit user action (a click), and a
// default-open accordion misleads operators into thinking that layer is
// "selected" when they've navigated nowhere.
+const navRef = ref<HTMLElement | null>(null);
+/** Bring the route's active nav row into view (it may sit below the fold
+ * on a long sidebar). Waits a tick so the route-driven expand has
+ * rendered the L2 children that contain the active row. */
+async function scrollActiveIntoView(): Promise<void> {
+ await nextTick();
+ // Both the layer row and its active child carry `.is-active`; scroll to
+ // the LAST (deepest) one — the active tab — so it lands in view.
+ // Exclude `.sw-nav-toggle` (the bottom "Debug events" button is
+ // `.is-active` whenever the panel is on — default on localhost — and
+ // being last in the DOM it would otherwise scroll the sidebar to the
+ // very end on every navigation regardless of the clicked item).
+ const actives = navRef.value?.querySelectorAll('.is-active:not(.sw-nav-toggle)');
+ actives?.[actives.length - 1]?.scrollIntoView({ block: 'nearest' });
+}
+
+// Expand the layer the route points at AND scroll its active item into
+// view — on entering a layer page (deep-link, reload, or in-app nav),
+// not just the first paint. URL keys are lowercase; layer keys are
+// UPPER_SNAKE — match case-insensitively.
watch(
[() => route.path, orderedLayers],
([path, rows]) => {
const m = path.match(/^\/layer\/([^/]+)/);
if (m) {
- const key = m[1];
- const L = rows.find((l) => l.key === key);
- if (L) expandedLayer.value = key;
+ const L = rows.find((l) => l.key.toUpperCase() === m[1]!.toUpperCase());
+ if (L) expandedLayer.value = L.key;
}
+ void scrollActiveIntoView();
},
{ immediate: true },
);
-
-// On first paint, bring the route's selected nav item into view — on a
-// long sidebar the active layer/tab can land below the fold, so landing
-// deep-linked (or after a reload) would otherwise show no visible
-// selection. Wait a tick so the route-driven expand above has rendered
-// the L2 children that may contain the active row.
-const navRef = ref<HTMLElement | null>(null);
-onMounted(async () => {
- await nextTick();
- navRef.value?.querySelector('.is-active')?.scrollIntoView({ block: 'nearest' });
-});
+onMounted(scrollActiveIntoView);
interface NavRow {
icon: IconName;
@@ -347,13 +366,37 @@ watch(
</script>
<template>
- <aside class="sw-side">
- <RouterLink to="/" class="sw-brand" aria-label="SkyWalking Horizon">
- <span class="brand-logo" v-html="isLightAppearance ? logoSwBlue : logoSw" />
- <small>Horizon</small>
- </RouterLink>
+ <aside class="sw-side" :class="{ collapsed: sidebarCollapsed }">
+ <!-- Expanded: original wordmark header (unchanged) + collapse toggle.
+ Folded: the wordmark moves to the topbar (see AppTopbar); the rail
+ keeps just the expand chevron in the same 44px header row. -->
+ <div v-if="!sidebarCollapsed" class="sw-brand-row">
+ <RouterLink to="/" class="sw-brand" aria-label="SkyWalking Horizon">
+ <span class="brand-logo" v-html="isLightAppearance ? logoSwBlue : logoSw" />
+ <small>Horizon</small>
+ </RouterLink>
+ <button
+ type="button"
+ class="side-toggle"
+ title="Collapse menu"
+ aria-label="Collapse menu"
+ @click="toggleSidebar"
+ >
+ <Icon name="caret" :size="12" />
+ </button>
+ </div>
+ <button
+ v-else
+ type="button"
+ class="side-expand"
+ title="Show menu"
+ aria-label="Show menu"
+ @click="toggleSidebar"
+ >
+ <Icon name="caret" :size="12" />
+ </button>
- <nav ref="navRef" class="sw-nav">
+ <nav v-show="!sidebarCollapsed" ref="navRef" class="sw-nav">
<!-- Overviews are gated by `overview:read`. -->
<template v-if="auth.hasVerb('overview:read')">
<div class="sw-nav-section sw-nav-section--icon">
@@ -842,7 +885,7 @@ watch(
</template>
</nav>
- <div class="sw-side-foot">
+ <div v-show="!sidebarCollapsed" class="sw-side-foot">
<div class="sw-avatar">
{{ auth.user?.username ? auth.user.username.slice(0, 2).toUpperCase() : '?' }}
</div>
@@ -868,11 +911,58 @@ watch(
</template>
<style scoped>
+/* Brand + collapse toggle share the 44px top row (aligns with the
+ * topbar). Shown only while expanded; when folded the row is replaced by
+ * the expand chevron and the wordmark moves to the topbar. */
+.sw-brand-row {
+ display: flex;
+ align-items: center;
+ height: 44px;
+ border-bottom: 1px solid var(--sw-line);
+}
.sw-brand,
.sw-brand:hover {
text-decoration: none;
color: inherit;
+ /* Border + height now live on .sw-brand-row; override the global
+ * .sw-brand so we don't double the border or the height. */
+ flex: 1;
+ min-width: 0;
+ height: auto;
+ border-bottom: none;
+ overflow: hidden;
+}
+.side-toggle {
+ flex: 0 0 auto;
+ width: 26px;
+ height: 26px;
+ margin: 0 9px;
+ display: inline-grid;
+ place-items: center;
+ background: transparent;
+ border: none;
+ border-radius: 5px;
+ color: var(--sw-fg-3);
+ cursor: pointer;
+}
+.side-toggle:hover { background: var(--sw-bg-2); color: var(--sw-fg-1); }
+/* caret base glyph points down — rotate to a left chevron (collapse). */
+.side-toggle :deep(svg) { transition: transform 0.15s; transform: rotate(90deg); }
+/* Folded rail: the expand chevron occupies the 44px header row (aligns
+ * with the topbar, where the wordmark now lives). */
+.side-expand {
+ width: 100%;
+ height: 44px;
+ display: grid;
+ place-items: center;
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid var(--sw-line);
+ color: var(--sw-fg-3);
+ cursor: pointer;
}
+.side-expand:hover { background: var(--sw-bg-2); color: var(--sw-fg-1); }
+.side-expand :deep(svg) { transform: rotate(-90deg); }
.brand-logo {
display: inline-flex;
align-items: center;
diff --git a/apps/ui/src/shell/AppTopbar.vue b/apps/ui/src/shell/AppTopbar.vue
index 5b02983..02f0396 100644
--- a/apps/ui/src/shell/AppTopbar.vue
+++ b/apps/ui/src/shell/AppTopbar.vue
@@ -25,6 +25,13 @@ import { useTimeRangeStore, TIME_PRESETS, STEP_LIMITS, isValidRange, type TimeSt
import { useThemeStore, AVAILABLE_THEMES, type ThemeId } from '@/state/theme';
import { useAuthStore } from '@/state/auth';
import { useTimeDefaultsStore } from '@/state/timeDefaults';
+import { useSidebar } from '@/controls/sidebar';
+import logoSw from '@/assets/icons/logo-sw.svg?raw';
+
+// When the sidebar is folded its wordmark is hidden; surface the brand
+// (logo + name) here in the topbar's left zone instead.
+const { collapsed: sidebarCollapsed } = useSidebar();
+const logoSwBlue = logoSw.replace(/fill="#fff"/g, 'fill="#1368B3"');
// Per-user "Save as my default" / "Reset to org default" on the time
// picker. The org default is what the admin set on
@@ -49,6 +56,9 @@ function resetTimeDefaultToOrg(): void {
// 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 isLightAppearance = computed<boolean>(
+ () => AVAILABLE_THEMES.find((t) => t.id === themeStore.active)?.appearance === 'light',
+);
const themeMenuOpen = ref(false);
const themeChipEl = ref<HTMLElement | null>(null);
function toggleThemeMenu(): void { themeMenuOpen.value = !themeMenuOpen.value; }
@@ -391,10 +401,14 @@ function formatRangeStamp(ms: number, step: TimeStep): string {
<template>
<header class="sw-top">
- <!-- Left zone intentionally empty for now. The framework-event
- feed moved to a dedicated bottom-fixed panel (gated by the
- Admin → "Debug events" sidebar toggle) — see
- `controls/debugPanel.ts` + `shell/DebugEventPanel.vue`. -->
+ <!-- When the sidebar is folded, its wordmark is hidden — show the
+ brand (logo + name) here so the product identity stays visible.
+ Otherwise the left zone stays empty (the framework-event feed
+ moved to the bottom-fixed DebugEventPanel). -->
+ <RouterLink v-if="sidebarCollapsed" to="/" class="top-brand" aria-label="SkyWalking Horizon">
+ <span class="top-brand-logo" v-html="isLightAppearance ? logoSwBlue : logoSw" />
+ <small>Horizon</small>
+ </RouterLink>
<div class="sw-top-spacer" />
<div class="sw-top-actions">
<component
@@ -625,6 +639,23 @@ function formatRangeStamp(ms: number, step: TimeStep): string {
</template>
<style scoped>
+/* Brand shown in the topbar's left zone only while the sidebar is
+ folded — same wordmark + "Horizon" label as the sidebar header. */
+.top-brand {
+ display: inline-flex;
+ align-items: center;
+ text-decoration: none;
+ color: inherit;
+}
+.top-brand-logo { display: inline-flex; align-items: center; }
+.top-brand-logo :deep(svg) { height: 16px; width: auto; display: block; }
+.top-brand small {
+ font-weight: 500;
+ color: var(--sw-fg-2);
+ margin-left: 4px;
+ letter-spacing: 0.02em;
+}
+
/* Disabled state for global time-range / refresh chips when the
current page owns its own time range. Greys out without removing
the chip so the operator still sees the affordance + tooltip. */
diff --git a/apps/ui/src/shell/PreviewModeBanner.vue b/apps/ui/src/shell/PreviewModeBanner.vue
new file mode 100644
index 0000000..4a65c1c
--- /dev/null
+++ b/apps/ui/src/shell/PreviewModeBanner.vue
@@ -0,0 +1,110 @@
+<!--
+ 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.
+-->
+<!--
+ Shown only while a page is in `?mode=preview` — tells the operator that
+ the dashboard they're looking at is rendered from an UNPUBLISHED source
+ (their local draft / bundled default / remote), not the normal live view.
+ Dismissible (× button or Esc); re-appears when the previewed target
+ changes so each dashboard surfaces the notice once.
+-->
+<script setup lang="ts">
+import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
+import { useRoute } from 'vue-router';
+import Icon from '@/components/icons/Icon.vue';
+import { usePreviewMode, getPreviewSource } from '@/controls/previewMode';
+import { useLayers } from '@/shell/useLayers';
+import { useConfigBundle } from '@/controls/configBundle';
+
+const route = useRoute();
+const previewMode = usePreviewMode();
+const { layers } = useLayers();
+const { bundle } = useConfigBundle();
+
+/** Friendly name of the dashboard being previewed (layer name / overview
+ * title), from the current route. */
+const target = computed<string | null>(() => {
+ const lm = route.path.match(/^\/layer\/([^/]+)/);
+ if (lm) {
+ const key = lm[1]!;
+ return layers.value.find((l) => l.key.toLowerCase() === key.toLowerCase())?.name || key.toUpperCase();
+ }
+ const om = route.path.match(/^\/overview\/([^/]+)/);
+ if (om) {
+ const id = om[1]!;
+ return (bundle.value?.overviews ?? []).find((o) => o.id === id)?.title || id;
+ }
+ return null;
+});
+const source = computed<string>(() => getPreviewSource() ?? 'local');
+
+const dismissed = ref(false);
+const show = computed(() => previewMode.value && !dismissed.value && !!target.value);
+// Surface the notice once per previewed target — re-show when it changes.
+watch([target, source], () => (dismissed.value = false));
+
+function close(): void {
+ dismissed.value = true;
+}
+function onKey(e: KeyboardEvent): void {
+ if (e.key === 'Escape' && show.value) close();
+}
+onMounted(() => window.addEventListener('keydown', onKey));
+onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
+</script>
+
+<template>
+ <div v-if="show" class="preview-banner" role="status">
+ <Icon name="event" :size="13" />
+ <span class="preview-banner-text">
+ <b>Preview mode</b> — <b>{{ target }}</b> is loaded from your <b>{{ source }}</b> version, not the
+ published one. Only you see this; everyone else gets the live (remote) dashboard.
+ </span>
+ <button class="preview-banner-close" type="button" title="Close (Esc)" aria-label="Close" @click="close">✕</button>
+ </div>
+</template>
+
+<style scoped>
+.preview-banner {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ margin: 10px 14px 0;
+ padding: 7px 12px;
+ border: 1px solid var(--sw-accent-line, var(--sw-accent));
+ background: var(--sw-accent-soft);
+ border-radius: 7px;
+ font-size: 12px;
+ color: var(--sw-fg-1);
+}
+.preview-banner :deep(svg) { color: var(--sw-accent); flex: 0 0 auto; }
+.preview-banner-text { flex: 1; line-height: 1.45; }
+.preview-banner-text b { color: var(--sw-fg-0); }
+.preview-banner-close {
+ flex: 0 0 auto;
+ width: 22px;
+ height: 22px;
+ display: grid;
+ place-items: center;
+ background: transparent;
+ border: none;
+ border-radius: 5px;
+ color: var(--sw-fg-3);
+ font-size: 12px;
+ cursor: pointer;
+}
+.preview-banner-close:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+</style>
diff --git a/apps/ui/src/shell/TemplateConflictPrompt.vue b/apps/ui/src/shell/TemplateConflictPrompt.vue
index b766498..24af840 100644
--- a/apps/ui/src/shell/TemplateConflictPrompt.vue
+++ b/apps/ui/src/shell/TemplateConflictPrompt.vue
@@ -15,109 +15,112 @@
limitations under the License.
-->
<!--
- Per-session conflict prompt. When the operator lands with templates
- that diverge between the local bundled copy and the OAP-stored remote
- copy, ask once which version to render: their LOCAL (unpublished) edits
- or the REMOTE (live) version. The choice is global and per login
- session (reset on login). Until chosen, the runtime defaults to remote.
+ Per-session reminder for EDITORS who have unpublished local template
+ drafts in THIS browser. Live pages always render remote; a draft is only
+ previewed via the editor's "Preview live" entrance (?mode=preview). This
+ prompt just nudges the operator to the relevant edit page so they can
+ publish or discard. No "use local vs remote" choice — remote is always
+ the live source. Shown once per session (dismissible), only to users who
+ can edit the kind of draft they hold.
-->
<script setup lang="ts">
import { computed, ref } from 'vue';
+import { useRouter } from 'vue-router';
import Modal from '@/features/operate/_shared/Modal.vue';
-import { useConfigBundle, setTemplateRenderMode, refreshConfigBundle } from '@/controls/configBundle';
-import { useTemplatePreference } from '@/controls/templatePreference';
import { useLayers } from '@/shell/useLayers';
-import { bffClient } from '@/api/client';
+import { useConfigBundle } from '@/controls/configBundle';
+import { useLocalTemplateEdits } from '@/controls/localTemplateEdits';
+import { usePreviewMode } from '@/controls/previewMode';
+import { useAuthStore } from '@/state/auth';
-const { bundle } = useConfigBundle();
-const pref = useTemplatePreference();
+const router = useRouter();
+const previewMode = usePreviewMode();
const { layers } = useLayers();
+const { bundle } = useConfigBundle();
+const auth = useAuthStore();
+const { edits } = useLocalTemplateEdits();
-/** Diverged templates named from the operator's MENU perspective —
- * the layer's sidebar label / overview title, not the template file. */
-const divergedItems = computed<string[]>(() => {
- const badges = (bundle.value?.syncStatus?.badges ?? []).filter((b) => b.status === 'diverged');
+const canEditLayers = computed<boolean>(() => auth.hasVerb('dashboard:read'));
+const canEditOverviews = computed<boolean>(() => auth.hasVerb('overview:write'));
+
+interface DraftItem {
+ kind: 'layer' | 'overview';
+ key: string;
+ label: string;
+}
+/** Unpublished local drafts in this browser, restricted to kinds the
+ * user can edit, named from the menu perspective. */
+const draftItems = computed<DraftItem[]>(() => {
const overviews = bundle.value?.overviews ?? [];
- return badges.map((b) => {
- if (b.kind === 'layer') {
- const L = layers.value.find((l) => l.key.toUpperCase() === b.key.toUpperCase());
- return L?.name ? `Layer · ${L.name}` : `Layer · ${b.key}`;
+ const out: DraftItem[] = [];
+ for (const name of Object.keys(edits.value)) {
+ const [, kind, ...rest] = name.split('.');
+ const key = rest.join('.');
+ if (kind === 'layer') {
+ if (!canEditLayers.value) continue;
+ const L = layers.value.find((l) => l.key.toUpperCase() === key.toUpperCase());
+ out.push({ kind, key, label: L?.name ? `Layer · ${L.name}` : `Layer · ${key}` });
+ } else if (kind === 'overview') {
+ if (!canEditOverviews.value) continue;
+ const ov = overviews.find((o) => o.id === key);
+ out.push({ kind, key, label: ov?.title ? `Overview · ${ov.title}` : `Overview · ${key}` });
}
- if (b.kind === 'overview') {
- const ov = overviews.find((o) => o.id === b.key);
- return ov?.title ? `Overview · ${ov.title}` : `Overview · ${b.key}`;
- }
- return b.key;
- });
+ }
+ return out;
});
-const divergedCount = computed(() => divergedItems.value.length);
-const open = computed(() => pref.mode === null && divergedCount.value > 0);
-// Second step shown when the operator picks "use live" — overriding the
-// local edits with the remote version is destructive, so it's confirmed.
-const confirmingRemote = ref(false);
-const busy = ref(false);
+const hasLayerDrafts = computed(() => draftItems.value.some((d) => d.kind === 'layer'));
+const hasOverviewDrafts = computed(() => draftItems.value.some((d) => d.kind === 'overview'));
-async function useLocal(): Promise<void> {
- await setTemplateRenderMode('local');
-}
+// Dismissed once per browser session so it doesn't nag on every route.
+const SESSION_KEY = 'horizon:localDraftPrompt:dismissed';
+const dismissed = ref<boolean>(
+ typeof sessionStorage !== 'undefined' && sessionStorage.getItem(SESSION_KEY) === '1',
+);
+// Not while previewing — a preview tab shows the dedicated preview banner
+// instead of the editor's unpublished-edits reminder.
+const open = computed(() => !previewMode.value && !dismissed.value && draftItems.value.length > 0);
-// Override local with remote: discard the local edits (revert bundled to
-// the live version), then render remote.
-async function useLive(): Promise<void> {
- if (busy.value) return;
- busy.value = true;
+function dismiss(): void {
+ dismissed.value = true;
try {
- await bffClient.templateSync.revertLocal();
- await refreshConfigBundle();
- pref.set('remote');
- } finally {
- busy.value = false;
- confirmingRemote.value = false;
+ sessionStorage.setItem(SESSION_KEY, '1');
+ } catch {
+ /* private mode — in-memory dismissal still holds for this session */
}
}
+function goLayers(): void {
+ const first = draftItems.value.find((d) => d.kind === 'layer');
+ dismiss();
+ void router.push({ path: '/admin/layer-dashboards', query: first ? { layer: first.key } : {} });
+}
+function goOverviews(): void {
+ dismiss();
+ void router.push({ path: '/admin/overview-templates' });
+}
</script>
<template>
- <Modal
- :open="open"
- :dismissable="false"
- :title="confirmingRemote ? 'Use the live version?' : 'Template versions differ from OAP'"
- >
- <div v-if="!confirmingRemote" class="tcp">
+ <Modal :open="open" :dismissable="false" title="You have unpublished local edits">
+ <div class="tcp">
<p class="tcp__lede">
- <b>{{ divergedCount }}</b> dashboard{{ divergedCount === 1 ? '' : 's' }} differ between the
- version OAP currently serves (<b>remote / live</b>) and your <b>local</b> copy — OAP may have
- newer changes, or you have unpublished local edits. Which should this session render?
+ <b>{{ draftItems.length }}</b> dashboard{{ draftItems.length === 1 ? '' : 's' }} ha{{ draftItems.length === 1 ? 's' : 've' }}
+ edits saved only in <b>this browser</b> — they are not live for anyone. Live pages render the
+ remote (OAP) version; preview your drafts from the editor’s <b>Preview live</b> button, then
+ <b>Push local → OAP</b> to publish.
</p>
<ul class="tcp__list">
- <li v-for="(name, i) in divergedItems" :key="i">{{ name }}</li>
- </ul>
- <ul class="tcp__opts">
- <li><b>Local</b> — preview your unpublished edits. Nothing is sent to OAP; publish later with “Sync all to OAP”.</li>
- <li><b>Remote</b> — show the live version everyone else sees. Your local edits stay on disk, unpublished.</li>
+ <li v-for="(d, i) in draftItems" :key="i">{{ d.label }}</li>
</ul>
</div>
- <div v-else class="tcp">
- <p class="tcp__lede tcp__warn">
- OAP holds a different version. Using live will <b>overwrite your
- {{ divergedCount }} local change{{ divergedCount === 1 ? '' : 's' }}</b> with the remote
- version — your local edits are <b>discarded and cannot be recovered</b>. Publish them with
- “Sync all to OAP” instead if you want to keep them. Override local with live?
- </p>
- </div>
-
<template #footer>
- <template v-if="!confirmingRemote">
- <button class="sw-btn" type="button" @click="confirmingRemote = true">Use live (remote)</button>
- <button class="sw-btn primary" type="button" @click="useLocal">Keep my local edits</button>
- </template>
- <template v-else>
- <button class="sw-btn" type="button" :disabled="busy" @click="confirmingRemote = false">Back</button>
- <button class="sw-btn danger" type="button" :disabled="busy" @click="useLive">
- {{ busy ? 'Overriding…' : 'Overwrite local with live' }}
- </button>
- </template>
+ <button class="sw-btn" type="button" @click="dismiss">Dismiss</button>
+ <button v-if="hasOverviewDrafts" class="sw-btn" type="button" @click="goOverviews">
+ Overview templates →
+ </button>
+ <button v-if="hasLayerDrafts" class="sw-btn is-primary" type="button" @click="goLayers">
+ Layer dashboards →
+ </button>
</template>
</Modal>
</template>
@@ -125,10 +128,8 @@ async function useLive(): Promise<void> {
<style scoped>
.tcp { padding: 4px 2px; }
.tcp__lede { margin: 0 0 10px; font-size: 12.5px; color: var(--sw-fg-1); line-height: 1.55; }
-.tcp__warn { color: var(--sw-fg-1); }
-.tcp__warn b { color: var(--sw-err); }
.tcp__list {
- margin: 0 0 12px;
+ margin: 0;
padding: 8px 10px 8px 24px;
max-height: 30vh;
overflow: auto;
@@ -139,6 +140,4 @@ async function useLive(): Promise<void> {
color: var(--sw-fg-1);
line-height: 1.6;
}
-.tcp__opts { margin: 0; padding-left: 18px; font-size: 11.5px; color: var(--sw-fg-2); line-height: 1.6; }
-.tcp__opts b { color: var(--sw-fg-0); }
</style>
diff --git a/apps/ui/src/shell/layerFromTemplate.ts b/apps/ui/src/shell/layerFromTemplate.ts
new file mode 100644
index 0000000..53f0d6a
--- /dev/null
+++ b/apps/ui/src/shell/layerFromTemplate.ts
@@ -0,0 +1,94 @@
+/*
+ * 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.
+ */
+
+/**
+ * Map a layer TEMPLATE (the admin/editor JSON shape — `components`,
+ * `slots`, etc.) to a menu `LayerDef`. Used to render preview pages and to
+ * overlay the sidebar menu from a browser-local draft so the per-layer tab
+ * set reflects the draft's enabled components — without pushing anything to
+ * OAP. The `components` flags drive the cap-based tab visibility.
+ */
+
+import type { LayerCaps, LayerDef } from '@skywalking-horizon-ui/api-client';
+
+/** Loose view of a layer template's menu-relevant fields. */
+export interface LayerTemplateContent {
+ key: string;
+ alias?: string;
+ color?: string;
+ group?: string;
+ visibility?: 'public' | 'operate';
+ documentLink?: string;
+ components?: Record<string, boolean | undefined>;
+ slots?: LayerDef['slots'];
+ metrics?: LayerDef['metrics'];
+ overview?: LayerDef['overview'];
+ naming?: LayerDef['naming'];
+ traces?: LayerDef['traces'];
+}
+
+/** `components.*` → `caps.*` (the tab-visibility flags the sidebar reads). */
+export function componentsToCaps(components: Record<string, boolean | undefined> | undefined): LayerCaps {
+ const c = components ?? {};
+ return {
+ dashboards: !!c.service,
+ instances: !!c.instances,
+ endpoints: !!c.endpoints,
+ serviceMap: !!c.topology,
+ endpointDependency: !!c.endpointDependency,
+ traces: !!c.traces,
+ logs: !!c.logs,
+ traceProfiling: !!c.traceProfiling,
+ ebpfProfiling: !!c.ebpfProfiling,
+ asyncProfiling: !!c.asyncProfiling,
+ };
+}
+
+/** Build a full LayerDef from template content — for previewing a layer
+ * OAP doesn't currently list (no live data). */
+export function layerContentToDef(t: LayerTemplateContent): LayerDef {
+ return {
+ key: t.key,
+ name: t.alias || t.key,
+ color: t.color || 'var(--sw-fg-3)',
+ serviceCount: 0,
+ active: false,
+ level: null,
+ group: t.group,
+ visibility: t.visibility,
+ normal: null,
+ documentLink: t.documentLink,
+ slots: t.slots ?? {},
+ caps: componentsToCaps(t.components),
+ header: t.metrics,
+ metrics: t.metrics,
+ overview: t.overview,
+ naming: t.naming,
+ traces: t.traces,
+ };
+}
+
+/** Overlay an existing menu LayerDef with a draft's menu-relevant fields
+ * (caps + slots) so the sidebar reflects the draft in preview mode. Keeps
+ * the live `serviceCount` / `color` / `name` from the menu entry. */
+export function overlayLayerDef(base: LayerDef, t: LayerTemplateContent): LayerDef {
+ return {
+ ...base,
+ slots: { ...base.slots, ...(t.slots ?? {}) },
+ caps: componentsToCaps(t.components),
+ };
+}
diff --git a/apps/ui/src/shell/router/index.ts b/apps/ui/src/shell/router/index.ts
index edfab5f..a873cc9 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -17,6 +17,7 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '@/state/auth';
import { pushEvent } from '@/controls/eventLog';
+import { setPreviewMode, usePreviewMode, getPreviewSource } from '@/controls/previewMode';
const placeholder = () => import('@/shell/PlaceholderView.vue');
@@ -315,7 +316,27 @@ router.beforeEach(async (to) => {
// buffer is intentionally NOT cleared on navigation — operators want
// to see the cross-page history (last 200 events) so they can trace
// "I clicked here, then services loaded, then I clicked there".
+// Preview mode (`?mode=preview&source=…`) is sticky within layer/overview
+// pages: the sidebar tab links don't carry query params, so without this
+// the overlay would drop the moment you navigate via the menu. We keep the
+// flag on and re-apply the query to the URL so it both propagates AND stays
+// visible. Leaving the layer/overview area turns preview off.
+const previewMode = usePreviewMode();
+const PREVIEWABLE = /^\/(layer|overview)\//;
router.afterEach((to, from) => {
+ if (to.query.mode === 'preview') {
+ setPreviewMode(true, typeof to.query.source === 'string' ? to.query.source : undefined);
+ } else if (previewMode.value && PREVIEWABLE.test(to.path)) {
+ // Menu navigation dropped the params — re-add them (no history entry).
+ const src = getPreviewSource();
+ void router.replace({
+ path: to.path,
+ query: { ...to.query, mode: 'preview', ...(src ? { source: src } : {}) },
+ });
+ return;
+ } else {
+ setPreviewMode(false);
+ }
if (to.path === from.path) return;
pushEvent('route', 'info', `Navigated to ${to.path}`);
});
diff --git a/apps/ui/src/shell/useLayers.ts b/apps/ui/src/shell/useLayers.ts
index 0887d1a..e1d3efe 100644
--- a/apps/ui/src/shell/useLayers.ts
+++ b/apps/ui/src/shell/useLayers.ts
@@ -19,6 +19,10 @@ import { computed } from 'vue';
import { useQuery } from '@tanstack/vue-query';
import type { LayerDef } from '@skywalking-horizon-ui/api-client';
import { bffClient } from '@/api/client';
+import { usePreviewMode, getPreviewSource } from '@/controls/previewMode';
+import { usePreviewOverride } from '@/controls/previewOverride';
+import { useLocalTemplateEdits, layerEditName } from '@/controls/localTemplateEdits';
+import { layerContentToDef, overlayLayerDef, type LayerTemplateContent } from '@/shell/layerFromTemplate';
/**
* Live OAP-driven layer + menu state. Fed by `GET /api/menu`. Refetches on
@@ -37,7 +41,51 @@ export function useLayers() {
refetchOnWindowFocus: true,
});
- const layers = computed<LayerDef[]>(() => q.data.value?.layers ?? []);
+ const previewMode = usePreviewMode();
+ const previewOverride = usePreviewOverride();
+ const localEdits = useLocalTemplateEdits();
+
+ /** Preview content for a layer key. `source=local` reads the live local
+ * draft; `bundled`/`remote` read the override snapshot. */
+ function previewContent(key: string): LayerTemplateContent | null {
+ const name = layerEditName(key);
+ const src = getPreviewSource();
+ if (src === 'local') return localEdits.get<LayerTemplateContent>(name) ?? null;
+ if (src === 'bundled' || src === 'remote') return previewOverride.get<LayerTemplateContent>(name) ?? null;
+ return (
+ previewOverride.get<LayerTemplateContent>(name) ??
+ localEdits.get<LayerTemplateContent>(name) ??
+ null
+ );
+ }
+
+ // In preview mode the per-layer tab set must reflect the previewed
+ // template (its `components`/`slots`), so the sidebar overlays caps/slots
+ // from the draft — and injects a previewed layer OAP doesn't list (no
+ // live data) so the operator can navigate its tabs. Nothing is pushed to
+ // OAP; this is browser-side only.
+ const layers = computed<LayerDef[]>(() => {
+ const base = q.data.value?.layers ?? [];
+ if (!previewMode.value) return base;
+ const seen = new Set(base.map((L) => L.key.toUpperCase()));
+ const overlaid = base.map((L) => {
+ const content = previewContent(L.key);
+ return content ? overlayLayerDef(L, content) : L;
+ });
+ // Inject previewed layers absent from the live menu.
+ const injected: LayerDef[] = [];
+ for (const name of [...previewOverride.names(), ...localEdits.names()]) {
+ if (!name.startsWith('horizon.layer.')) continue;
+ const key = name.slice('horizon.layer.'.length);
+ if (seen.has(key.toUpperCase())) continue;
+ const content = previewContent(key);
+ if (content) {
+ injected.push(layerContentToDef(content));
+ seen.add(key.toUpperCase());
+ }
+ }
+ return [...overlaid, ...injected];
+ });
/**
* Layers known to OAP (returned by `listLayers`). May include stale
* registry entries — receivers that ever ingested but currently have no
diff --git a/apps/ui/src/utils/stableJson.ts b/apps/ui/src/utils/stableJson.ts
new file mode 100644
index 0000000..7b9d853
--- /dev/null
+++ b/apps/ui/src/utils/stableJson.ts
@@ -0,0 +1,38 @@
+/*
+ * 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.
+ */
+
+/**
+ * JSON.stringify with object keys deep-sorted (array order preserved). Use
+ * it to compare / diff two objects that may carry the same data in a
+ * different key order — e.g. a locally-edited template (authored key order)
+ * vs OAP's copy (stored alphabetically). Without this, a one-field edit
+ * looks like "everything changed" in a line diff.
+ */
+function sortKeys(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(sortKeys);
+ if (value && typeof value === 'object') {
+ const obj = value as Record<string, unknown>;
+ const out: Record<string, unknown> = {};
+ for (const k of Object.keys(obj).sort()) out[k] = sortKeys(obj[k]);
+ return out;
+ }
+ return value;
+}
+
+export function stableStringify(value: unknown, space?: number): string {
+ return JSON.stringify(sortKeys(value), null, space);
+}
diff --git a/docs/customization/layer-templates.md b/docs/customization/layer-templates.md
index 3dc7ed6..eb00a03 100644
--- a/docs/customization/layer-templates.md
+++ b/docs/customization/layer-templates.md
@@ -321,21 +321,26 @@ When set, the layer's service list groups by `cluster`. Without it, services are
## Admin Editor
-Layer templates are editable at runtime via **Dashboard setup → Layer dashboards** (`/admin/layer-dashboards`, verb `dashboard:write`). The editor shows layer-specific controls for service, instance, endpoint, topology, trace, log, and profiling views.
+Layer templates are editable at runtime via **Dashboard setup → Layer dashboards** (`/admin/layer-dashboards`, verb `dashboard:write`). Pick a layer from the filterable dropdown (alias + key + sync status), then edit its service / instance / endpoint / topology / trace / log / profiling views. A live menu preview sits beside the Alias / Components / Menu-labels editor; clicking a menu item jumps to that component's config.
-The save/publish model has two steps:
+### How edits flow: draft → preview → publish
-1. **Save locally.** "Save locally" writes your edit to the local bundled copy and renders it immediately for preview — it does **not** touch OAP. The template now shows as **diverged** (local differs from what OAP serves), the row carries a *Synced from OAP — N diverged* banner, and the affected layers show a yellow warning icon in the sidebar. Save works even when OAP is unreachable.
-2. **Publish.** **Sync all to OAP** pushes the diverged templates to OAP (the runtime source of truth) — only the ones that differ — behind a confirmation that lists exactly what will be written. After publishing, the template is synced and everyone sees it.
+Your work-in-progress lives **in your browser**, never on the server until you publish. The live page everyone sees stays on the published OAP version throughout.
-A **Diverged only** filter and a **Showing: Local / Remote** display toggle sit at the top of the page; **Show diff** opens a side-by-side bundled-vs-OAP comparison.
+1. **Save (local).** Stores your edit as a draft in this browser only. Nobody else sees it, and your own normal browsing still shows the published version. The picker tags a layer with a local draft as **local**.
+2. **Reset to ▾.** Loads the **Bundled** (shipped default) or **Remote** (OAP live) version into the editor as a fresh starting point.
+3. **Preview ▾.** Opens the real layer page in a new tab rendering your **Local** draft, the **Bundled** default, or **Remote** — using sample data, so you can check layout, enabled components, and menu labels without touching the server. Preview works even for layers OAP currently reports no services for.
+4. **Check diff & push.** Shows a side-by-side *remote → local* diff and publishes to OAP (the runtime source of truth). Enabled only when your draft actually differs from remote. After publishing, the draft is cleared and everyone sees the change.
-### Local vs. remote conflicts
+A top banner summarizes page state — *Synced from OAP — N diverged, Y local* — and **Diverged** / **Local** filters narrow the picker. Each row shows a status chip: **synced** (bundled == OAP), **diverged** (OAP differs from bundled — OAP wins at render), **remote-only** (on OAP, no bundled default), **disabled** (deleted — see below), or **bundled** (OAP has no copy right now).
-OAP is the source of truth at runtime, so by default the app renders the OAP-stored version. When your local edits diverge, a per-session prompt (listing the affected items by menu name) asks which to render:
+### Disabling / reactivating a layer
-- **Keep my local edits** — render your local copy for preview; publish later with Sync all.
-- **Use live** — overwrite your local copy with the remote (OAP) version. This **discards your local edits** and is confirmed first; use it when OAP holds the newer version.
+OAP has no hard delete, so the **Disable** button next to the layer title soft-disables the layer on OAP. A disabled layer is dropped from the sidebar and renders nowhere, for everyone.
+
+A disabled layer still appears in this admin page (struck-through, status **disabled**) and offers a **Reactivate** button that re-enables it from the bundled default. A layer that exists only as an unpublished local draft is simply removed from your browser. Both actions are confirmed in a dialog first.
+
+> **Note:** re-enabling depends on the OAP UI-template API clearing the disabled flag. On OAP versions that don't support this, a disabled layer must be re-enabled from the OAP side. Treat disabling a built-in layer as a heavyweight action.
## Bundled examples
diff --git a/docs/customization/overview-templates.md b/docs/customization/overview-templates.md
index e3c4fb3..2392210 100644
--- a/docs/customization/overview-templates.md
+++ b/docs/customization/overview-templates.md
@@ -206,30 +206,35 @@ Read-only — Horizon does not support acknowledge / close / silence operations.
## Admin Editor
-Overview templates are editable at runtime via **Dashboard setup → Overview templates** (`/admin/overview-templates`, verb `overview:write`). The editor:
+Overview templates are editable at runtime via **Dashboard setup → Overview templates** (`/admin/overview-templates`, verb `overview:write`). Pick a dashboard from the filterable dropdown (title + id + sync status), then lay it out on a **12-column canvas**: drag a widget to reorder, corner-drag to resize, click a widget to edit it in the right-hand drawer. Section breaks ("text widget" / line break) and the dashboard title are selectable too. The canvas shows **sample data** so you can [...]
-- Lists all bundled overviews + any added ones, with widget count and editable flag.
-- For each overview, shows the widget array with per-widget controls.
-- **Type-aware editor**: per `widget.type`, only the relevant fields are exposed:
+**Per-widget fields** (the drawer shows only what the `widget.type` needs):
- | Type | Fields shown |
- |---|---|
- | `section-break` | `title`, `cols` |
- | `metric` | `layer`, `title`, `tip`, `mqe`, `unit`, `aggregation`, `span`, `rowSpan` |
- | `topology` | `layer`, `title`, `tip`, `span`, `rowSpan` |
- | `alarms` | `layer`, `title`, `tip`, `limit`, `span`, `rowSpan` |
- | `kpi-tile` | `layer`, `title`, `tip`, `showCount`, KPI rows (add / remove), `span`, `rowSpan` |
- | `metric-composite` | `layer`, `title`, `tip`, KPI rows (mixed MQE + service-count source), `span`, `rowSpan` |
+| Type | Fields shown |
+|---|---|
+| `section-break` | `title`, `cols` |
+| `metric` | `layer`, `title`, `tip`, `mqe`, `unit`, `aggregation`, `span`, `rowSpan` |
+| `topology` | `layer`, `title`, `tip`, `span`, `rowSpan` |
+| `alarms` | `layer`, `title`, `tip`, `limit`, `span`, `rowSpan` |
+| `kpi-tile` | `layer`, `title`, `tip`, `showCount`, KPI rows (add / remove), `span`, `rowSpan` |
+| `metric-composite` | `layer`, `title`, `tip`, KPI rows (each a stacked card: label / source / MQE / unit / aggr / style / max), `span`, `rowSpan` |
+
+### How edits flow: draft → preview → publish
+
+Same model as [layer templates](/customization/layer-templates): your edit lives **in your browser**, and the live page everyone sees stays on the published OAP version until you publish.
+
+1. **Save (local).** Stores your draft in this browser only; the dashboard is tagged **local** in the picker.
+2. **Reset to ▾** loads the **Bundled** or **Remote** version into the canvas.
+3. **Preview ▾** opens the real overview page in a new tab rendering **Local** / **Bundled** / **Remote**.
+4. **Check diff & push** shows a *remote → local* diff and publishes to OAP (create-or-update). Enabled only when your draft differs from remote.
-- **Add / remove widgets** with the type picker.
-- **Preview** renders the in-progress template against live OAP data.
+A **+ New dashboard** form (inside the picker) creates a dashboard the same way: it writes a **local draft** (id is the template name, must be unique) — edit and preview it, then **Check diff & push** publishes it to OAP. A pushed dashboard with no bundled default is **remote-only**; the picker, the live page, and the sidebar all show it.
-The save/publish model has two steps:
+A top banner summarizes state — *Synced from OAP — N diverged, Y local* — with **Diverged** / **Local** filters. Status chips per row: **synced**, **diverged** (OAP wins at render), **remote-only**, **disabled** (deleted), **bundled**.
-1. **Save locally.** The edit is written to the local bundled copy and renders immediately for preview. OAP is not changed yet.
-2. **Publish.** **Sync all to OAP** pushes diverged overview templates to OAP behind a confirmation that lists the affected templates.
+### Deleting a dashboard
-If the local copy differs from OAP, Horizon shows the template as **diverged**. Use **Show diff** to compare local and remote, **Keep my local edits** to preview, or **Use live** to discard the local copy and render the OAP version.
+OAP has no hard delete, so the **Delete** button next to the title soft-disables the dashboard on OAP (a disabled dashboard drops from the picker's live state, the sidebar, and the live page). A dashboard that exists only as an unpublished local draft is removed from your browser instead. Either action is confirmed in a dialog first; deletion is irreversible from the UI, but a new dashboard can always be created with the same id.
## Hot reload