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 / 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 · >0 degraded · <0 not started' }}</div> + </div> + </div> + </div> + + <div class="phase-note"> + <strong>Coming in Phase 6 / 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 & 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])); +}
