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 5bfd3dc fix(bff): wait for OAP admin readiness before bootSeed (#19)
5bfd3dc is described below
commit 5bfd3dcc361a0618be8746fefa0a9c116ee149d8
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Wed May 27 08:07:42 2026 +0800
fix(bff): wait for OAP admin readiness before bootSeed (#19)
* fix(bff): wait for OAP admin readiness before bootSeed; warn-per-fail; no
periodic polling
Previously `bootSeed()` was fire-and-forget on the very first listen
callback — a single `client.list()` attempt, and if OAP's admin port
wasn't bound yet (compose / k8s rollout where OAP starts after the
BFF binds, or the admin module is wired lazily) the function logged
one warn and returned `unreachable: true` with no retry. Every
bundled template stayed un-pushed for that BFF lifetime; the only
recovery was an operator restart of the BFF after OAP came up.
Adds `waitForOapAdminReady(deps, signal)` in
apps/bff/src/logic/templates/sync.ts that uses `client.list()` as a
readiness check, backs off 1s → 2s → 4s → … capped at 60s, and
logs one warn per failed attempt. Each warn carries `attempt` and
`nextRetryInMs` so an operator grepping logs sees the wait progress
without ambiguity. On the first successful list it returns and the
loop terminates — there's no steady-state polling: the admin port is
only touched again when an operator triggers a UI sync or an
admin-edit action.
Wired into server.ts as a two-step boot:
1. `await waitForOapAdminReady(deps, bootSeedAbort.signal)`
2. `await bootSeed(deps)` (unchanged — still absent-only via
`seedMissing`, so a fully-seeded OAP from a prior boot is a
no-op write-wise).
The wait is cancellable via an AbortController bound to shutdown so
the BFF process can exit cleanly when signal-killed mid-backoff (k8s
rolling restart, ctrl-c) instead of blocking the close handler.
If `client.list()` flaps between the readiness probe and the
subsequent `bootSeed` (very narrow window) we still log a clear warn
on the partial seed; the absent-only semantics protect operator
edits in that case.
Tested: 80 / 80 BFF unit tests pass.
* fix(bff): gate bundled-template fs.watch behind NODE_ENV=development
Bundled templates ship inside the BFF image — they're immutable in
production (changing one means a rebuild + redeploy), so the
fs.watch is pure overhead there: a file descriptor held forever
plus a debounce timer that never fires. The watcher exists purely
for the dev-loop convenience of seeing JSON edits without an
`Editor: restart` chord; `tsx watch` only reloads on `.ts`
changes.
Gate tightened from `NODE_ENV !== 'test'` (which left it running
in every non-test env including prod) to `NODE_ENV === 'development'`.
The dev script in apps/bff/package.json already sets
`NODE_ENV=development`, so local dev keeps the hot-reload behavior;
prod / staging / docker images run without the watcher.
* fix(menu): prefer remote OAP layer template over disk bundled when both
exist
The sidebar's `/api/menu` route always called `getLayerTemplate(rawKey)`
to source per-layer metadata (alias, color, components, slots, caps,
documentLink, metrics, overview, log, traces, naming). That helper
reads ONLY from the bundled in-process cache (disk JSON files), never
from OAP. So after an operator edited a layer template via the admin
UI and pushed it to OAP, the sidebar kept rendering the disk-bundled
copy forever — `components` toggles, `caps`, `slots`, `alias` changes
were invisible there even though the per-layer pages (which go
through `/api/configs/bundle` and its `pickLayerContent`) reflected
them correctly. Two read paths over the same data, one merging
remote + bundled and one ignoring remote, surfaced as "I edited
components, but the sidebar still shows the bundled set."
Fix mirrors the bundle endpoint's precedence:
1. remote OAP UI-template row, when present + not disabled
(operator's published edits go live the moment they push)
2. disk-bundled template, when remote is absent
3. null when neither exists (caller falls back to LAYER_DEFAULTS).
Reuses the same `getSyncStatus()` read the disabled-filter already
does — single sync-status call now yields both the disabled set AND
the per-name remote row map. Two-key projection on the existing
30s sync cache; no extra OAP round-trips.
`disabledLayerKeys` is renamed to `layerSyncSnapshot` and returns
{ disabled, layerRowsByName }; the live-path and OAP-unreachable
fallback both go through the new `resolveLayerTemplate(rawKey,
layerRowsByName)` helper. The unreachable fallback passes an empty
row map so it falls cleanly to bundled, identical to the prior
behavior on that path.
* fix(landing): block dashboard render when OAP query host is unreachable
Policy: admin-host unreachable is acceptable (the operator can still
browse existing dashboards, admin pages drop to read-only via
AdminFeatureWarning), but query-host unreachable means there's no
service data anywhere — rendering an "empty" dashboard misleads
operators into chasing a dashboard-config problem when the real
problem is upstream.
OverviewLanding now:
1. Suspends its redirect cascade while `oapReachable === false`.
Previously the cascade would happily redirect into an overview
dashboard or layer page, each of which would also render empty
and confusingly look like a stale layout.
2. Renders a full-page warning card in place of the empty / routing
placeholder when OAP is unreachable, with a "Retry now" button
wired to the useLayers refetch and a hint that the background
poll is also retrying.
Sidebar + GlobalConnectivityBanner unchanged — the topbar strip
keeps surfacing the live reachability state across every route, the
sidebar still renders so the operator can step over to admin pages
(which are intentionally still reachable in read-only mode to fix
template / config issues even with OAP down).
Per-layer pages also unchanged: they keep their existing
bundled-fallback render so an operator can verify a layer template
they just edited even when OAP is down. Only the landing /
overview-cascade path fully blocks.
12 new i18n keys × 8 locales.
* chore(local-boot): add horizon.unreachable.yaml for previewing OAP-down
landing block
Points queryUrl + adminUrl at 127.0.0.1:12801 (nothing listening) so the
new full-page "OAP query host is unreachable" landing state can be
exercised end-to-end without standing up an OAP. Auth / RBAC mirror
horizon.local.yaml — admin/admin still logs in; the landing cascade is
suppressed and the warning card renders instead.
* chore(asf): dismiss stale PR approvals on new commits
Apache INFRA's .asf.yaml exposes the GitHub branch-protection toggle
under required_pull_request_reviews.dismiss_stale_reviews. With this
off, a reviewer's LGTM persists across every later push to the PR —
which means follow-up commits ship under an approval of code the
reviewer never saw. Enable it on main so each fresh push needs a
fresh review.
Note: .asf.yaml is read from the default branch, so this only takes
effect once merged.
---
.asf.yaml | 3 +
.claude/skills/local-boot/horizon.unreachable.yaml | 102 +++++++++++++++++++
apps/bff/src/http/query/menu.ts | 97 ++++++++++++++----
apps/bff/src/logic/templates/sync.ts | 68 +++++++++++++
apps/bff/src/server.ts | 66 ++++++++----
apps/ui/src/i18n/locales/de.json | 14 ++-
apps/ui/src/i18n/locales/en.json | 14 ++-
apps/ui/src/i18n/locales/es.json | 14 ++-
apps/ui/src/i18n/locales/fr.json | 14 ++-
apps/ui/src/i18n/locales/ja.json | 14 ++-
apps/ui/src/i18n/locales/ko.json | 14 ++-
apps/ui/src/i18n/locales/pt.json | 14 ++-
apps/ui/src/i18n/locales/zh-CN.json | 14 ++-
apps/ui/src/render/overview/OverviewLanding.vue | 113 +++++++++++++++++----
14 files changed, 494 insertions(+), 67 deletions(-)
diff --git a/.asf.yaml b/.asf.yaml
index 790c91a..1914a0d 100644
--- a/.asf.yaml
+++ b/.asf.yaml
@@ -46,3 +46,6 @@ github:
- Required
required_pull_request_reviews:
required_approving_review_count: 1
+ # Drop stale approvals when a PR gets new commits, so a reviewer's
+ # LGTM doesn't carry over to code they haven't seen.
+ dismiss_stale_reviews: true
diff --git a/.claude/skills/local-boot/horizon.unreachable.yaml
b/.claude/skills/local-boot/horizon.unreachable.yaml
new file mode 100644
index 0000000..b9f81ec
--- /dev/null
+++ b/.claude/skills/local-boot/horizon.unreachable.yaml
@@ -0,0 +1,102 @@
+# Static local-boot config for previewing the "OAP query host is
+# unreachable" landing block. Points queryUrl + adminUrl at a port
+# nothing is listening on (127.0.0.1:12801 — adjacent to the normal
+# local OAP 12800), so every OAP request fails fast with ECONNREFUSED.
+# Auth + RBAC mirror horizon.local.yaml so admin/admin still logs in.
+
+server:
+ host: 127.0.0.1
+ port: 8081
+
+oap:
+ queryUrl: http://127.0.0.1:12801
+ adminUrl: http://127.0.0.1:12801
+ zipkinUrl: http://127.0.0.1:9412/zipkin
+ timeoutMs: 5000
+
+auth:
+ backend: local
+ local:
+ users:
+ - username: viewer
+ passwordHash:
"$argon2id$v=19$m=65536,t=3,p=4$Gp175hqr+EF2iZ7v1fndvw$w6w9hDI59/UA+CRARChDoGRlR1TkVt6kqzApa021K+0"
+ roles: [viewer]
+ - username: maintainer
+ passwordHash:
"$argon2id$v=19$m=65536,t=3,p=4$w7ULwB3/jzH9FxVoHJ238A$y+qGoX6IPeOoGywLQCpfpAN5VJXcaevoWeJQhaybvQU"
+ roles: [maintainer]
+ - username: operator
+ passwordHash:
"$argon2id$v=19$m=65536,t=3,p=4$nzoI4RqiobprtzX/mJqe5Q$FY2Hi7mKep0DPHoaE++r/KD++WLUwTgRUFLde87j2Wg"
+ roles: [operator]
+ - username: admin
+ passwordHash:
"$argon2id$v=19$m=65536,t=3,p=4$joV9AVlyLS3pqq4mLrYokQ$pJLkTKrz9/LzEH6YaFljdz9k8dyBiryjwSB26Diiz9U"
+ roles: [admin]
+
+rbac:
+ enabled: true
+ roles:
+ viewer:
+ - "metrics:read"
+ - "alarms:read"
+ - "traces:read"
+ - "logs:read"
+ - "topology:read"
+ - "profile:read"
+ - "overview:read"
+ maintainer:
+ - "metrics:read"
+ - "alarms:read"
+ - "traces:read"
+ - "logs:read"
+ - "topology:read"
+ - "profile:read"
+ - "overview:read"
+ - "cluster:read"
+ - "inspect:read"
+ - "ttl:read"
+ - "config:read"
+ operator:
+ - "metrics:read"
+ - "alarms:read"
+ - "traces:read"
+ - "logs:read"
+ - "topology:read"
+ - "profile:read"
+ - "cluster:read"
+ - "inspect:read"
+ - "ttl:read"
+ - "config:read"
+ - "overview:read"
+ - "overview:write"
+ - "setup:read"
+ - "setup:write"
+ - "dashboard:read"
+ - "dashboard:write"
+ - "alarm-setup:read"
+ - "alarm-setup:write"
+ - "alarm-rule:read"
+ - "alarm-rule:write"
+ - "rule:read"
+ - "rule:write"
+ - "rule:write:structural"
+ - "rule:delete"
+ - "rule:debug"
+ - "live-debug:read"
+ - "live-debug:write"
+ - "profile:enable"
+ admin:
+ - "*"
+ landingByRole:
+ viewer: /
+ maintainer: /operate/cluster
+ operator: /
+ admin: /
+
+session:
+ ttlMinutes: 60
+ cookieName: horizon_sid
+ cookieSecure: false
+
+audit: { file: ./horizon-audit.jsonl }
+setup: { file: ./horizon-setup.json }
+alarms: { file: ./horizon-alarms.json }
+debugLog: { enabled: false, file: ./horizon-wire.jsonl, maxBodyChars: 8192,
redactAuthHeaders: true }
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index 29271f3..bb335fa 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -31,6 +31,8 @@ import { buildOapOpts, graphqlPost } from
'../../client/graphql.js';
import { allLayerTemplates, getLayerTemplate, type LayerComponentFlags, type
LayerTemplate } from '../../logic/layers/loader.js';
import { getSyncStatus } from '../../logic/templates/sync.js';
import { iterateBundledTemplates } from '../../logic/templates/aggregator.js';
+import { formatName, parseEnvelope } from '../../logic/templates/names.js';
+import type { TemplateRow } from '../../logic/templates/sync.js';
import type { ServiceLayerCatalog } from
'../../logic/services/service-layer-catalog.js';
import { logger } from '../../logger.js';
import type { Locale } from '../../i18n/index.js';
@@ -80,28 +82,50 @@ export interface MenuRouteDeps {
serviceCatalog: ServiceLayerCatalog;
}
-/** 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;
+interface LayerSyncSnapshot {
+ /** Canonical layer keys disabled on OAP (sidebar hides them). */
+ disabled: Set<string>;
+ /** Per-name layer rows for the live OAP UI-template state. Lets the
+ * menu prefer the operator's published edits (alias / components /
+ * slots / caps / colour / metrics / overview / log / traces /
+ * naming) over the disk-bundled defaults — same precedence rule
+ * the config-bundle endpoint already applies via
+ * `pickLayerContent`. Empty when OAP is unreachable; the menu
+ * then falls back to bundled cleanly. */
+ layerRowsByName: Map<string, TemplateRow>;
+}
+
+/** Read the shared 30s sync cache once and project the two things the
+ * menu needs out of it: which layer rows are admin-disabled, and the
+ * full per-name remote envelope content so the menu can prefer
+ * operator edits over disk-bundled defaults. Soft-fails to an empty
+ * snapshot so the sidebar never breaks because the template status
+ * couldn't be read. */
+async function layerSyncSnapshot(deps: MenuRouteDeps):
Promise<LayerSyncSnapshot> {
+ const empty: LayerSyncSnapshot = { disabled: new Set(), layerRowsByName: new
Map() };
+ if (!deps.uiTemplateClient) return empty;
try {
const sync = await getSyncStatus({
client: deps.uiTemplateClient(),
bundled: () => iterateBundledTemplates(),
logger,
});
- if (sync.unreachable) return out;
+ if (sync.unreachable) return empty;
+ const disabled = new Set<string>();
+ const layerRowsByName = new Map<string, TemplateRow>();
for (const row of sync.rows) {
- if (row.kind === 'layer' && row.status === 'disabled') {
- out.add(canonical(row.key.toUpperCase()));
+ if (row.kind !== 'layer') continue;
+ if (row.locale !== undefined) continue; // skip i18n overlay rows
+ layerRowsByName.set(row.name, row);
+ if (row.status === 'disabled') {
+ disabled.add(canonical(row.key.toUpperCase()));
}
}
+ return { disabled, layerRowsByName };
} catch {
// Status unavailable — show every layer rather than hide wrongly.
}
- return out;
+ return empty;
}
// `listLayers` — active layers in this deployment.
@@ -190,6 +214,32 @@ const DEFAULT_FOR_UNKNOWN_LAYER = {
caps: {dashboards: true } as LayerCaps,
};
+/** Resolve the layer template the menu should serve for `rawKey`,
+ * matching the bundle endpoint's `pickLayerContent` precedence:
+ * 1. remote OAP UI-template row, when present + not disabled
+ * (operator edits go live the moment they push) ;
+ * 2. disk-bundled template, when remote is absent ;
+ * 3. null when neither exists (caller falls back to LAYER_DEFAULTS).
+ * Mirrors the bundle endpoint so the sidebar metadata and the
+ * rendered per-layer pages agree on which version of a template is
+ * live — previously the menu always read disk, so an operator who
+ * pushed an edited `components` set saw the dashboard reflect it
+ * while the sidebar caps / slots / alias stayed on the disk copy. */
+function resolveLayerTemplate(
+ rawKey: string,
+ layerRowsByName: Map<string, TemplateRow>,
+): LayerTemplate | null {
+ const bundled = getLayerTemplate(rawKey);
+ const row = layerRowsByName.get(formatName('layer', rawKey.toUpperCase()));
+ if (row && row.status !== 'disabled' && row.effective === 'remote' &&
row.remote) {
+ const env = parseEnvelope(row.remote.configuration);
+ if (env && env.content && typeof env.content === 'object' && 'key' in
env.content) {
+ return env.content as LayerTemplate;
+ }
+ }
+ return bundled;
+}
+
function deriveLayer(
rawKey: string,
active: boolean,
@@ -197,12 +247,16 @@ function deriveLayer(
serviceCount: number,
normal: boolean | null,
locale: Locale,
+ layerRowsByName: Map<string, TemplateRow>,
): LayerDef {
- // JSON template wins when present — alias / color / slots / caps /
- // documentLink all come from there. Hardcoded LAYER_DEFAULTS stays as
- // the fallback for layers without a template (older OAP layers,
- // custom layers).
- const rawTpl = getLayerTemplate(rawKey);
+ // Remote (OAP) wins when present — alias / color / slots / caps /
+ // components / documentLink follow the operator's published edits.
+ // Disk-bundled template is the fallback for remote-absent layers
+ // (bundled-only / OAP unreachable). Hardcoded LAYER_DEFAULTS only
+ // applies for layers without ANY template (older OAP layers, custom
+ // layers added on-the-fly by an OAP plugin we don't ship a template
+ // for).
+ const rawTpl = resolveLayerTemplate(rawKey, layerRowsByName);
const tpl = rawTpl
? localize<LayerTemplate>(rawTpl, getLayerOverlay(rawKey, locale), locale)
: null;
@@ -307,7 +361,10 @@ export function registerMenuRoute(app: FastifyInstance,
deps: MenuRouteDeps): vo
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);
+ // Same sync read also gives us the per-layer remote envelope content
+ // so deriveLayer can prefer operator-pushed edits over disk-bundled
+ // defaults (alias / components / slots / caps / colour).
+ const { disabled, layerRowsByName } = await layerSyncSnapshot(deps);
const layers = ordered
.filter((key) => !HIDDEN_LAYERS.has(key) && !disabled.has(key))
.map((key) =>
@@ -318,6 +375,7 @@ export function registerMenuRoute(app: FastifyInstance,
deps: MenuRouteDeps): vo
countByCanonical.get(key) ?? (activeCanonical.has(key) ? 0 : -1),
normalByCanonical.get(key) ?? null,
locale,
+ layerRowsByName,
),
);
@@ -332,15 +390,18 @@ export function registerMenuRoute(app: FastifyInstance,
deps: MenuRouteDeps): vo
// sidebar still renders navigation (each page surfaces its own
// OAP-down state). Service counts are unknown (-1) and layers are
// marked inactive; everything else (alias / color / slots / caps)
- // comes from the template, exactly like the live path.
+ // comes from the bundled template — no remote rows available, so
+ // we pass an empty row map and deriveLayer falls through to
+ // bundled cleanly.
const HIDDEN_LAYERS = new Set(['BANYANDB']);
const seen = new Set<string>();
const layers: LayerDef[] = [];
+ const emptyRows = new Map<string, TemplateRow>();
for (const tpl of allLayerTemplates()) {
const key = canonical(tpl.key.toUpperCase());
if (seen.has(key) || HIDDEN_LAYERS.has(key)) continue;
seen.add(key);
- layers.push(deriveLayer(key, false, null, -1, null, locale));
+ layers.push(deriveLayer(key, false, null, -1, null, locale,
emptyRows));
}
const body: MenuResponse = {
layers,
diff --git a/apps/bff/src/logic/templates/sync.ts
b/apps/bff/src/logic/templates/sync.ts
index 9002a05..752a5e5 100644
--- a/apps/bff/src/logic/templates/sync.ts
+++ b/apps/bff/src/logic/templates/sync.ts
@@ -178,6 +178,74 @@ export async function bootSeed(deps: SyncDeps):
Promise<SyncStatus> {
return status;
}
+/**
+ * Block until OAP admin is reachable, then return. Uses `client.list()`
+ * as the readiness check (same call bootSeed itself runs first, so a
+ * success here proves the seed can proceed). Backs off 1s → 2s → 4s
+ * → … capped at 60s so a slow OAP startup doesn't pin the loop on the
+ * fast end; each failed attempt emits one warn line so an operator
+ * grepping logs sees the wait progress.
+ *
+ * Why we wait here instead of letting `bootSeed` fall through on the
+ * first failure: when OAP and Horizon start in the same compose / k8s
+ * rollout, OAP's admin module often binds after the BFF process is
+ * already up. The old behaviour was a single attempt → warn → no
+ * retry → no templates ever pushed for that BFF lifetime. This
+ * function fixes the race without introducing any steady-state
+ * polling: once `list()` succeeds we return, the seed runs once,
+ * we never touch the admin port from here again until the operator
+ * triggers an admin action.
+ *
+ * `signal` lets the caller (server boot) cancel the wait on shutdown
+ * so the BFF process can exit cleanly even mid-backoff.
+ */
+const READINESS_INITIAL_DELAY_MS = 1000;
+const READINESS_MAX_DELAY_MS = 60_000;
+
+export async function waitForOapAdminReady(
+ deps: SyncDeps,
+ signal?: AbortSignal,
+): Promise<void> {
+ let delay = READINESS_INITIAL_DELAY_MS;
+ let attempt = 0;
+ for (;;) {
+ if (signal?.aborted) return;
+ attempt++;
+ try {
+ await deps.client.list();
+ if (attempt > 1) {
+ deps.logger.info({ attempt }, 'OAP admin reachable — proceeding with
boot seed');
+ }
+ return;
+ } catch (err) {
+ deps.logger.warn(
+ { err: errMsg(err), attempt, nextRetryInMs: delay },
+ 'OAP admin unreachable — retrying readiness check',
+ );
+ }
+ await sleepCancelable(delay, signal);
+ delay = Math.min(delay * 2, READINESS_MAX_DELAY_MS);
+ }
+}
+
+function sleepCancelable(ms: number, signal?: AbortSignal): Promise<void> {
+ return new Promise((resolve) => {
+ if (signal?.aborted) {
+ resolve();
+ return;
+ }
+ const timer = setTimeout(() => {
+ signal?.removeEventListener('abort', onAbort);
+ resolve();
+ }, ms);
+ const onAbort = (): void => {
+ clearTimeout(timer);
+ resolve();
+ };
+ signal?.addEventListener('abort', onAbort, { once: true });
+ });
+}
+
/** Force the next caller of `getSyncStatus` to re-list OAP. No I/O here. */
export function resync(): void {
invalidateSyncCache();
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 78c20a3..ff239a5 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -58,7 +58,7 @@ import { registerOverviewRoutes } from
'./http/config/overview.js';
import { registerConfigBundleRoute } from './http/config/bundle.js';
import { registerTemplateSyncAdminRoutes } from
'./http/admin/template-sync.js';
import { buildOapClients } from './client/index.js';
-import { bootSeed } from './logic/templates/sync.js';
+import { bootSeed, waitForOapAdminReady } from './logic/templates/sync.js';
import { iterateBundledTemplates, iterateBundledOverlays } from
'./logic/templates/aggregator.js';
// Admin (operational tools)
import { registerDslCatalogRoutes } from './http/admin/dsl/catalog.js';
@@ -183,10 +183,16 @@ registerAsyncProfileRoutes(app, { config: source,
sessions });
// ── Config ─────────────────────────────────────────────────────────
registerDashboardConfigRoute(app, { config: source, sessions });
registerLayerTemplateRoutes(app, { config: source, sessions });
-// Spawn the bundled-template fs.watch once per process. Skipped in
-// tests (each test file imports the loader; a watcher per import
-// EMFILEs CI under low ulimits). Production calls this exactly once.
-if (process.env.NODE_ENV !== 'test') startLayerTemplateWatcher();
+// Spawn the bundled-template fs.watch ONLY in development. Bundled
+// templates ship inside the BFF image — they're immutable in prod
+// (rebuild + redeploy is how you'd change them), so an fs.watch on
+// `bundled_templates/` is pure overhead there. `tsx watch` only
+// reloads on `.ts` edits, so during local dev the watcher lets a
+// JSON edit (layer template, overlay catalog) take effect without
+// a manual restart. Tests skip it for the same EMFILE reason as
+// before (each test file imports the loader; a watcher per import
+// would exhaust the fd ceiling on low-ulimit CI).
+if (process.env.NODE_ENV === 'development') startLayerTemplateWatcher();
registerAlarmsConfigRoutes(app, { config: source, sessions, audit, store:
alarmsStore, serviceLayer });
registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore
});
registerOverviewRoutes(app, { config: source, sessions });
@@ -251,34 +257,49 @@ app.get('/api/health', async () => ({
}));
const { host, port } = source.current.server;
+// AbortController bound to shutdown so the readiness wait can exit
+// cleanly when the BFF is signal-killed mid-backoff (k8s rolling
+// restart, ctrl-c, etc.) instead of holding the process open.
+const bootSeedAbort = new AbortController();
app.listen({ host, port }).then(
() => {
logger.info(`BFF listening on http://${host}:${port}`);
- // Fire-and-forget the boot-time OAP template seed: list OAP, POST any
- // bundled template that's missing on the OAP side. This is the ONLY
- // path that writes implicitly to OAP — runtime sync is read-only.
- // Failures are non-fatal: the BFF stays up, the UI falls back to
- // bundled templates and shows the read-only banner.
- void bootSeed({
- client: buildOapClients(source.current).uiTemplate(),
- bundled: () => iterateBundledTemplates(),
- bundledOverlays: () => iterateBundledOverlays(),
- logger,
- })
- .then((status) => {
+ // Wait for OAP admin readiness, then run the boot-time template
+ // seed ONCE. Two-phase so we don't lose the seed when OAP is still
+ // starting up alongside the BFF (compose / k8s rollout, slow OAP
+ // module wiring). The wait is a backoff loop that warn-logs each
+ // failed ping; once `client.list()` succeeds we run bootSeed and
+ // never touch the admin port from here again until an operator
+ // admin action triggers a fresh sync. The seed itself is
+ // absent-only (`seedMissing` skips templates already present), so
+ // a successful previous boot leaves nothing to re-push.
+ void (async (): Promise<void> => {
+ const deps = {
+ client: buildOapClients(source.current).uiTemplate(),
+ bundled: () => iterateBundledTemplates(),
+ bundledOverlays: () => iterateBundledOverlays(),
+ logger,
+ };
+ try {
+ await waitForOapAdminReady(deps, bootSeedAbort.signal);
+ if (bootSeedAbort.signal.aborted) return;
+ const status = await bootSeed(deps);
if (status.unreachable) {
+ // Admin port flapped between readiness probe and the actual
+ // `bootSeed.list()` — very narrow window, but log it the
+ // same way so the operator notices.
logger.warn(
{ lastSuccessfulSyncAt: status.lastSuccessfulSyncAt },
- 'OAP UI-template boot seed: admin unreachable, rendering bundled
(admin pages will be read-only until OAP comes back)',
+ 'OAP UI-template boot seed: admin became unreachable between
readiness and seed',
);
} else {
const counts = countByStatus(status.rows);
logger.info(counts, 'OAP UI-template boot seed: complete');
}
- })
- .catch((err) => {
+ } catch (err) {
logger.error({ err }, 'OAP UI-template boot seed: unexpected error');
- });
+ }
+ })();
},
(err) => {
logger.fatal({ err }, 'failed to start BFF');
@@ -294,6 +315,9 @@ function countByStatus(rows: Array<{ status: string }>):
Record<string, number>
async function shutdown(signal: string) {
logger.info({ signal }, 'shutting down');
+ // Cancel an in-flight OAP-admin readiness wait so the boot-seed
+ // promise resolves quickly instead of blocking shutdown.
+ bootSeedAbort.abort();
await app.close();
await sessions.close();
await audit.close();
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index af41a71..9a441c9 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1179,5 +1179,17 @@
"Showing the OAP-live version. End users render the same bytes.": "Zeigt die
OAP-Live-Version — exakt was Endnutzer sehen.",
"reset to": "zurücksetzen auf",
"Discard local edits and reload only the disk-shipped overlay (ignore OAP
overlay).": "Lokale Änderungen verwerfen und nur das mitgelieferte Disk-Overlay
neu laden (OAP-Overlay ignorieren).",
- "Discard local edits and reload from the OAP overlay row.": "Lokale
Änderungen verwerfen und aus der OAP-Overlay-Zeile neu laden."
+ "Discard local edits and reload from the OAP overlay row.": "Lokale
Änderungen verwerfen und aus der OAP-Overlay-Zeile neu laden.",
+ "OAP query host is unreachable": "OAP-Query-Host ist nicht erreichbar",
+ "Check that the OAP query host is up and reachable from the BFF. Dashboards
stay hidden until OAP responds — bundled fallbacks would show empty data and
look like a dashboard misconfiguration.": "Prüfe, ob der OAP-Query-Host läuft
und vom BFF aus erreichbar ist. Dashboards bleiben verborgen, bis OAP antwortet
— mitgelieferte Fallbacks würden leere Daten zeigen und wie eine
Dashboard-Fehlkonfiguration aussehen.",
+ "Retrying…": "Erneuter Versuch…",
+ "Retry now": "Jetzt erneut versuchen",
+ "Auto-retries every 30s in the background. Admin pages remain available in
read-only mode.": "Automatische Wiederholung alle 30 s im Hintergrund.
Admin-Seiten bleiben im Nur-Lese-Modus verfügbar.",
+ "No data is flowing yet": "Es fließen noch keine Daten",
+ "OAP hasn't received any service data. The relevant overview will appear
here automatically as soon as data starts arriving.": "OAP hat noch keine
Service-Daten empfangen. Die passende Übersicht erscheint hier automatisch,
sobald Daten eintreffen.",
+ "Ask your operations team to verify that the agents or receivers for your
services are configured and pointing at this OAP.": "Bitte das Operations-Team
prüfen, ob die Agents oder Receiver für die Services konfiguriert sind und auf
dieses OAP zeigen.",
+ "No dashboard configured yet": "Noch kein Dashboard konfiguriert",
+ "{n} layer reporting services but no overview dashboard is set up.": "{n}
Ebene(n) melden Services, aber es ist kein Übersichts-Dashboard eingerichtet.",
+ "Ask your operations team to set up a dashboard for you.": "Bitte das
Operations-Team, ein Dashboard einzurichten.",
+ "Routing…": "Weiterleitung…"
}
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 7f478ce..3b33c6b 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1179,5 +1179,17 @@
"Showing the OAP-live version. End users render the same bytes.": "Showing
the OAP-live version. End users render the same bytes.",
"reset to": "reset to",
"Discard local edits and reload only the disk-shipped overlay (ignore OAP
overlay).": "Discard local edits and reload only the disk-shipped overlay
(ignore OAP overlay).",
- "Discard local edits and reload from the OAP overlay row.": "Discard local
edits and reload from the OAP overlay row."
+ "Discard local edits and reload from the OAP overlay row.": "Discard local
edits and reload from the OAP overlay row.",
+ "OAP query host is unreachable": "OAP query host is unreachable",
+ "Check that the OAP query host is up and reachable from the BFF. Dashboards
stay hidden until OAP responds — bundled fallbacks would show empty data and
look like a dashboard misconfiguration.": "Check that the OAP query host is up
and reachable from the BFF. Dashboards stay hidden until OAP responds — bundled
fallbacks would show empty data and look like a dashboard misconfiguration.",
+ "Retrying…": "Retrying…",
+ "Retry now": "Retry now",
+ "Auto-retries every 30s in the background. Admin pages remain available in
read-only mode.": "Auto-retries every 30s in the background. Admin pages remain
available in read-only mode.",
+ "No data is flowing yet": "No data is flowing yet",
+ "OAP hasn't received any service data. The relevant overview will appear
here automatically as soon as data starts arriving.": "OAP hasn't received any
service data. The relevant overview will appear here automatically as soon as
data starts arriving.",
+ "Ask your operations team to verify that the agents or receivers for your
services are configured and pointing at this OAP.": "Ask your operations team
to verify that the agents or receivers for your services are configured and
pointing at this OAP.",
+ "No dashboard configured yet": "No dashboard configured yet",
+ "{n} layer reporting services but no overview dashboard is set up.": "{n}
layer reporting services but no overview dashboard is set up.",
+ "Ask your operations team to set up a dashboard for you.": "Ask your
operations team to set up a dashboard for you.",
+ "Routing…": "Routing…"
}
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 45bad9b..34acc6f 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1179,5 +1179,17 @@
"Showing the OAP-live version. End users render the same bytes.": "Mostrando
la versión en vivo de OAP — los usuarios finales ven los mismos bytes.",
"reset to": "restablecer a",
"Discard local edits and reload only the disk-shipped overlay (ignore OAP
overlay).": "Descarta los cambios locales y recarga solo el overlay de disco
(ignora el overlay de OAP).",
- "Discard local edits and reload from the OAP overlay row.": "Descarta los
cambios locales y recarga desde la fila de overlay de OAP."
+ "Discard local edits and reload from the OAP overlay row.": "Descarta los
cambios locales y recarga desde la fila de overlay de OAP.",
+ "OAP query host is unreachable": "No se puede acceder al host de consultas
de OAP",
+ "Check that the OAP query host is up and reachable from the BFF. Dashboards
stay hidden until OAP responds — bundled fallbacks would show empty data and
look like a dashboard misconfiguration.": "Verifica que el host de consultas de
OAP esté en funcionamiento y accesible desde el BFF. Los paneles permanecen
ocultos hasta que OAP responda; mostrar los respaldos integrados mostraría
datos vacíos y parecería un error de configuración del panel.",
+ "Retrying…": "Reintentando…",
+ "Retry now": "Reintentar ahora",
+ "Auto-retries every 30s in the background. Admin pages remain available in
read-only mode.": "Reintenta automáticamente cada 30 s en segundo plano. Las
páginas de administración permanecen disponibles en modo solo lectura.",
+ "No data is flowing yet": "Aún no llegan datos",
+ "OAP hasn't received any service data. The relevant overview will appear
here automatically as soon as data starts arriving.": "OAP aún no ha recibido
datos de servicios. La vista general correspondiente aparecerá aquí
automáticamente en cuanto comiencen a llegar datos.",
+ "Ask your operations team to verify that the agents or receivers for your
services are configured and pointing at this OAP.": "Solicita al equipo de
operaciones que verifique que los agents o receivers de tus servicios están
configurados y apuntando a este OAP.",
+ "No dashboard configured yet": "Aún no hay un panel configurado",
+ "{n} layer reporting services but no overview dashboard is set up.": "{n}
capa(s) están reportando servicios, pero no se ha configurado un panel
general.",
+ "Ask your operations team to set up a dashboard for you.": "Pide al equipo
de operaciones que configure un panel para ti.",
+ "Routing…": "Redirigiendo…"
}
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index cd03348..6efaabe 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1179,5 +1179,17 @@
"Showing the OAP-live version. End users render the same bytes.": "Affiche
la version OAP en ligne — exactement ce que voient les utilisateurs finaux.",
"reset to": "réinitialiser à",
"Discard local edits and reload only the disk-shipped overlay (ignore OAP
overlay).": "Abandonner les modifications locales et recharger uniquement
l'overlay livré sur disque (ignorer l'overlay OAP).",
- "Discard local edits and reload from the OAP overlay row.": "Abandonner les
modifications locales et recharger depuis la ligne d'overlay OAP."
+ "Discard local edits and reload from the OAP overlay row.": "Abandonner les
modifications locales et recharger depuis la ligne d'overlay OAP.",
+ "OAP query host is unreachable": "L'hôte de requête OAP est inaccessible",
+ "Check that the OAP query host is up and reachable from the BFF. Dashboards
stay hidden until OAP responds — bundled fallbacks would show empty data and
look like a dashboard misconfiguration.": "Vérifie que l'hôte de requête OAP
est démarré et joignable depuis le BFF. Les tableaux de bord restent masqués
jusqu'à ce qu'OAP réponde — afficher les solutions de repli intégrées
montrerait des données vides et ressemblerait à une mauvaise configuration de
tableau de bord.",
+ "Retrying…": "Nouvelle tentative…",
+ "Retry now": "Réessayer maintenant",
+ "Auto-retries every 30s in the background. Admin pages remain available in
read-only mode.": "Réessais automatiques toutes les 30 s en arrière-plan. Les
pages d'administration restent accessibles en lecture seule.",
+ "No data is flowing yet": "Aucune donnée ne circule encore",
+ "OAP hasn't received any service data. The relevant overview will appear
here automatically as soon as data starts arriving.": "OAP n'a encore reçu
aucune donnée de service. La vue d'ensemble correspondante apparaîtra ici
automatiquement dès que des données commenceront à arriver.",
+ "Ask your operations team to verify that the agents or receivers for your
services are configured and pointing at this OAP.": "Demandez à votre équipe
d'exploitation de vérifier que les agents ou récepteurs de vos services sont
configurés et pointent vers cet OAP.",
+ "No dashboard configured yet": "Aucun tableau de bord configuré pour
l'instant",
+ "{n} layer reporting services but no overview dashboard is set up.": "{n}
couche(s) signalent des services, mais aucun tableau de bord d'ensemble n'est
configuré.",
+ "Ask your operations team to set up a dashboard for you.": "Demandez à votre
équipe d'exploitation de configurer un tableau de bord pour vous.",
+ "Routing…": "Routage…"
}
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index d5aefd9..2213ab5 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1179,5 +1179,17 @@
"Showing the OAP-live version. End users render the same bytes.": "OAP
のライブ版を表示中 — エンドユーザーが見るものと同じです。",
"reset to": "リセット先",
"Discard local edits and reload only the disk-shipped overlay (ignore OAP
overlay).": "ローカル編集を破棄し、同梱のディスクオーバーレイのみを再読み込み(OAP オーバーレイは無視)。",
- "Discard local edits and reload from the OAP overlay row.": "ローカル編集を破棄し、OAP
オーバーレイ行から再読み込み。"
+ "Discard local edits and reload from the OAP overlay row.": "ローカル編集を破棄し、OAP
オーバーレイ行から再読み込み。",
+ "OAP query host is unreachable": "OAP クエリホストに到達できません",
+ "Check that the OAP query host is up and reachable from the BFF. Dashboards
stay hidden until OAP responds — bundled fallbacks would show empty data and
look like a dashboard misconfiguration.": "OAP クエリホストが起動しており BFF
から到達可能か確認してください。OAP が応答するまでダッシュボードは非表示のままです —
バンドルされたフォールバックを表示すると空のデータとなり、ダッシュボードの設定ミスのように見えるためです。",
+ "Retrying…": "再試行中…",
+ "Retry now": "今すぐ再試行",
+ "Auto-retries every 30s in the background. Admin pages remain available in
read-only mode.": "バックグラウンドで 30 秒ごとに自動再試行します。管理ページは読み取り専用モードで引き続き利用できます。",
+ "No data is flowing yet": "まだデータが流れていません",
+ "OAP hasn't received any service data. The relevant overview will appear
here automatically as soon as data starts arriving.": "OAP
はまだサービスデータを受信していません。データが届き次第、該当する概要がここに自動的に表示されます。",
+ "Ask your operations team to verify that the agents or receivers for your
services are configured and pointing at this OAP.": "運用チームに、サービスの agent または
receiver が設定されてこの OAP を指しているか確認するよう依頼してください。",
+ "No dashboard configured yet": "まだダッシュボードが設定されていません",
+ "{n} layer reporting services but no overview dashboard is set up.": "{n}
個のレイヤーがサービスを報告していますが、概要ダッシュボードが設定されていません。",
+ "Ask your operations team to set up a dashboard for you.":
"運用チームにダッシュボードの設定を依頼してください。",
+ "Routing…": "ルーティング中…"
}
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 6f7613b..c11a8c7 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1179,5 +1179,17 @@
"Showing the OAP-live version. End users render the same bytes.": "OAP 라이브
버전 표시 중 — 최종 사용자가 보는 것과 동일합니다.",
"reset to": "재설정 대상",
"Discard local edits and reload only the disk-shipped overlay (ignore OAP
overlay).": "로컬 편집을 버리고 디스크에 동봉된 오버레이만 다시 불러옵니다(OAP 오버레이 무시).",
- "Discard local edits and reload from the OAP overlay row.": "로컬 편집을 버리고 OAP
오버레이 행에서 다시 불러옵니다."
+ "Discard local edits and reload from the OAP overlay row.": "로컬 편집을 버리고 OAP
오버레이 행에서 다시 불러옵니다.",
+ "OAP query host is unreachable": "OAP 쿼리 호스트에 접근할 수 없습니다",
+ "Check that the OAP query host is up and reachable from the BFF. Dashboards
stay hidden until OAP responds — bundled fallbacks would show empty data and
look like a dashboard misconfiguration.": "OAP 쿼리 호스트가 실행 중이고 BFF에서 접근 가능한지
확인하세요. OAP가 응답할 때까지 대시보드는 숨겨집니다 — 번들 폴백을 표시하면 빈 데이터가 나오고 대시보드 설정 오류처럼 보일 수
있습니다.",
+ "Retrying…": "재시도 중…",
+ "Retry now": "지금 재시도",
+ "Auto-retries every 30s in the background. Admin pages remain available in
read-only mode.": "백그라운드에서 30초마다 자동 재시도합니다. 관리 페이지는 읽기 전용 모드로 계속 사용할 수 있습니다.",
+ "No data is flowing yet": "아직 데이터가 들어오지 않았습니다",
+ "OAP hasn't received any service data. The relevant overview will appear
here automatically as soon as data starts arriving.": "OAP가 아직 어떤 서비스 데이터도 수신하지
않았습니다. 데이터가 도착하는 즉시 관련 개요가 여기에 자동으로 표시됩니다.",
+ "Ask your operations team to verify that the agents or receivers for your
services are configured and pointing at this OAP.": "운영 팀에 서비스의 agent 또는
receiver가 구성되어 있고 이 OAP를 가리키는지 확인하도록 요청하세요.",
+ "No dashboard configured yet": "아직 대시보드가 구성되지 않았습니다",
+ "{n} layer reporting services but no overview dashboard is set up.": "{n}개
레이어가 서비스를 보고하고 있지만 개요 대시보드가 설정되어 있지 않습니다.",
+ "Ask your operations team to set up a dashboard for you.": "운영 팀에 대시보드를 설정해
달라고 요청하세요.",
+ "Routing…": "라우팅 중…"
}
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index e12ea9a..3732315 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1179,5 +1179,17 @@
"Showing the OAP-live version. End users render the same bytes.": "Exibindo
a versão em produção do OAP — os usuários finais veem exatamente isto.",
"reset to": "redefinir para",
"Discard local edits and reload only the disk-shipped overlay (ignore OAP
overlay).": "Descartar edições locais e recarregar apenas o overlay do disco
(ignorar o overlay do OAP).",
- "Discard local edits and reload from the OAP overlay row.": "Descartar
edições locais e recarregar a partir da linha de overlay do OAP."
+ "Discard local edits and reload from the OAP overlay row.": "Descartar
edições locais e recarregar a partir da linha de overlay do OAP.",
+ "OAP query host is unreachable": "Host de consulta do OAP inacessível",
+ "Check that the OAP query host is up and reachable from the BFF. Dashboards
stay hidden until OAP responds — bundled fallbacks would show empty data and
look like a dashboard misconfiguration.": "Verifique se o host de consulta do
OAP está ativo e acessível pelo BFF. Os dashboards permanecem ocultos até que o
OAP responda — os fallbacks integrados mostrariam dados vazios e pareceriam
erro de configuração de dashboard.",
+ "Retrying…": "Tentando novamente…",
+ "Retry now": "Tentar agora",
+ "Auto-retries every 30s in the background. Admin pages remain available in
read-only mode.": "Repete automaticamente a cada 30 s em segundo plano. As
páginas de administração permanecem disponíveis em modo somente leitura.",
+ "No data is flowing yet": "Ainda não há dados chegando",
+ "OAP hasn't received any service data. The relevant overview will appear
here automatically as soon as data starts arriving.": "O OAP ainda não recebeu
dados de nenhum serviço. A visão geral correspondente aparecerá aqui
automaticamente assim que os dados começarem a chegar.",
+ "Ask your operations team to verify that the agents or receivers for your
services are configured and pointing at this OAP.": "Peça à sua equipe de
operações para verificar se os agents ou receivers dos seus serviços estão
configurados e apontando para este OAP.",
+ "No dashboard configured yet": "Ainda não há dashboard configurado",
+ "{n} layer reporting services but no overview dashboard is set up.": "{n}
camada(s) estão reportando serviços, mas nenhum dashboard de visão geral foi
configurado.",
+ "Ask your operations team to set up a dashboard for you.": "Peça à equipe de
operações para configurar um dashboard para você.",
+ "Routing…": "Roteando…"
}
diff --git a/apps/ui/src/i18n/locales/zh-CN.json
b/apps/ui/src/i18n/locales/zh-CN.json
index bc44031..e6c7aab 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1179,5 +1179,17 @@
"Showing the OAP-live version. End users render the same bytes.": "显示 OAP
实时版本 — 与终端用户看到的内容一致。",
"reset to": "重置为",
"Discard local edits and reload only the disk-shipped overlay (ignore OAP
overlay).": "丢弃本地编辑,只重新加载随包的磁盘覆盖层(忽略 OAP 覆盖层)。",
- "Discard local edits and reload from the OAP overlay row.": "丢弃本地编辑,从 OAP
覆盖行重新加载。"
+ "Discard local edits and reload from the OAP overlay row.": "丢弃本地编辑,从 OAP
覆盖行重新加载。",
+ "OAP query host is unreachable": "OAP 查询主机不可达",
+ "Check that the OAP query host is up and reachable from the BFF. Dashboards
stay hidden until OAP responds — bundled fallbacks would show empty data and
look like a dashboard misconfiguration.": "请确认 OAP 查询主机已启动并且 BFF 能够访问。OAP
恢复响应前仪表板保持隐藏 — 显示内置回退会带来空数据,看起来像是仪表板配置错误。",
+ "Retrying…": "正在重试…",
+ "Retry now": "立即重试",
+ "Auto-retries every 30s in the background. Admin pages remain available in
read-only mode.": "后台每 30 秒自动重试。管理页面仍可在只读模式下访问。",
+ "No data is flowing yet": "还没有数据接入",
+ "OAP hasn't received any service data. The relevant overview will appear
here automatically as soon as data starts arriving.": "OAP
尚未收到任何服务数据。数据一旦开始接入,相关概览会自动出现。",
+ "Ask your operations team to verify that the agents or receivers for your
services are configured and pointing at this OAP.": "请运维团队确认服务的 agent 或
receiver 已配置并指向此 OAP。",
+ "No dashboard configured yet": "尚未配置仪表板",
+ "{n} layer reporting services but no overview dashboard is set up.": "{n}
个分层已上报服务,但尚未配置概览仪表板。",
+ "Ask your operations team to set up a dashboard for you.": "请运维团队为你配置仪表板。",
+ "Routing…": "路由跳转中…"
}
diff --git a/apps/ui/src/render/overview/OverviewLanding.vue
b/apps/ui/src/render/overview/OverviewLanding.vue
index 66e21d2..53df8d9 100644
--- a/apps/ui/src/render/overview/OverviewLanding.vue
+++ b/apps/ui/src/render/overview/OverviewLanding.vue
@@ -29,11 +29,13 @@
editor where they can configure the empty deployment.
-->
<script setup lang="ts">
-import { computed, watchEffect } from 'vue';
+import { computed, ref, watchEffect } from 'vue';
import { useRoute, useRouter } from 'vue-router';
+import { useI18n } from 'vue-i18n';
import { useOverviewDashboards } from
'@/render/overview/useOverviewDashboards';
import { firstLayerTab, useLayers } from '@/shell/useLayers';
+const { t } = useI18n({ useScope: 'global' });
const router = useRouter();
const route = useRoute();
const { publicOverviews, isLoading: overviewsLoading } =
useOverviewDashboards();
@@ -42,6 +44,7 @@ const {
oapError,
availableLayers,
isLoading: layersLoading,
+ refetch: refetchLayers,
} = useLayers();
/** Render the empty card (no redirect cascade) when the route is the
@@ -49,10 +52,35 @@ const {
* by the cascade itself when there's nothing to land on. */
const forceEmpty = computed<boolean>(() => route.name === 'landing-empty');
+/** Block dashboard render when OAP is unreachable. The landing page
+ * is the only surface that fully blocks (per the team policy — see
+ * PR #19 thread): per-layer pages still show their bundled-fallback
+ * view so an operator can verify a template they just edited.
+ * Cascade is suppressed alongside so we don't redirect into an
+ * empty overview / layer page that would just re-show the same
+ * error one level deeper. */
+const blockForOapDown = computed<boolean>(
+ () => !oapReachable.value && !overviewsLoading.value && !layersLoading.value,
+);
+
+const retrying = ref(false);
+async function retryOap(): Promise<void> {
+ if (retrying.value) return;
+ retrying.value = true;
+ try {
+ await refetchLayers();
+ } finally {
+ retrying.value = false;
+ }
+}
+
watchEffect(() => {
// Wait for both data sources — without `layers`, a fresh boot would
// briefly fall through while the menu is still in flight.
if (overviewsLoading.value || layersLoading.value) return;
+ // OAP down → freeze the cascade so the operator sits on the warning
+ // page until they retry (or background refetch lands a success).
+ if (blockForOapDown.value) return;
// Direct visit to `/landing-empty` — render the card, no redirect.
if (forceEmpty.value) return;
@@ -85,9 +113,30 @@ watchEffect(() => {
<template>
<div class="landing">
- <div v-if="!oapReachable && !overviewsLoading && !layersLoading"
class="banner err">
- <strong>OAP unreachable.</strong>
- {{ oapError ?? 'Check that the OAP query host is up and reachable from
the BFF.' }}
+ <!-- OAP query host unreachable — render a full-page warning and
+ freeze the redirect cascade. Distinct from `admin host
+ unreachable` (which is acceptable: operators can still browse
+ existing dashboards, admin pages drop to read-only). A dead
+ query port means no service data anywhere, so showing an
+ "empty" dashboard would mislead operators into chasing a
+ dashboard-config problem when the real problem is upstream.
+ Retry button re-runs the menu fetch; the topbar's global
+ retry-poll also keeps firing in the background. -->
+ <div v-if="blockForOapDown" class="oap-down">
+ <div class="oap-down-card">
+ <h2>{{ t('OAP query host is unreachable') }}</h2>
+ <p>
+ {{ oapError ?? t('Check that the OAP query host is up and reachable
from the BFF. Dashboards stay hidden until OAP responds — bundled fallbacks
would show empty data and look like a dashboard misconfiguration.') }}
+ </p>
+ <div class="oap-down-actions">
+ <button type="button" class="sw-btn is-primary" :disabled="retrying"
@click="retryOap">
+ {{ retrying ? t('Retrying…') : t('Retry now') }}
+ </button>
+ </div>
+ <p class="oap-down-foot">
+ {{ t('Auto-retries every 30s in the background. Admin pages remain
available in read-only mode.') }}
+ </p>
+ </div>
</div>
<!-- Empty landing — rendered for the dedicated `/landing-empty`
route. Cascade lands here automatically when there's no
@@ -101,40 +150,64 @@ watchEffect(() => {
-->
<div v-else-if="forceEmpty" class="empty">
<div v-if="availableLayers.length === 0" class="empty-card">
- <h2>No data is flowing yet</h2>
+ <h2>{{ t('No data is flowing yet') }}</h2>
<p>
- OAP hasn't received any service data. The relevant overview will
appear here
- automatically as soon as data starts arriving.
+ {{ t('OAP hasn\'t received any service data. The relevant overview
will appear here automatically as soon as data starts arriving.') }}
</p>
<p class="empty-ask">
- Ask your operations team to verify that the agents or receivers for
your
- services are configured and pointing at this OAP.
+ {{ t('Ask your operations team to verify that the agents or
receivers for your services are configured and pointing at this OAP.') }}
</p>
</div>
<div v-else class="empty-card">
- <h2>No dashboard configured yet</h2>
+ <h2>{{ t('No dashboard configured yet') }}</h2>
<p>
- {{ availableLayers.length }} layer{{ availableLayers.length === 1 ?
'' : 's' }}
- {{ availableLayers.length === 1 ? 'is' : 'are' }} reporting
services, but no
- overview dashboard has been set up for them.
+ {{ t('{n} layer reporting services but no overview dashboard is set
up.', { n: availableLayers.length }) }}
</p>
<p class="empty-ask">
- Ask your operations team to set up a dashboard for you.
+ {{ t('Ask your operations team to set up a dashboard for you.') }}
</p>
</div>
</div>
- <div v-else class="empty">Routing…</div>
+ <div v-else class="empty">{{ t('Routing…') }}</div>
</div>
</template>
<style scoped>
.landing { padding: 20px 20px 60px; max-width: 1440px; margin: 0 auto; }
-.banner.err {
- margin: 0 0 16px; padding: 10px 12px;
- background: var(--sw-err-soft);
- border: 1px solid rgba(239, 68, 68, 0.3);
- border-radius: 6px; color: #f87171; font-size: 12px; line-height: 1.5;
+
+/* OAP query host unreachable — full-page warning card replacing
+ * the redirect cascade. Centered, large enough to be unmissable,
+ * but not so loud the operator can't see the topbar banner above. */
+.oap-down { padding: 60px 20px; display: flex; justify-content: center; }
+.oap-down-card {
+ max-width: 600px;
+ width: 100%;
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-err);
+ border-left: 3px solid var(--sw-err);
+ border-radius: 8px;
+ padding: 28px;
+ text-align: left;
}
+.oap-down-card h2 {
+ margin: 0 0 12px;
+ font-size: var(--sw-fs-lg);
+ color: var(--sw-err);
+ font-weight: var(--sw-fw-semibold);
+}
+.oap-down-card p {
+ margin: 0 0 16px;
+ font-size: var(--sw-fs-base);
+ color: var(--sw-fg-1);
+ line-height: var(--sw-lh-relaxed);
+}
+.oap-down-foot {
+ margin: 16px 0 0 !important;
+ font-size: var(--sw-fs-sm) !important;
+ color: var(--sw-fg-3) !important;
+}
+.oap-down-actions { display: flex; gap: 8px; }
+
.empty { padding: 60px 20px; text-align: center; color: var(--sw-fg-3);
font-size: 13px; }
.empty-card {
background: var(--sw-bg-1);