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

commit 4c9be3bafcaedeccf3de81a2a8fc14d1749327c4
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 15:06:36 2026 +0800

    topbar: oap info chip with version, server timezone, health
    
    GET /api/oap/info aliases version + getTimeInfo + checkHealth into one
    GraphQL request. Soft-fails to {reachable:false,error} when OAP is down
    so the topbar can render an offline chip without crashing the route.
    
    - packages/api-client/oap-info.ts: shared OapInfo wire type. Notes
      that OAP's timezone field is a string in ±HHmm form, not minutes.
      Exposes parseOapTimezoneMinutes() to convert.
    - apps/bff/oap/info-routes.ts: registers /api/oap/info; reuses
      graphqlPost client.
    - apps/ui/composables/useOapInfo.ts: vue-query (20s stale, 30s poll)
      plus a toServerTzString(epochMs, granularity) helper that the
      time-range picker will use to send queries in OAP server TZ while
      rendering local-TZ labels to the user.
    - AppTopbar: replaces stubbed 'env: production' pill with a live OAP
      status chip (version · UTC offset · pulse dot). Click → cluster
      status. Tooltip exposes server clock + health score. TZ label tints
      amber when browser TZ differs from server TZ.
    - ClusterStatusView (new): early preview at /operate/cluster — surfaces
      version / server timezone / server clock / health score as KPI cards.
      Phase 6/7 expands into the full module activity matrix per
      docs/design/system-status.md.
---
 apps/bff/src/oap/info-routes.ts                 |  92 ++++++++++
 apps/bff/src/server.ts                          |   2 +
 apps/ui/src/api/client.ts                       |  12 +-
 apps/ui/src/components/shell/AppTopbar.vue      | 105 ++++++++++-
 apps/ui/src/composables/useOapInfo.ts           | 100 ++++++++++
 apps/ui/src/router/index.ts                     |   2 +-
 apps/ui/src/views/operate/ClusterStatusView.vue | 235 ++++++++++++++++++++++++
 packages/api-client/src/index.ts                |   2 +
 packages/api-client/src/oap-info.ts             |  52 ++++++
 9 files changed, 593 insertions(+), 9 deletions(-)

diff --git a/apps/bff/src/oap/info-routes.ts b/apps/bff/src/oap/info-routes.ts
new file mode 100644
index 0000000..cca0c6e
--- /dev/null
+++ b/apps/bff/src/oap/info-routes.ts
@@ -0,0 +1,92 @@
+/*
+ * 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 { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../config/loader.js';
+import type { SessionStore } from '../auth/sessions.js';
+import { requireAuth } from '../auth/middleware.js';
+import { graphqlPost } from './graphql-client.js';
+
+/**
+ * One round-trip combining `version`, `getTimeInfo`, and `checkHealth`.
+ *
+ *   - `version` returns the OAP build version string.
+ *   - `getTimeInfo.timezone` is minutes-from-UTC (e.g. 480 for UTC+8).
+ *   - `getTimeInfo.currentTimestamp` is the server's "now" in ms; lets the
+ *     UI compute clock drift and convert user-input ranges into the
+ *     server's TZ for query construction.
+ *   - `checkHealth.score` is 0 healthy, >0 degraded, <0 not started.
+ */
+const INFO_QUERY = /* GraphQL */ `
+  query HorizonOapInfo {
+    version
+    time: getTimeInfo {
+      timezone
+      currentTimestamp
+    }
+    health: checkHealth {
+      score
+      details
+    }
+  }
+`;
+
+interface InfoRaw {
+  version?: string | null;
+  time?: { timezone?: string | null; currentTimestamp?: number | null } | null;
+  health?: { score?: number | null; details?: string | null } | null;
+}
+
+import type { OapInfo } from '@skywalking-horizon-ui/api-client';
+
+export interface InfoRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  fetch?: FetchLike;
+}
+
+export function registerOapInfoRoute(app: FastifyInstance, deps: 
InfoRouteDeps): void {
+  const auth = requireAuth(deps);
+  app.get('/api/oap/info', { preHandler: auth }, async (_req: FastifyRequest, 
reply: FastifyReply) => {
+    const cfg = deps.config.current;
+    const statusUrl = cfg.oap.statusUrl;
+    try {
+      const raw = await graphqlPost<InfoRaw>(
+        { statusUrl, timeoutMs: cfg.oap.timeoutMs, fetch: deps.fetch },
+        INFO_QUERY,
+      );
+      const body: OapInfo = {
+        reachable: true,
+        statusUrl,
+        version: raw.version ?? undefined,
+        timezone: raw.time?.timezone ?? undefined,
+        currentTimestamp: raw.time?.currentTimestamp ?? undefined,
+        healthScore: raw.health?.score ?? undefined,
+        healthDetails: raw.health?.details ?? undefined,
+      };
+      return reply.send(body);
+    } catch (err) {
+      const body: OapInfo = {
+        reachable: false,
+        statusUrl,
+        error: err instanceof Error ? err.message : String(err),
+      };
+      return reply.status(200).send(body);
+    }
+  });
+}
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 0c3b135..d3605ea 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -21,6 +21,7 @@ import { AuditLogger } from './audit/logger.js';
 import { registerAuthRoutes } from './auth/routes.js';
 import { SessionStore } from './auth/sessions.js';
 import { loadConfig, type ConfigSource } from './config/loader.js';
+import { registerOapInfoRoute } from './oap/info-routes.js';
 import { registerMenuRoute } from './oap/menu-routes.js';
 import { registerOapRoutes } from './oap/routes.js';
 import { registerPreflightRoutes } from './oap/preflight-routes.js';
@@ -58,6 +59,7 @@ await app.register(cookie);
 app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, 
done) => done(null, body));
 
 registerAuthRoutes(app, source, sessions, audit);
+registerOapInfoRoute(app, { config: source, sessions });
 registerMenuRoute(app, { config: source, sessions });
 registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore 
});
 registerOapRoutes(app, { config: source, sessions, audit });
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index a82eb97..e99bf63 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -15,13 +15,19 @@
  * limitations under the License.
  */
 
-import type { MenuResponse, SetupResponse, SetupSavePayload } from 
'@skywalking-horizon-ui/api-client';
+import type {
+  MenuResponse,
+  OapInfo,
+  SetupResponse,
+  SetupSavePayload,
+} from '@skywalking-horizon-ui/api-client';
 
 export type {
   MenuResponse,
   LayerDef,
   LayerCaps,
   LayerSlots,
+  OapInfo,
   SetupResponse,
   SetupSavePayload,
   LayerConfig,
@@ -105,6 +111,10 @@ export class BffClient {
     return this.request<MenuResponse>('GET', '/api/menu');
   }
 
+  oapInfo(): Promise<OapInfo> {
+    return this.request<OapInfo>('GET', '/api/oap/info');
+  }
+
   // ── setup (per-layer overrides) ──────────────────────────────────────
   loadSetup(): Promise<SetupResponse> {
     return this.request<SetupResponse>('GET', '/api/setup');
diff --git a/apps/ui/src/components/shell/AppTopbar.vue 
b/apps/ui/src/components/shell/AppTopbar.vue
index d4f456a..c2aef3b 100644
--- a/apps/ui/src/components/shell/AppTopbar.vue
+++ b/apps/ui/src/components/shell/AppTopbar.vue
@@ -16,8 +16,9 @@
 -->
 <script setup lang="ts">
 import { computed } from 'vue';
-import { useRoute } from 'vue-router';
+import { RouterLink, useRoute } from 'vue-router';
 import Icon from '@/components/icons/Icon.vue';
+import { useOapInfo } from '@/composables/useOapInfo';
 
 const route = useRoute();
 
@@ -28,6 +29,45 @@ const crumbs = computed<string[]>(() => {
   if (segs.length === 0) return ['Home'];
   return segs.map((s) => s.replace(/-/g, ' ').replace(/^./, (c) => 
c.toUpperCase()));
 });
+
+const { info, reachable, version, tzOffsetLabel, healthState } = useOapInfo();
+
+const oapChipTooltip = computed<string>(() => {
+  if (!info.value) return 'OAP status — loading…';
+  if (!reachable.value) {
+    return `OAP unreachable: ${info.value.error ?? 'no response'}\nFix the 
upstream and the pill turns green.`;
+  }
+  const parts: string[] = [];
+  if (info.value.version) parts.push(`Version ${info.value.version}`);
+  if (tzOffsetLabel.value) parts.push(`Server TZ ${tzOffsetLabel.value}`);
+  if (info.value.currentTimestamp) {
+    parts.push(`Server clock ${new 
Date(info.value.currentTimestamp).toLocaleString()} (your local time)`);
+  }
+  if (info.value.healthScore !== undefined) {
+    parts.push(`Health score ${info.value.healthScore} — 
${info.value.healthDetails ?? '(no details)'}`);
+  }
+  return parts.join('\n');
+});
+
+const localTzLabel = computed<string>(() => {
+  const offMin = -new Date().getTimezoneOffset(); // browser returns inverted 
sign
+  const sign = offMin >= 0 ? '+' : '-';
+  const abs = Math.abs(offMin);
+  const h = Math.floor(abs / 60);
+  const m = abs % 60;
+  return m === 0 ? `UTC${sign}${h}` : `UTC${sign}${h}:${String(m).padStart(2, 
'0')}`;
+});
+
+// True when the user's browser is in a different TZ than the OAP server.
+// Time-range queries get converted server-side; the chip flags the gap
+// so the operator knows the displayed local time will differ from the
+// server's log timestamps.
+const { timezone: serverTzMin } = useOapInfo();
+const tzMismatch = computed<boolean>(() => {
+  if (serverTzMin.value === undefined) return false;
+  const browserMin = -new Date().getTimezoneOffset();
+  return browserMin !== serverTzMin.value;
+});
 </script>
 
 <template>
@@ -45,12 +85,16 @@ const crumbs = computed<string[]>(() => {
       <kbd>⌘K</kbd>
     </div>
     <div class="sw-top-actions">
-      <div class="sw-btn">
-        <span style="color: var(--sw-fg-2)">env</span>
-        <b style="color: var(--sw-fg-0)">production</b>
-        <Icon name="caret" :size="10" />
-      </div>
-      <div class="sw-btn">
+      <RouterLink class="sw-btn oap-chip" :class="`is-${healthState}`" 
:title="oapChipTooltip" to="/operate/cluster">
+        <span class="dot" />
+        <span v-if="reachable && version" class="ver">v{{ version }}</span>
+        <span v-else-if="reachable" class="ver">OAP</span>
+        <span v-else class="ver">offline</span>
+        <span v-if="reachable && tzOffsetLabel" class="tz" :class="{ mismatch: 
tzMismatch }">
+          {{ tzOffsetLabel }}
+        </span>
+      </RouterLink>
+      <div class="sw-btn" :title="`Browser local time · ${localTzLabel}`">
         <Icon name="clock" :size="12" />
         <span>Last 30 minutes</span>
         <Icon name="caret" :size="10" />
@@ -60,3 +104,50 @@ const crumbs = computed<string[]>(() => {
     </div>
   </header>
 </template>
+
+<style scoped>
+.oap-chip {
+  text-decoration: none;
+  font-family: var(--sw-mono);
+  font-variant-numeric: tabular-nums;
+  font-size: 10.5px;
+  gap: 6px;
+}
+.oap-chip .dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  display: inline-block;
+}
+.oap-chip.is-ok .dot {
+  background: var(--sw-ok);
+  box-shadow: 0 0 6px 0 rgba(34, 197, 94, 0.55);
+}
+.oap-chip.is-warn .dot {
+  background: var(--sw-warn);
+}
+.oap-chip.is-err .dot {
+  background: var(--sw-err);
+  animation: pulse-err 1.6s infinite;
+}
+.oap-chip.is-unknown .dot {
+  background: var(--sw-fg-3);
+}
+.oap-chip .ver {
+  color: var(--sw-fg-0);
+  font-weight: 600;
+}
+.oap-chip .tz {
+  color: var(--sw-fg-2);
+  padding-left: 4px;
+  border-left: 1px solid var(--sw-line-2);
+}
+.oap-chip .tz.mismatch {
+  color: var(--sw-warn);
+}
+@keyframes pulse-err {
+  0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6); }
+  70% { box-shadow: 0 0 0 6px transparent; }
+  100% { box-shadow: 0 0 0 0 transparent; }
+}
+</style>
diff --git a/apps/ui/src/composables/useOapInfo.ts 
b/apps/ui/src/composables/useOapInfo.ts
new file mode 100644
index 0000000..6b82e27
--- /dev/null
+++ b/apps/ui/src/composables/useOapInfo.ts
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { computed } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import { parseOapTimezoneMinutes, type OapInfo } from 
'@skywalking-horizon-ui/api-client';
+import { bffClient } from '@/api/client';
+
+/**
+ * Live OAP info — version, server timezone, current server timestamp,
+ * health score. Polled every 30s. Drives the topbar status chip and
+ * (later) time-range conversion between the browser's local TZ (display)
+ * and the OAP server TZ (query construction).
+ */
+export function useOapInfo() {
+  const q = useQuery({
+    queryKey: ['oap-info'],
+    queryFn: () => bffClient.oapInfo(),
+    staleTime: 20_000,
+    refetchInterval: 30_000,
+    refetchOnWindowFocus: true,
+  });
+
+  const info = computed<OapInfo | null>(() => q.data.value ?? null);
+  const reachable = computed<boolean>(() => info.value?.reachable ?? false);
+  const version = computed<string | undefined>(() => info.value?.version);
+  const healthScore = computed<number | undefined>(() => 
info.value?.healthScore);
+  /** OAP server timezone offset in minutes (UTC+8 → 480). Derived from the
+   *  OAP `±HHmm` string. */
+  const timezone = computed<number | undefined>(() => 
parseOapTimezoneMinutes(info.value?.timezone));
+
+  /** Pretty UTC offset like `UTC+8`, `UTC-5:30`. Returns `''` if unknown. */
+  const tzOffsetLabel = computed<string>(() => {
+    const tz = timezone.value;
+    if (tz === undefined || tz === null) return '';
+    const sign = tz >= 0 ? '+' : '-';
+    const abs = Math.abs(tz);
+    const h = Math.floor(abs / 60);
+    const m = abs % 60;
+    return m === 0 ? `UTC${sign}${h}` : 
`UTC${sign}${h}:${String(m).padStart(2, '0')}`;
+  });
+
+  /**
+   * Convert a UTC ms-epoch to a string in the OAP server's local TZ in
+   * the `yyyy-MM-dd HHmm` format the OAP query-protocol expects. Returns
+   * an empty string when timezone is unknown.
+   */
+  function toServerTzString(epochMs: number, granularity: 'minute' | 'hour' | 
'day' = 'minute'): string {
+    const tz = timezone.value;
+    if (tz === undefined || tz === null) return '';
+    // Shift the UTC instant by the server's offset, then format. Using
+    // toISOString gives us a UTC-formatted timestamp on the shifted
+    // value, which by construction is the server's wall-clock time.
+    const shifted = new Date(epochMs + tz * 60_000);
+    const y = shifted.getUTCFullYear();
+    const mo = String(shifted.getUTCMonth() + 1).padStart(2, '0');
+    const d = String(shifted.getUTCDate()).padStart(2, '0');
+    if (granularity === 'day') return `${y}-${mo}-${d}`;
+    const h = String(shifted.getUTCHours()).padStart(2, '0');
+    if (granularity === 'hour') return `${y}-${mo}-${d} ${h}`;
+    const mi = String(shifted.getUTCMinutes()).padStart(2, '0');
+    return `${y}-${mo}-${d} ${h}${mi}`;
+  }
+
+  /** Health status pill colour: ok / warn / err / unknown. */
+  const healthState = computed<'ok' | 'warn' | 'err' | 'unknown'>(() => {
+    if (!reachable.value) return 'err';
+    if (healthScore.value === undefined) return 'unknown';
+    if (healthScore.value < 0) return 'err';
+    if (healthScore.value > 0) return 'warn';
+    return 'ok';
+  });
+
+  return {
+    isLoading: q.isLoading,
+    info,
+    reachable,
+    version,
+    timezone,
+    tzOffsetLabel,
+    healthScore,
+    healthState,
+    toServerTzString,
+    refetch: q.refetch,
+  };
+}
diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts
index 9e9892c..e81da7a 100644
--- a/apps/ui/src/router/index.ts
+++ b/apps/ui/src/router/index.ts
@@ -73,7 +73,7 @@ const shellRoutes: RouteRecordRaw[] = [
   // Marketplace — all dashboards / templates across layers
   { path: 'operate/marketplace', component: placeholder, props: { title: 
'Marketplace', phase: 'Phase 2', note: 'All dashboard templates browse + clone 
+ customize.' } },
   // Cluster
-  { path: 'operate/cluster', component: placeholder, props: { title: 'Cluster 
status', phase: 'Phase 6 / 7', note: 'Module activity matrix · storage health · 
receiver activity · effective config tree · TTL grid.' } },
+  { path: 'operate/cluster', component: () => 
import('@/views/operate/ClusterStatusView.vue') },
   // DSL Management
   { path: 'operate/dsl/:catalog(otel-rules|telegraf-rules|lal|log-mal-rules)', 
component: placeholder, props: (r) => ({ title: `DSL · ${r.params.catalog}`, 
phase: 'Phase 6', note: 'Rule catalog grid + filter + new-rule form. Click a 
rule to open the editor.' }) },
   { path: 
'operate/dsl/:catalog(otel-rules|telegraf-rules|lal|log-mal-rules)/:name', 
component: placeholder, props: (r) => ({ title: `Edit · ${r.params.name}`, 
phase: 'Phase 6', note: 'Monaco YAML + diff vs server + diff vs bundled + 
destructive-confirm.' }) },
diff --git a/apps/ui/src/views/operate/ClusterStatusView.vue 
b/apps/ui/src/views/operate/ClusterStatusView.vue
new file mode 100644
index 0000000..27acab5
--- /dev/null
+++ b/apps/ui/src/views/operate/ClusterStatusView.vue
@@ -0,0 +1,235 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useOapInfo } from '@/composables/useOapInfo';
+
+// Early preview of the Cluster Status page. Surfaces the OAP version,
+// timezone, server clock and health score now; the full module-activity
+// matrix / storage health / config-tree / TTL grid lands in Phase 6/7
+// (see docs/design/system-status.md).
+const { info, reachable, version, tzOffsetLabel, healthState, healthScore } = 
useOapInfo();
+
+const serverClockLocal = computed<string>(() => {
+  const ts = info.value?.currentTimestamp;
+  if (!ts) return '—';
+  return new Date(ts).toLocaleString();
+});
+
+const localTzLabel = computed<string>(() => {
+  const offMin = -new Date().getTimezoneOffset();
+  const sign = offMin >= 0 ? '+' : '-';
+  const abs = Math.abs(offMin);
+  const h = Math.floor(abs / 60);
+  const m = abs % 60;
+  return m === 0 ? `UTC${sign}${h}` : `UTC${sign}${h}:${String(m).padStart(2, 
'0')}`;
+});
+
+const healthLabel = computed<string>(() => {
+  if (!reachable.value) return 'unreachable';
+  if (healthScore.value === undefined) return 'unknown';
+  if (healthScore.value < 0) return 'not started';
+  if (healthScore.value > 0) return `degraded (score ${healthScore.value})`;
+  return 'healthy';
+});
+</script>
+
+<template>
+  <div class="cluster">
+    <header class="page-head">
+      <div>
+        <div class="kicker">Operate · Cluster status</div>
+        <h1>OAP cluster</h1>
+        <p class="lede">
+          Live view of the OAP backend horizon is connected to.
+          The full module-activity matrix, storage health, receiver 
throughput, effective-config tree
+          and TTL grid land in Phase 6&nbsp;/&nbsp;7 — for now this page shows 
the basics.
+        </p>
+      </div>
+    </header>
+
+    <div class="grid">
+      <div class="sw-card kpi">
+        <div class="sw-card-head">
+          <h4>Version</h4>
+          <span class="sw-badge" :class="`is-${healthState}`">
+            <span class="state-dot" />{{ healthLabel }}
+          </span>
+        </div>
+        <div class="kpi-body">
+          <div class="kpi-value">{{ version ?? '—' }}</div>
+          <div class="kpi-label">{{ reachable ? info?.statusUrl : 'OAP 
unreachable' }}</div>
+        </div>
+      </div>
+
+      <div class="sw-card kpi">
+        <div class="sw-card-head"><h4>Server timezone</h4></div>
+        <div class="kpi-body">
+          <div class="kpi-value">{{ tzOffsetLabel || '—' }}</div>
+          <div class="kpi-label">Browser local: {{ localTzLabel }}</div>
+        </div>
+      </div>
+
+      <div class="sw-card kpi">
+        <div class="sw-card-head"><h4>Server clock</h4></div>
+        <div class="kpi-body">
+          <div class="kpi-value mono">{{ serverClockLocal }}</div>
+          <div class="kpi-label">As seen in your browser timezone</div>
+        </div>
+      </div>
+
+      <div class="sw-card kpi">
+        <div class="sw-card-head"><h4>Health score</h4></div>
+        <div class="kpi-body">
+          <div class="kpi-value">{{ healthScore ?? '—' }}</div>
+          <div class="kpi-label">{{ info?.healthDetails ?? '0 ok · &gt;0 
degraded · &lt;0 not started' }}</div>
+        </div>
+      </div>
+    </div>
+
+    <div class="phase-note">
+      <strong>Coming in Phase 6&nbsp;/&nbsp;7</strong>
+      <ul>
+        <li>Per-node cluster map (host/port, role, heartbeat)</li>
+        <li>Module activity matrix (module × provider × node)</li>
+        <li>Storage backend health (BanyanDB / Elasticsearch / JDBC)</li>
+        <li>Receiver activity (gRPC / HTTP / Kafka / OTLP throughput, queue 
depth)</li>
+        <li>Navigable effective-configuration tree with two-node diff</li>
+        <li>TTL &amp; retention grid (hot / warm / cold)</li>
+      </ul>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.cluster {
+  padding: 20px 20px 60px;
+  max-width: 1440px;
+  margin: 0 auto;
+}
+.page-head {
+  margin-bottom: 18px;
+}
+.kicker {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.1em;
+  color: var(--sw-accent);
+  margin-bottom: 6px;
+}
+.page-head h1 {
+  font-size: 22px;
+  font-weight: 600;
+  letter-spacing: -0.02em;
+  color: var(--sw-fg-0);
+  margin: 0 0 8px;
+}
+.lede {
+  font-size: 12.5px;
+  color: var(--sw-fg-1);
+  line-height: 1.5;
+  margin: 0;
+  max-width: 720px;
+}
+.grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+  gap: 12px;
+  margin-bottom: 20px;
+}
+.kpi {
+  display: flex;
+  flex-direction: column;
+}
+.kpi .sw-card-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.kpi .sw-card-head h4 {
+  flex: 1;
+}
+.kpi-body {
+  padding: 14px 12px 14px;
+}
+.kpi-value {
+  font-size: 22px;
+  font-weight: 600;
+  letter-spacing: -0.02em;
+  color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+  line-height: 1.1;
+}
+.kpi-value.mono {
+  font-family: var(--sw-mono);
+  font-size: 14px;
+  font-weight: 500;
+}
+.kpi-label {
+  margin-top: 4px;
+  font-size: 11px;
+  color: var(--sw-fg-2);
+}
+.sw-badge .state-dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: currentColor;
+  margin-right: 4px;
+  display: inline-block;
+  vertical-align: middle;
+}
+.sw-badge.is-ok {
+  color: var(--sw-ok);
+  background: var(--sw-ok-soft);
+  border-color: rgba(34, 197, 94, 0.3);
+}
+.sw-badge.is-warn {
+  color: var(--sw-warn);
+  background: var(--sw-warn-soft);
+  border-color: rgba(234, 179, 8, 0.3);
+}
+.sw-badge.is-err {
+  color: var(--sw-err);
+  background: var(--sw-err-soft);
+  border-color: rgba(239, 68, 68, 0.3);
+}
+.sw-badge.is-unknown {
+  color: var(--sw-fg-3);
+}
+.phase-note {
+  background: var(--sw-bg-1);
+  border: 1px dashed var(--sw-line-2);
+  border-radius: 8px;
+  padding: 14px 16px;
+}
+.phase-note strong {
+  display: block;
+  font-size: 11px;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--sw-accent);
+  margin-bottom: 8px;
+}
+.phase-note ul {
+  margin: 0;
+  padding-left: 18px;
+  color: var(--sw-fg-1);
+  font-size: 12px;
+  line-height: 1.7;
+}
+</style>
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index fe55b6a..238411f 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -24,6 +24,8 @@ export type {
   SetupResponse,
   SetupSavePayload,
 } from './setup.js';
+export type { OapInfo } from './oap-info.js';
+export { parseOapTimezoneMinutes } from './oap-info.js';
 export {
   RuntimeRuleClient,
   type RuntimeRuleClientOptions,
diff --git a/packages/api-client/src/oap-info.ts 
b/packages/api-client/src/oap-info.ts
new file mode 100644
index 0000000..0df4bc6
--- /dev/null
+++ b/packages/api-client/src/oap-info.ts
@@ -0,0 +1,52 @@
+/*
+ * 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.
+ */
+
+/**
+ * Live OAP status carried by `GET /api/oap/info`. The UI surfaces this in
+ * the topbar (version + UTC offset + reachable dot) and uses the
+ * `timezone` + `currentTimestamp` to align time-range queries with the
+ * OAP server clock — display stays in the browser's local TZ; the
+ * outgoing query string is converted into the OAP's TZ for the wire.
+ */
+
+export interface OapInfo {
+  reachable: boolean;
+  statusUrl: string;
+  /** OAP build version. */
+  version?: string;
+  /** Raw OAP timezone string in `±HHmm` form (e.g. `+0000`, `+0800`, `-0530`).
+   *  Use `parseOapTimezoneMinutes` to convert to signed minutes-from-UTC. */
+  timezone?: string;
+  /** OAP-clock "now" in ms epoch when the BFF made the call. */
+  currentTimestamp?: number;
+  /** Health score: 0 = OK, >0 = degraded, <0 = not started. */
+  healthScore?: number;
+  healthDetails?: string;
+  error?: string;
+}
+
+/**
+ * Convert OAP's `±HHmm` timezone string to signed minutes-from-UTC.
+ * Returns `undefined` for malformed input.
+ */
+export function parseOapTimezoneMinutes(tz: string | undefined): number | 
undefined {
+  if (!tz) return undefined;
+  const m = /^([+-])(\d{2})(\d{2})$/.exec(tz);
+  if (!m) return undefined;
+  const sign = m[1] === '-' ? -1 : 1;
+  return sign * (Number(m[2]) * 60 + Number(m[3]));
+}

Reply via email to