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 8afe1a5 configs: single-shot preload of every layer + overview into
localStorage
8afe1a5 is described below
commit 8afe1a5c888f515c7fc291ca90c53cb143254919
Author: Wu Sheng <[email protected]>
AuthorDate: Sun May 17 11:42:44 2026 +0800
configs: single-shot preload of every layer + overview into localStorage
A new BFF endpoint GET /api/configs/bundle returns the dashboard
widget set for every (layer, scope) and the full overview list in
one round-trip, with an MD5 etag. The SPA fetches this on shell
mount and persists the body to localStorage:
- ensureConfigBundle() (controls/configBundle.ts) is idempotent —
first call hydrates state synchronously from localStorage (so
page-1 paints already see configs), then fires the network
request with If-None-Match. A 304 confirms the cached copy is
still good; a 200 supersedes it and re-persists.
- useLayerDashboardConfig reads getDashboardConfig() first; the
per-(layer, scope) network endpoint is now only a fallback for
cache misses (e.g. a layer added since the cached bundle was
written).
- useOverviewDashboards likewise reads getOverviews() first; the
network list call only fires when the bundle is empty.
- AppShell triggers ensureConfigBundle() on mount so the preload
starts the moment the operator is past auth.
- EventTicker shows pre-load progress alongside the dashboard /
service / instance events: "Pre-loading dashboard + overview
configs…" → "Pre-loaded 42 layer configs + 2 overviews" (or
"Configs unchanged · using cached copy" on 304).
After a single visit the SPA reads all dashboard configs from
localStorage — every subsequent navigation skips the dashboard-
config round-trip entirely.
---
apps/bff/src/http/config/bundle.ts | 96 +++++++++++++++
apps/bff/src/server.ts | 2 +
apps/ui/src/api/client.ts | 2 +
apps/ui/src/api/scopes/configs.ts | 63 ++++++++++
apps/ui/src/controls/configBundle.ts | 134 +++++++++++++++++++++
.../render/layer-dashboard/useLayerDashboard.ts | 27 ++++-
.../src/render/overview/useOverviewDashboards.ts | 22 +++-
apps/ui/src/shell/AppShell.vue | 11 ++
8 files changed, 353 insertions(+), 4 deletions(-)
diff --git a/apps/bff/src/http/config/bundle.ts
b/apps/bff/src/http/config/bundle.ts
new file mode 100644
index 0000000..d68dd2c
--- /dev/null
+++ b/apps/bff/src/http/config/bundle.ts
@@ -0,0 +1,96 @@
+/*
+ * 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.
+ */
+
+/**
+ * `GET /api/configs/bundle` — preload payload for the SPA. Returns the
+ * dashboard widget set for every (layer, scope) pair PLUS the full
+ * overview-dashboard list in one round-trip so the SPA can cache the
+ * lot in localStorage and serve config lookups synchronously after
+ * the first visit. The body excludes runtime data (no MQE evaluation
+ * happens here) — the SPA still fires the dashboard/landing routes
+ * to populate widget values.
+ *
+ * Versioning: `etag` is a stable hash of the payload (md5 of the
+ * JSON shape). The SPA passes it back as `If-None-Match` on
+ * subsequent loads; an unchanged bundle returns 304 so the client
+ * keeps using its localStorage copy.
+ */
+
+import { createHash } from 'node:crypto';
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type { DashboardWidget, OverviewDashboard } 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 { allLayerTemplates, widgetsForScope } from
'../../logic/layers/loader.js';
+import { loadOverviewDashboards } from '../../logic/overview/loader.js';
+
+export interface ConfigBundleDeps {
+ config: ConfigSource;
+ sessions: SessionStore;
+}
+
+type ScopeMap = Partial<Record<'service' | 'instance' | 'endpoint',
DashboardWidget[]>>;
+export interface ConfigBundle {
+ etag: string;
+ generatedAt: number;
+ layers: Record<string, ScopeMap>;
+ overviews: OverviewDashboard[];
+}
+
+let cached: ConfigBundle | null = null;
+let cachedSourceVersion = -1;
+function bundle(sourceVersion: number): ConfigBundle {
+ if (cached && cachedSourceVersion === sourceVersion) return cached;
+ const layers: Record<string, ScopeMap> = {};
+ for (const tpl of allLayerTemplates()) {
+ const scopes: ScopeMap = {};
+ for (const scope of ['service', 'instance', 'endpoint'] as const) {
+ const ws = widgetsForScope(tpl, scope);
+ // Only include scopes that actually have widgets — keeps the
+ // bundle tight (so11y_java_agent contributes only `instance`,
+ // mesh_dp the same, etc.).
+ if (ws.length > 0) scopes[scope] = ws;
+ }
+ layers[tpl.key.toLowerCase()] = scopes;
+ }
+ const overviews = loadOverviewDashboards();
+ const body = { layers, overviews };
+ const etag = createHash('md5').update(JSON.stringify(body)).digest('hex');
+ cached = { etag, generatedAt: Date.now(), ...body };
+ cachedSourceVersion = sourceVersion;
+ return cached;
+}
+
+export function registerConfigBundleRoute(app: FastifyInstance, deps:
ConfigBundleDeps): void {
+ const auth = requireAuth(deps);
+ app.get(
+ '/api/configs/bundle',
+ { preHandler: auth },
+ async (req: FastifyRequest, reply: FastifyReply) => {
+ const sourceVersion = (deps.config as { version?: number }).version ?? 0;
+ const body = bundle(sourceVersion);
+ const inm = req.headers['if-none-match'];
+ if (typeof inm === 'string' && inm === body.etag) {
+ return reply.code(304).send();
+ }
+ reply.header('ETag', body.etag);
+ reply.header('Cache-Control', 'private, max-age=0, must-revalidate');
+ return reply.send(body);
+ },
+ );
+}
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index c97394d..23399d7 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -49,6 +49,7 @@ import { registerLayerTemplateRoutes } from
'./http/config/layer-template.js';
import { registerAlarmsConfigRoutes } from './http/config/alarms.js';
import { registerSetupRoutes } from './http/config/setup.js';
import { registerOverviewRoutes } from './http/config/overview.js';
+import { registerConfigBundleRoute } from './http/config/bundle.js';
// Admin (operational tools)
import { registerDslCatalogRoutes } from './http/admin/dsl/catalog.js';
import { registerDslRuleRoutes } from './http/admin/dsl/rule.js';
@@ -124,6 +125,7 @@ registerLayerTemplateRoutes(app, { config: source, sessions
});
registerAlarmsConfigRoutes(app, { config: source, sessions, audit, store:
alarmsStore, serviceLayer });
registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore
});
registerOverviewRoutes(app, { config: source, sessions });
+registerConfigBundleRoute(app, { config: source, sessions });
// ── Admin ──────────────────────────────────────────────────────────
registerDslCatalogRoutes(app, { config: source, sessions, audit });
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index fa6ada9..bcc6929 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -63,6 +63,7 @@ import { LiveDebugApi } from './scopes/live-debug';
import { InspectApi } from './scopes/inspect';
import { AlarmsApi } from './scopes/alarms';
import { LayerTemplatesApi } from './scopes/layer-template';
+import { ConfigsApi } from './scopes/configs';
// ── Wire types re-exported from @skywalking-horizon-ui/api-client ────
// Re-exported so consumers can import everything from this module.
@@ -531,6 +532,7 @@ export class BffClient {
readonly inspect = new InspectApi(this);
readonly alarms = new AlarmsApi(this);
readonly layerTemplates = new LayerTemplatesApi(this);
+ readonly configs = new ConfigsApi(this);
}
export const bffClient = new BffClient();
diff --git a/apps/ui/src/api/scopes/configs.ts
b/apps/ui/src/api/scopes/configs.ts
new file mode 100644
index 0000000..b1ae9f5
--- /dev/null
+++ b/apps/ui/src/api/scopes/configs.ts
@@ -0,0 +1,63 @@
+/*
+ * 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.
+ */
+
+import type {
+ DashboardWidget,
+ OverviewDashboard,
+} from '@skywalking-horizon-ui/api-client';
+import type { BffClient } from '../client';
+
+export type BundleScopeMap = Partial<
+ Record<'service' | 'instance' | 'endpoint', DashboardWidget[]>
+>;
+
+export interface ConfigBundle {
+ etag: string;
+ generatedAt: number;
+ layers: Record<string, BundleScopeMap>;
+ overviews: OverviewDashboard[];
+}
+
+/** `bff.configs` — preload of dashboard + overview configs. The SPA
+ * caches the response in localStorage and re-fetches with
+ * `If-None-Match` so a 304 means "your cached copy is still good". */
+export class ConfigsApi {
+ constructor(private readonly bff: BffClient) {}
+
+ /**
+ * Fetch the bundle, optionally with a prior `etag` for cache
+ * validation. Returns `null` on a 304 (the caller's cached copy
+ * is current); otherwise a full bundle.
+ */
+ async bundle(ifNoneMatch?: string): Promise<ConfigBundle | null> {
+ const headers: Record<string, string> = {};
+ if (ifNoneMatch) headers['If-None-Match'] = ifNoneMatch;
+ // Direct fetch so we can detect 304 without throwing.
+ const res = await fetch('/api/configs/bundle', {
+ method: 'GET',
+ credentials: 'include',
+ headers,
+ });
+ if (res.status === 304) return null;
+ if (res.status === 401) {
+ this.bff.handleUnauthorized();
+ throw new Error('unauthenticated');
+ }
+ if (!res.ok) throw new Error(`bundle fetch failed (${res.status})`);
+ return (await res.json()) as ConfigBundle;
+ }
+}
diff --git a/apps/ui/src/controls/configBundle.ts
b/apps/ui/src/controls/configBundle.ts
new file mode 100644
index 0000000..b541aec
--- /dev/null
+++ b/apps/ui/src/controls/configBundle.ts
@@ -0,0 +1,134 @@
+/*
+ * 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.
+ */
+
+/**
+ * Single-shot preload of every layer's dashboard widget set + the
+ * overview-dashboard list. Lives in a module singleton so any
+ * composable can look up `getDashboardConfig(layerKey, scope)` /
+ * `getOverviews()` synchronously without re-fetching, and persists to
+ * `localStorage` so a returning operator gets instant config reads —
+ * the BFF's ETag tells us whether the cached copy is still good.
+ *
+ * Boot sequence:
+ * 1. AppShell calls `ensureConfigBundle()` on mount.
+ * 2. We read the prior bundle (if any) from localStorage and seed
+ * `state` synchronously so the first paint already has configs.
+ * 3. We fire `GET /api/configs/bundle` with `If-None-Match`. A 304
+ * means the cached copy is current; a 200 supersedes it.
+ * 4. Progress shows up in the EventTicker via pushEvent('preload', …).
+ */
+
+import { ref, computed, type ComputedRef, type Ref } from 'vue';
+import { bffClient } from '@/api/client';
+import { pushEvent } from '@/controls/eventLog';
+import type { ConfigBundle, BundleScopeMap } from '@/api/scopes/configs';
+import type { DashboardWidget, OverviewDashboard } from
'@skywalking-horizon-ui/api-client';
+
+const STORAGE_KEY = 'horizon:configBundle:v1';
+const state = ref<ConfigBundle | null>(null);
+let loadPromise: Promise<void> | null = null;
+
+function readStorage(): ConfigBundle | null {
+ if (typeof localStorage === 'undefined') return null;
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as ConfigBundle;
+ if (!parsed?.etag || !parsed?.layers) return null;
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+function writeStorage(b: ConfigBundle): void {
+ if (typeof localStorage === 'undefined') return;
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(b));
+ } catch {
+ /* quota / disabled storage — degrade silently, in-memory still works */
+ }
+}
+
+/**
+ * Idempotent — first call kicks off the network fetch (or 304 check
+ * against the localStorage etag); subsequent calls await the same
+ * promise. Safe to call from every composable that needs configs.
+ */
+export function ensureConfigBundle(): Promise<void> {
+ if (loadPromise) return loadPromise;
+ loadPromise = (async () => {
+ const cached = readStorage();
+ if (cached) {
+ state.value = cached;
+ pushEvent(
+ 'preload',
+ 'info',
+ `Cached configs: ${Object.keys(cached.layers).length} layers +
${cached.overviews.length} overviews`,
+ );
+ }
+ pushEvent('preload', 'start', 'Pre-loading dashboard + overview configs…');
+ try {
+ const fresh = await bffClient.configs.bundle(cached?.etag);
+ if (fresh) {
+ state.value = fresh;
+ writeStorage(fresh);
+ pushEvent(
+ 'preload',
+ 'ok',
+ `Pre-loaded ${Object.keys(fresh.layers).length} layer configs +
${fresh.overviews.length} overviews`,
+ );
+ } else {
+ pushEvent('preload', 'ok', 'Configs unchanged · using cached copy');
+ }
+ } catch (err) {
+ pushEvent(
+ 'preload',
+ 'err',
+ `Config preload failed: ${err instanceof Error ? err.message :
String(err)}`,
+ );
+ // Don't rethrow — the SPA falls back to per-page network reads.
+ }
+ })();
+ return loadPromise;
+}
+
+/** Sync lookup. Returns null when the bundle hasn't loaded yet OR
+ * when the (layer, scope) pair has no widgets configured. */
+export function getDashboardConfig(
+ layerKey: string,
+ scope: 'service' | 'instance' | 'endpoint',
+): DashboardWidget[] | null {
+ 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. */
+export function getOverviews(): OverviewDashboard[] | null {
+ return state.value?.overviews ?? null;
+}
+
+export function useConfigBundle(): {
+ bundle: Ref<ConfigBundle | null>;
+ loaded: ComputedRef<boolean>;
+} {
+ return {
+ bundle: state,
+ loaded: computed<boolean>(() => state.value !== null),
+ };
+}
diff --git a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
index 4c6ea38..5e44264 100644
--- a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
+++ b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
@@ -32,19 +32,40 @@ import { useQuery } from '@tanstack/vue-query';
import { pushEvent } from '@/controls/eventLog';
import { useAutoRefreshSubscribe } from
'../../controls/useAutoRefreshSubscribe';
import { bffClient } from '@/api/client';
+import {
+ ensureConfigBundle,
+ getDashboardConfig,
+ useConfigBundle,
+} from '@/controls/configBundle';
+import type { DashboardConfig } from '@skywalking-horizon-ui/api-client';
export function useLayerDashboardConfig(layerKey: Ref<string>, scope?:
Ref<string>) {
+ // Prefer the preloaded bundle. The bundle preload kicks off at app
+ // mount in AppShell; if for some reason this composable runs first
+ // we still trigger it here so the lookup eventually resolves.
+ void ensureConfigBundle();
+ const { loaded } = useConfigBundle();
+ const bundled = computed<DashboardConfig | null>(() => {
+ if (!loaded.value) return null;
+ const s = (scope?.value ?? 'service') as 'service' | 'instance' |
'endpoint';
+ const widgets = getDashboardConfig(layerKey.value, s);
+ if (!widgets) return null;
+ return { layer: layerKey.value, scope: s, widgets };
+ });
+ // Network fallback — only fires if the bundle lookup came back null
+ // (e.g. a layer added since the cached bundle was written). Keeps
+ // the page rendering even when localStorage is stale.
const q = useQuery({
queryKey: ['dashboard-config', layerKey, scope ?? computed(() =>
'service')],
queryFn: () => bffClient.layer.dashboardConfig(layerKey.value,
scope?.value),
- enabled: computed(() => layerKey.value.length > 0),
+ enabled: computed(() => layerKey.value.length > 0 && loaded.value &&
bundled.value === null),
staleTime: 5 * 60_000,
});
useAutoRefreshSubscribe(() => q.refetch());
return {
- config: computed(() => q.data.value ?? null),
- isLoading: q.isLoading,
+ config: computed(() => bundled.value ?? q.data.value ?? null),
+ isLoading: computed(() => !loaded.value && q.isLoading.value),
error: q.error,
};
}
diff --git a/apps/ui/src/render/overview/useOverviewDashboards.ts
b/apps/ui/src/render/overview/useOverviewDashboards.ts
index e0fe988..b8ef3cb 100644
--- a/apps/ui/src/render/overview/useOverviewDashboards.ts
+++ b/apps/ui/src/render/overview/useOverviewDashboards.ts
@@ -19,6 +19,11 @@ import { computed } from 'vue';
import { useQuery } from '@tanstack/vue-query';
import { bffClient } from '@/api/client';
import { useLayers } from '../../shell/useLayers';
+import {
+ ensureConfigBundle,
+ getOverviews,
+ useConfigBundle,
+} from '@/controls/configBundle';
/**
* Overview-dashboard list driver. Fetches the BFF's bundled list, then
@@ -32,9 +37,14 @@ import { useLayers } from '../../shell/useLayers';
* section.
*/
export function useOverviewDashboards() {
+ void ensureConfigBundle();
+ const { loaded: bundleLoaded } = useConfigBundle();
+ // Network fallback only when the bundle is missing (or empty —
+ // e.g. a fresh BFF deploy that didn't ship overview templates yet).
const q = useQuery({
queryKey: ['overview-dashboards'],
queryFn: () => bffClient.overview.list(),
+ enabled: computed(() => bundleLoaded.value && (getOverviews()?.length ??
0) === 0),
staleTime: 60_000,
refetchOnWindowFocus: true,
});
@@ -46,7 +56,17 @@ export function useOverviewDashboards() {
return s;
});
- const all = computed(() => q.data.value?.dashboards ?? []);
+ const all = computed(() => {
+ // SetupView reads `d.widgetCount` (a precomputed convenience the
+ // list endpoint adds). Bundle entries are the full OverviewDashboard
+ // shape with `widgets[]` — derive `widgetCount` from that so both
+ // paths produce the same shape downstream.
+ const bundled = getOverviews();
+ if (bundled && bundled.length > 0) {
+ return bundled.map((d) => ({ ...d, widgetCount: d.widgets?.length ?? 0
}));
+ }
+ return q.data.value?.dashboards ?? [];
+ });
const visible = computed(() =>
all.value.filter((d) => {
const layers = d.layers ?? [];
diff --git a/apps/ui/src/shell/AppShell.vue b/apps/ui/src/shell/AppShell.vue
index 0c78599..2b4823e 100644
--- a/apps/ui/src/shell/AppShell.vue
+++ b/apps/ui/src/shell/AppShell.vue
@@ -15,12 +15,23 @@
limitations under the License.
-->
<script setup lang="ts">
+import { onMounted } from 'vue';
import { RouterView } from 'vue-router';
import AppSidebar from './AppSidebar.vue';
import AppTopbar from './AppTopbar.vue';
import GlobalConnectivityBanner from './GlobalConnectivityBanner.vue';
import TracePopout from '@/layer/traces/TracePopout.vue';
import ZipkinTracePopout from '@/layer/traces/ZipkinTracePopout.vue';
+import { ensureConfigBundle } from '@/controls/configBundle';
+
+// Kick the config preload once the shell mounts (i.e. after the auth
+// guard has let the user through). All layer dashboard configs +
+// overview list arrive in one round-trip and land in localStorage so
+// subsequent navigations read configs synchronously — no per-page
+// spinner for what's effectively static template content.
+onMounted(() => {
+ void ensureConfigBundle();
+});
</script>
<template>