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>

Reply via email to