feat(web): 升級可觀測性指揮面板
All checks were successful
CD Pipeline / tests (push) Successful in 1m37s
Code Review / ai-code-review (push) Successful in 15s
CD Pipeline / build-and-deploy (push) Successful in 4m40s
CD Pipeline / post-deploy-checks (push) Successful in 1m29s

This commit is contained in:
Your Name
2026-06-12 13:09:10 +08:00
parent 78ab1b821f
commit e67193a4ac
3 changed files with 789 additions and 16 deletions

View File

@@ -80,6 +80,104 @@
"iwooos": "IwoooS",
"iwooosSecurityCompliance": "IwoooS 安全合規"
},
"observabilityCommand": {
"eyebrow": "AWOOOI 可觀測性指揮面板",
"title": "主機、專案、網站、服務與工具全域監控",
"subtitle": "把已納管的主機、專案、網站前後台、服務、套件、工具、監控訊號與 AI Agent 決策鏈收斂在同一個只讀操作視角先看總圖、缺口與邊界再往下鑽監控、APM、錯誤、應用與服務細節。",
"source": "{generated} · {current} → {next}",
"loading": "資料讀取中",
"liveRead": "正式 API 讀回",
"partialData": "部分資料待回讀",
"apiReady": "資料鏈正常",
"runtimeGate": "執行閘門 {count}",
"metrics": {
"decisionCoverage": "決策支援覆蓋率",
"decisionCoverageDetail": "{readable}/{total} 個目標已有可判讀合約或正式證據;仍不代表自動修復已開放。",
"totalScope": "全域納管目標",
"totalScopeDetail": "主機 {hosts}、專案 {projects}、網站前後台 {websites} 已進入同一張佈局圖。",
"actionRequired": "需處置焦點",
"actionRequiredDetail": "健康缺口 {health}、過期端點 {stale},先進 owner / SRE 判讀,不直接改線上。",
"runtimeGate": "自動執行授權",
"runtimeGateDetail": "重啟、reload、發通知、改 endpoint、讀 secret 與套用修復入口維持 0。"
},
"sections": {
"scopeEyebrow": "01 / Scope Matrix",
"scopeTitle": "全域範圍矩陣",
"scopeDetail": "用同一個矩陣把主機、專案、網站前後台、服務、套件、工具與學習鏈路納入;每一格都顯示可判讀程度與仍需批准的範圍。",
"topologyEyebrow": "02 / Topology",
"topologyTitle": "訊號拓樸與 AI 接管路徑",
"topologyDetail": "從主機與產品面開始接到服務健康、監控訊號、AwoooI SRE 戰情室與 AI Agent 決策鏈,讓告警來源和下一步清楚可追。",
"flowEyebrow": "03 / Flow",
"flowTitle": "告警到處置的流程視圖",
"flowDetail": "用流程呈現收集、關聯、分類、路由與批准閘門;避免週報只有 0也避免批准後不知道下一步。",
"signalEyebrow": "04 / Signal Contracts",
"signalTitle": "監控合約與降噪候選",
"signalDetail": "Prometheus、Alertmanager、Grafana、SigNoz、Sentry、OTEL 等訊號只讀呈現;降噪與規則調整先形成 proposal。",
"gapEyebrow": "05 / Health Gaps",
"gapTitle": "健康缺口與過期端點",
"gapDetail": "把服務健康、端點 truth drift、runner 證明與 provider 來源缺口整理成 SRE 可判斷的處置焦點。",
"boundaryEyebrow": "06 / Guardrails",
"boundaryTitle": "不可誤讀合約",
"boundaryDetail": "此頁是監控與決策視圖,不是 runtime 授權;任何會改正式環境的動作仍需獨立批准與驗證。",
"drilldownEyebrow": "07 / Drilldown",
"drilldownTitle": "細節分頁",
"drilldownDetail": "保留原本監控、APM、錯誤、應用與服務目錄作為總圖後的細節查證入口。"
},
"scope": {
"targets": "{count} 個目標",
"approval": "需批准 {count}",
"readable": "可判讀"
},
"topology": {
"hosts": "主機層",
"hostsDetail": "110 / 111 / 112 / 120 / 121 / 188 等主機進入佈局視野。",
"products": "專案與網站",
"productsDetail": "前台、後台與相鄰專案使用同一組治理視角。",
"services": "服務健康",
"servicesDetail": "API、Web、AI provider、備份與安全邊界健康目標。",
"signals": "監控訊號",
"signalsDetail": "Prometheus、Alertmanager、SigNoz、Sentry、Grafana 與 OTEL。",
"sre": "AwoooI SRE 戰情室",
"sreDetail": "失敗或需處置才進戰情室,避免心跳與成功訊息洗版。",
"ai": "AI Agent 決策鏈",
"aiDetail": "分類、證據、PlayBook、KM 與 verifier 仍受 runtime gate 控制。"
},
"flow": {
"collect": "收集",
"collectDetail": "把主機、專案、網站、服務、工具與套件整理成統一納管目標。",
"correlate": "關聯",
"correlateDetail": "用健康目標和端點合約判斷資料是否新鮮與可用。",
"classify": "分類",
"classifyDetail": "保留分類缺口,避免把心跳或週報誤判成真實事件。",
"route": "路由",
"routeDetail": "真正需處置才進 AwoooI SRE 戰情室與 AwoooP。",
"gate": "閘門",
"gateDetail": "批准前只產生候選、證據與操作建議,不直接執行。"
},
"boundary": {
"readOnly": "只讀證據",
"readOnlyDetail": "此頁只讀 committed snapshot 與正式 API不主動打 live probe 或修改設定。",
"noRuntime": "Runtime gate 0",
"noRuntimeDetail": "不重啟服務、不 reload Alertmanager、不發 Telegram 測試通知、不改 endpoint。",
"sreRoom": "戰情室集中",
"sreRoomDetail": "後續告警治理要把產品告警收斂到 AwoooI SRE 戰情室,其他 Bot / 群組只保留經批准例外。"
},
"values": {
"activeGoverned": "主動治理",
"readOnlyLayout": "只讀佈局",
"blockedByGate": "閘門阻擋",
"planned": "規劃中",
"candidateOnly": "候選",
"verified": "已驗證",
"actionRequired": "需處置",
"blocked": "阻擋",
"failureOnly": "失敗才通知",
"actionRequiredPolicy": "需處置才通知",
"approvalRequired": "需批准",
"dailySummaryOnly": "只進日報",
"noDirectNotify": "不直接通知"
}
},
"locale": {
"switch": "切換語系",
"zhTW": "繁體中文",

View File

@@ -80,6 +80,104 @@
"iwooos": "IwoooS",
"iwooosSecurityCompliance": "IwoooS 安全合規"
},
"observabilityCommand": {
"eyebrow": "AWOOOI 可觀測性指揮面板",
"title": "主機、專案、網站、服務與工具全域監控",
"subtitle": "把已納管的主機、專案、網站前後台、服務、套件、工具、監控訊號與 AI Agent 決策鏈收斂在同一個只讀操作視角先看總圖、缺口與邊界再往下鑽監控、APM、錯誤、應用與服務細節。",
"source": "{generated} · {current} → {next}",
"loading": "資料讀取中",
"liveRead": "正式 API 讀回",
"partialData": "部分資料待回讀",
"apiReady": "資料鏈正常",
"runtimeGate": "執行閘門 {count}",
"metrics": {
"decisionCoverage": "決策支援覆蓋率",
"decisionCoverageDetail": "{readable}/{total} 個目標已有可判讀合約或正式證據;仍不代表自動修復已開放。",
"totalScope": "全域納管目標",
"totalScopeDetail": "主機 {hosts}、專案 {projects}、網站前後台 {websites} 已進入同一張佈局圖。",
"actionRequired": "需處置焦點",
"actionRequiredDetail": "健康缺口 {health}、過期端點 {stale},先進 owner / SRE 判讀,不直接改線上。",
"runtimeGate": "自動執行授權",
"runtimeGateDetail": "重啟、reload、發通知、改 endpoint、讀 secret 與套用修復入口維持 0。"
},
"sections": {
"scopeEyebrow": "01 / Scope Matrix",
"scopeTitle": "全域範圍矩陣",
"scopeDetail": "用同一個矩陣把主機、專案、網站前後台、服務、套件、工具與學習鏈路納入;每一格都顯示可判讀程度與仍需批准的範圍。",
"topologyEyebrow": "02 / Topology",
"topologyTitle": "訊號拓樸與 AI 接管路徑",
"topologyDetail": "從主機與產品面開始接到服務健康、監控訊號、AwoooI SRE 戰情室與 AI Agent 決策鏈,讓告警來源和下一步清楚可追。",
"flowEyebrow": "03 / Flow",
"flowTitle": "告警到處置的流程視圖",
"flowDetail": "用流程呈現收集、關聯、分類、路由與批准閘門;避免週報只有 0也避免批准後不知道下一步。",
"signalEyebrow": "04 / Signal Contracts",
"signalTitle": "監控合約與降噪候選",
"signalDetail": "Prometheus、Alertmanager、Grafana、SigNoz、Sentry、OTEL 等訊號只讀呈現;降噪與規則調整先形成 proposal。",
"gapEyebrow": "05 / Health Gaps",
"gapTitle": "健康缺口與過期端點",
"gapDetail": "把服務健康、端點 truth drift、runner 證明與 provider 來源缺口整理成 SRE 可判斷的處置焦點。",
"boundaryEyebrow": "06 / Guardrails",
"boundaryTitle": "不可誤讀合約",
"boundaryDetail": "此頁是監控與決策視圖,不是 runtime 授權;任何會改正式環境的動作仍需獨立批准與驗證。",
"drilldownEyebrow": "07 / Drilldown",
"drilldownTitle": "細節分頁",
"drilldownDetail": "保留原本監控、APM、錯誤、應用與服務目錄作為總圖後的細節查證入口。"
},
"scope": {
"targets": "{count} 個目標",
"approval": "需批准 {count}",
"readable": "可判讀"
},
"topology": {
"hosts": "主機層",
"hostsDetail": "110 / 111 / 112 / 120 / 121 / 188 等主機進入佈局視野。",
"products": "專案與網站",
"productsDetail": "前台、後台與相鄰專案使用同一組治理視角。",
"services": "服務健康",
"servicesDetail": "API、Web、AI provider、備份與安全邊界健康目標。",
"signals": "監控訊號",
"signalsDetail": "Prometheus、Alertmanager、SigNoz、Sentry、Grafana 與 OTEL。",
"sre": "AwoooI SRE 戰情室",
"sreDetail": "失敗或需處置才進戰情室,避免心跳與成功訊息洗版。",
"ai": "AI Agent 決策鏈",
"aiDetail": "分類、證據、PlayBook、KM 與 verifier 仍受 runtime gate 控制。"
},
"flow": {
"collect": "收集",
"collectDetail": "把主機、專案、網站、服務、工具與套件整理成統一納管目標。",
"correlate": "關聯",
"correlateDetail": "用健康目標和端點合約判斷資料是否新鮮與可用。",
"classify": "分類",
"classifyDetail": "保留分類缺口,避免把心跳或週報誤判成真實事件。",
"route": "路由",
"routeDetail": "真正需處置才進 AwoooI SRE 戰情室與 AwoooP。",
"gate": "閘門",
"gateDetail": "批准前只產生候選、證據與操作建議,不直接執行。"
},
"boundary": {
"readOnly": "只讀證據",
"readOnlyDetail": "此頁只讀 committed snapshot 與正式 API不主動打 live probe 或修改設定。",
"noRuntime": "Runtime gate 0",
"noRuntimeDetail": "不重啟服務、不 reload Alertmanager、不發 Telegram 測試通知、不改 endpoint。",
"sreRoom": "戰情室集中",
"sreRoomDetail": "後續告警治理要把產品告警收斂到 AwoooI SRE 戰情室,其他 Bot / 群組只保留經批准例外。"
},
"values": {
"activeGoverned": "主動治理",
"readOnlyLayout": "只讀佈局",
"blockedByGate": "閘門阻擋",
"planned": "規劃中",
"candidateOnly": "候選",
"verified": "已驗證",
"actionRequired": "需處置",
"blocked": "阻擋",
"failureOnly": "失敗才通知",
"actionRequiredPolicy": "需處置才通知",
"approvalRequired": "需批准",
"dailySummaryOnly": "只進日報",
"noDirectNotify": "不直接通知"
}
},
"locale": {
"switch": "切換語系",
"zhTW": "繁體中文",

View File

@@ -1,18 +1,37 @@
'use client'
/**
* 可觀測性 (/observability) — Sprint 5 整合頁面
* ================================================
* 整合: 服務監控 + APM + 錯誤追蹤 + 應用 + 服務目錄
* 全部使用 Panel 元件 (無雙重 AppLayout)
*
* 零假數據: 全部串接真實 API
*
* 建立時間: 2026-04-08 (台北時區)
* 更新時間: 2026-04-09 — 全部 Tab Panel 抽取完成
* 可觀測性 (/observability)
* ========================
* 首屏整合只讀 AI Agent deployment layout、監控合約矩陣與服務健康缺口
* 下方保留既有監控 / APM / 錯誤 / 應用 / 服務目錄分頁作為鑽取細節。
*/
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { useTranslations } from 'next-intl'
import {
Activity,
AlertTriangle,
BellRing,
Boxes,
CheckCircle2,
CircuitBoard,
Database,
GitBranch,
Globe2,
Layers3,
Lock,
Network,
PackageCheck,
Radar,
Route,
Server,
ShieldCheck,
Workflow,
Wrench,
XCircle,
type LucideIcon,
} from 'lucide-react'
import { AppLayout } from '@/components/layout'
import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
import { MonitoringPanel } from '@/components/panels/MonitoringPanel'
@@ -20,41 +39,599 @@ import { ApmPanel } from '@/components/panels/ApmPanel'
import { ErrorsPanel } from '@/components/panels/ErrorsPanel'
import { AppsPanel } from '@/components/panels/AppsPanel'
import { ServicesPanel } from '@/components/panels/ServicesPanel'
import {
apiClient,
type AiAgentAutomationInventorySnapshot,
type AiAgentDeploymentLayoutSnapshot,
type ObservabilityContractMatrixSnapshot,
type ServiceHealthGapMatrixSnapshot,
} from '@/lib/api-client'
import { cn } from '@/lib/utils'
type CommandState = {
deployment: AiAgentDeploymentLayoutSnapshot | null
observability: ObservabilityContractMatrixSnapshot | null
serviceHealth: ServiceHealthGapMatrixSnapshot | null
inventory: AiAgentAutomationInventorySnapshot | null
loading: boolean
error: boolean
}
type Tone = 'ok' | 'warn' | 'danger' | 'neutral'
const DOMAIN_ICONS: Record<string, LucideIcon> = {
hosts: Server,
projects: GitBranch,
websites: Globe2,
services: Workflow,
packages: PackageCheck,
tools: Wrench,
learning: CircuitBoard,
}
const DOMAIN_ORDER = ['hosts', 'projects', 'websites', 'services', 'packages', 'tools', 'learning']
function toneClass(tone: Tone) {
if (tone === 'ok') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
if (tone === 'warn') return 'border-amber-200 bg-amber-50 text-amber-700'
if (tone === 'danger') return 'border-rose-200 bg-rose-50 text-rose-700'
return 'border-[#e0ddd4] bg-white text-[#141413]'
}
function riskTone(status?: string): Tone {
if (!status) return 'neutral'
if (status === 'verified' || status === 'active_governed' || status === 'read_only_layout') return 'ok'
if (status === 'action_required' || status === 'planned' || status === 'candidate_only') return 'warn'
if (status === 'blocked' || status === 'blocked_by_gate' || status === 'critical') return 'danger'
return 'neutral'
}
function formatNumber(value?: number | null) {
if (typeof value !== 'number' || Number.isNaN(value)) return '--'
return new Intl.NumberFormat('zh-TW').format(value)
}
function formatDateTime(value?: string) {
if (!value) return '--'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '--'
return date.toLocaleString('zh-TW', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function MetricTile({
label,
value,
detail,
icon,
tone = 'neutral',
}: {
label: string
value: ReactNode
detail: string
icon: ReactNode
tone?: Tone
}) {
return (
<div className="min-w-0 border border-[#e0ddd4] bg-white p-4">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-medium text-[#87867f]">{label}</p>
<p className="mt-2 truncate font-['Syne'] text-3xl font-bold leading-none text-[#141413]">
{value}
</p>
</div>
<div className={cn('flex h-9 w-9 shrink-0 items-center justify-center border', toneClass(tone))}>
{icon}
</div>
</div>
<p className="mt-3 min-w-0 text-sm leading-6 text-[#5f5d57]">{detail}</p>
</div>
)
}
function SectionHeader({
eyebrow,
title,
detail,
}: {
eyebrow: string
title: string
detail: string
}) {
return (
<div className="mb-4 flex min-w-0 flex-col gap-1">
<p className="text-xs font-semibold text-[#d97757]">{eyebrow}</p>
<h2 className="font-['Syne'] text-xl font-bold leading-tight text-[#141413]">{title}</h2>
<p className="max-w-4xl text-sm leading-6 text-[#5f5d57]">{detail}</p>
</div>
)
}
function StatusChip({ children, tone = 'neutral' }: { children: ReactNode; tone?: Tone }) {
return (
<span className={cn('inline-flex min-h-[24px] max-w-full items-center border px-2 py-1 text-xs font-medium', toneClass(tone))}>
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">{children}</span>
</span>
)
}
function MiniBar({ value, tone = 'ok' }: { value: number; tone?: Tone }) {
const color = tone === 'danger' ? 'bg-rose-500' : tone === 'warn' ? 'bg-amber-500' : 'bg-emerald-600'
return (
<div className="h-2 overflow-hidden bg-[#ebe8df]">
<div className={cn('h-full', color)} style={{ width: `${Math.max(0, Math.min(value, 100))}%` }} />
</div>
)
}
export default function ObservabilityPage({ params }: { params: { locale: string } }) {
const t = useTranslations('nav')
const nav = useTranslations('nav')
const t = useTranslations('observabilityCommand')
const [state, setState] = useState<CommandState>({
deployment: null,
observability: null,
serviceHealth: null,
inventory: null,
loading: true,
error: false,
})
useEffect(() => {
let alive = true
async function load() {
setState(prev => ({ ...prev, loading: true, error: false }))
const [deployment, observability, serviceHealth, inventory] = await Promise.allSettled([
apiClient.getAiAgentDeploymentLayout(),
apiClient.getObservabilityContractMatrix(),
apiClient.getServiceHealthGapMatrix(),
apiClient.getAiAgentAutomationInventorySnapshot(),
])
if (!alive) return
setState({
deployment: deployment.status === 'fulfilled' ? deployment.value : null,
observability: observability.status === 'fulfilled' ? observability.value : null,
serviceHealth: serviceHealth.status === 'fulfilled' ? serviceHealth.value : null,
inventory: inventory.status === 'fulfilled' ? inventory.value : null,
loading: false,
error: [deployment, observability, serviceHealth, inventory].some(result => result.status === 'rejected'),
})
}
load()
return () => {
alive = false
}
}, [])
const statusLabels = useMemo(
() => ({
active_governed: t('values.activeGoverned'),
read_only_layout: t('values.readOnlyLayout'),
blocked_by_gate: t('values.blockedByGate'),
planned: t('values.planned'),
candidate_only: t('values.candidateOnly'),
verified: t('values.verified'),
action_required: t('values.actionRequired'),
blocked: t('values.blocked'),
failure_only: t('values.failureOnly'),
action_required_policy: t('values.actionRequiredPolicy'),
approval_required: t('values.approvalRequired'),
daily_summary_only: t('values.dailySummaryOnly'),
no_direct_notify: t('values.noDirectNotify'),
}),
[t]
)
const valueLabel = (value?: string) => {
if (!value) return '--'
return statusLabels[value as keyof typeof statusLabels] ?? value.replaceAll('_', ' ')
}
const deploymentTargets = state.deployment?.deployment_targets ?? []
const serviceTargets = state.serviceHealth?.service_health_targets ?? []
const observabilitySurfaces = state.observability?.observability_surfaces ?? []
const totalDecisionTargets = deploymentTargets.length + serviceTargets.length + observabilitySurfaces.length
const readableTargets =
deploymentTargets.filter(target => target.deployment_state !== 'planned' && target.deployment_state !== 'candidate_only').length +
serviceTargets.filter(target => target.status === 'verified').length +
observabilitySurfaces.filter(surface => surface.status === 'verified').length
const decisionCoverage = totalDecisionTargets === 0 ? 0 : Math.round((readableTargets / totalDecisionTargets) * 100)
const actionRequired =
(state.observability?.rollups.surface_ids_requiring_action.length ?? 0) +
(state.serviceHealth?.rollups.target_ids_requiring_action.length ?? 0) +
(state.deployment?.rollups.approval_required_target_ids.length ?? 0)
const runtimeGateCount =
Object.values(state.observability?.operation_boundaries ?? {}).filter(Boolean).length +
Object.values(state.serviceHealth?.operation_boundaries ?? {}).filter(Boolean).length +
Object.values(state.deployment?.approval_boundaries ?? {}).filter(Boolean).length
const domainRows = DOMAIN_ORDER.map(domainId => {
const domain = state.deployment?.domains.find(item => item.domain_id === domainId)
const targets = deploymentTargets.filter(target => target.domain_id === domainId)
const approvalRequired = targets.filter(target => state.deployment?.rollups.approval_required_target_ids.includes(target.target_id)).length
const verified = targets.filter(target => target.deployment_state === 'active_governed' || target.deployment_state === 'read_only_layout').length
return {
domainId,
label: domain?.display_name ?? domainId,
description: domain?.description ?? '',
count: targets.length,
verified,
approvalRequired,
Icon: DOMAIN_ICONS[domainId] ?? Boxes,
}
})
const topologyNodes = [
{
key: 'hosts',
label: t('topology.hosts'),
value: state.deployment?.rollups.by_domain.hosts ?? 0,
detail: t('topology.hostsDetail'),
Icon: Server,
tone: 'ok' as Tone,
},
{
key: 'products',
label: t('topology.products'),
value: (state.deployment?.rollups.by_domain.projects ?? 0) + (state.deployment?.rollups.by_domain.websites ?? 0),
detail: t('topology.productsDetail'),
Icon: Globe2,
tone: 'ok' as Tone,
},
{
key: 'services',
label: t('topology.services'),
value: state.serviceHealth?.rollups.total_targets ?? 0,
detail: t('topology.servicesDetail'),
Icon: Workflow,
tone: 'warn' as Tone,
},
{
key: 'signals',
label: t('topology.signals'),
value: state.observability?.rollups.total_surfaces ?? 0,
detail: t('topology.signalsDetail'),
Icon: Activity,
tone: 'warn' as Tone,
},
{
key: 'sre',
label: t('topology.sre'),
value: state.deployment?.telegram_contract.primary_gateway ? 1 : 0,
detail: t('topology.sreDetail'),
Icon: BellRing,
tone: 'neutral' as Tone,
},
{
key: 'ai',
label: t('topology.ai'),
value: state.inventory?.program_status.overall_completion_percent ?? '--',
detail: t('topology.aiDetail'),
Icon: CircuitBoard,
tone: 'danger' as Tone,
},
]
const flowSteps = [
{
label: t('flow.collect'),
value: formatNumber(state.deployment?.rollups.total_targets),
detail: t('flow.collectDetail'),
},
{
label: t('flow.correlate'),
value: formatNumber(state.serviceHealth?.rollups.total_targets),
detail: t('flow.correlateDetail'),
},
{
label: t('flow.classify'),
value: formatNumber(state.observability?.rollups.classification_gap_ids.length),
detail: t('flow.classifyDetail'),
},
{
label: t('flow.route'),
value: formatNumber(state.deployment?.rollups.by_telegram_policy.action_required),
detail: t('flow.routeDetail'),
},
{
label: t('flow.gate'),
value: formatNumber(runtimeGateCount),
detail: t('flow.gateDetail'),
},
]
const tabs: TabConfig[] = [
{
id: 'monitoring',
label: t('monitoring'),
label: nav('monitoring'),
content: <MonitoringPanel />,
},
{
id: 'apm',
label: t('apm'),
label: nav('apm'),
content: <ApmPanel />,
},
{
id: 'errors',
label: t('errors'),
label: nav('errors'),
content: <ErrorsPanel />,
},
{
id: 'apps',
label: t('apps'),
label: nav('apps'),
content: <AppsPanel />,
},
{
id: 'services',
label: t('services'),
label: nav('services'),
content: <ServicesPanel />,
},
]
const sourceLabel = t('source', {
generated: formatDateTime(state.observability?.generated_at ?? state.deployment?.generated_at ?? state.serviceHealth?.generated_at),
current: state.observability?.program_status.current_task_id ?? state.serviceHealth?.program_status.current_task_id ?? '--',
next: state.observability?.program_status.next_task_id ?? state.serviceHealth?.program_status.next_task_id ?? '--',
})
return (
<AppLayout locale={params.locale}>
<PageTabs tabs={tabs} defaultTab="monitoring" syncWithUrl={true} />
<main className="min-h-screen bg-[#f5f4ed]">
<section className="border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-6 md:px-8">
<div className="mx-auto flex max-w-[1600px] flex-col gap-6">
<div className="flex min-w-0 flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<p className="text-sm font-semibold text-[#d97757]">{t('eyebrow')}</p>
<h1 className="mt-2 font-['Syne'] text-3xl font-bold leading-tight text-[#141413] md:text-4xl">
{t('title')}
</h1>
<p className="mt-3 max-w-5xl text-sm leading-6 text-[#5f5d57] md:text-base">{t('subtitle')}</p>
</div>
<div className="grid min-w-0 grid-cols-2 gap-2 md:grid-cols-4 lg:w-[520px]">
<StatusChip tone={state.loading ? 'neutral' : 'ok'}>{state.loading ? t('loading') : t('liveRead')}</StatusChip>
<StatusChip tone={state.error ? 'warn' : 'ok'}>{state.error ? t('partialData') : t('apiReady')}</StatusChip>
<StatusChip tone="danger">{t('runtimeGate', { count: runtimeGateCount })}</StatusChip>
<StatusChip tone="neutral">{sourceLabel}</StatusChip>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<MetricTile
label={t('metrics.decisionCoverage')}
value={`${decisionCoverage}%`}
detail={t('metrics.decisionCoverageDetail', { readable: readableTargets, total: totalDecisionTargets })}
icon={<GaugeIcon />}
tone={decisionCoverage >= 80 ? 'ok' : decisionCoverage >= 60 ? 'warn' : 'danger'}
/>
<MetricTile
label={t('metrics.totalScope')}
value={formatNumber(state.deployment?.rollups.total_targets)}
detail={t('metrics.totalScopeDetail', {
hosts: state.deployment?.rollups.by_domain.hosts ?? 0,
projects: state.deployment?.rollups.by_domain.projects ?? 0,
websites: state.deployment?.rollups.by_domain.websites ?? 0,
})}
icon={<Network className="h-5 w-5" />}
tone="ok"
/>
<MetricTile
label={t('metrics.actionRequired')}
value={formatNumber(actionRequired)}
detail={t('metrics.actionRequiredDetail', {
health: state.serviceHealth?.rollups.health_gap_ids.length ?? 0,
stale: state.serviceHealth?.rollups.stale_endpoint_ids.length ?? 0,
})}
icon={<AlertTriangle className="h-5 w-5" />}
tone={actionRequired > 0 ? 'warn' : 'ok'}
/>
<MetricTile
label={t('metrics.runtimeGate')}
value={formatNumber(runtimeGateCount)}
detail={t('metrics.runtimeGateDetail')}
icon={<Lock className="h-5 w-5" />}
tone="danger"
/>
</div>
</div>
</section>
<div className="mx-auto flex max-w-[1600px] flex-col gap-6 px-4 py-6 md:px-8">
<section className="border-y border-[#e0ddd4] bg-transparent py-2">
<SectionHeader
eyebrow={t('sections.scopeEyebrow')}
title={t('sections.scopeTitle')}
detail={t('sections.scopeDetail')}
/>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{domainRows.map(row => {
const coverage = row.count === 0 ? 0 : Math.round((row.verified / row.count) * 100)
const tone: Tone = row.approvalRequired > 0 ? 'warn' : 'ok'
return (
<div key={row.domainId} className="min-w-0 border border-[#e0ddd4] bg-white p-4">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className={cn('flex h-9 w-9 shrink-0 items-center justify-center border', toneClass(tone))}>
<row.Icon className="h-5 w-5" />
</div>
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-[#141413]">{row.label}</h3>
<p className="mt-1 text-xs text-[#87867f]">{t('scope.targets', { count: row.count })}</p>
</div>
</div>
<StatusChip tone={tone}>{t('scope.approval', { count: row.approvalRequired })}</StatusChip>
</div>
<p className="mt-3 line-clamp-2 min-h-[40px] text-sm leading-5 text-[#5f5d57]">{row.description}</p>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between text-xs text-[#5f5d57]">
<span>{t('scope.readable')}</span>
<span>{coverage}%</span>
</div>
<MiniBar value={coverage} tone={tone} />
</div>
</div>
)
})}
</div>
</section>
<section className="grid gap-6 xl:grid-cols-[1.35fr_0.65fr]">
<div className="min-w-0 border border-[#e0ddd4] bg-white p-5">
<SectionHeader
eyebrow={t('sections.topologyEyebrow')}
title={t('sections.topologyTitle')}
detail={t('sections.topologyDetail')}
/>
<div className="grid gap-3 md:grid-cols-3">
{topologyNodes.map((node, index) => (
<div key={node.key} className="relative min-w-0 border border-[#e0ddd4] bg-[#faf9f3] p-4">
{index < topologyNodes.length - 1 && (
<div className="pointer-events-none absolute left-full top-1/2 hidden h-px w-3 bg-[#d6d1c3] md:block" />
)}
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-medium text-[#87867f]">{node.label}</p>
<p className="mt-2 font-['Syne'] text-3xl font-bold leading-none text-[#141413]">
{formatNumber(typeof node.value === 'number' ? node.value : Number.NaN)}
</p>
</div>
<div className={cn('flex h-9 w-9 shrink-0 items-center justify-center border', toneClass(node.tone))}>
<node.Icon className="h-5 w-5" />
</div>
</div>
<p className="mt-3 text-sm leading-5 text-[#5f5d57]">{node.detail}</p>
</div>
))}
</div>
</div>
<div className="min-w-0 border border-[#e0ddd4] bg-white p-5">
<SectionHeader
eyebrow={t('sections.flowEyebrow')}
title={t('sections.flowTitle')}
detail={t('sections.flowDetail')}
/>
<div className="flex flex-col">
{flowSteps.map((step, index) => (
<div key={step.label} className="grid min-w-0 grid-cols-[32px_1fr] gap-3">
<div className="flex flex-col items-center">
<div className="flex h-7 w-7 items-center justify-center border border-[#d97757] bg-[#fff7ed] text-xs font-bold text-[#d97757]">
{index + 1}
</div>
{index < flowSteps.length - 1 && <div className="h-full min-h-8 w-px bg-[#e0ddd4]" />}
</div>
<div className="min-w-0 pb-4">
<div className="flex min-w-0 items-center justify-between gap-3">
<h3 className="truncate text-sm font-semibold text-[#141413]">{step.label}</h3>
<span className="font-['DM_Mono'] text-sm font-semibold text-[#141413]">{step.value}</span>
</div>
<p className="mt-1 text-sm leading-5 text-[#5f5d57]">{step.detail}</p>
</div>
</div>
))}
</div>
</div>
</section>
<section className="grid gap-6 xl:grid-cols-3">
<div className="min-w-0 border border-[#e0ddd4] bg-white p-5 xl:col-span-2">
<SectionHeader
eyebrow={t('sections.signalEyebrow')}
title={t('sections.signalTitle')}
detail={t('sections.signalDetail')}
/>
<div className="grid gap-3 md:grid-cols-2">
{observabilitySurfaces.map(surface => (
<div key={surface.surface_id} className="min-w-0 border border-[#e0ddd4] bg-[#faf9f3] p-4">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-[#141413]">{surface.display_name}</h3>
<p className="mt-1 text-xs text-[#87867f]">{valueLabel(surface.kind)}</p>
</div>
<StatusChip tone={riskTone(surface.status)}>{valueLabel(surface.status)}</StatusChip>
</div>
<p className="mt-3 line-clamp-3 text-sm leading-5 text-[#5f5d57]">{surface.next_action}</p>
<div className="mt-3 flex flex-wrap gap-2">
<StatusChip tone={riskTone(surface.noise_policy_status)}>{valueLabel(surface.noise_policy_status)}</StatusChip>
<StatusChip tone="neutral">{valueLabel(surface.evidence_status)}</StatusChip>
</div>
</div>
))}
</div>
</div>
<div className="min-w-0 border border-[#e0ddd4] bg-white p-5">
<SectionHeader
eyebrow={t('sections.gapEyebrow')}
title={t('sections.gapTitle')}
detail={t('sections.gapDetail')}
/>
<div className="flex flex-col gap-3">
{state.serviceHealth?.health_gaps.slice(0, 4).map(gap => (
<div key={gap.gap_id} className="min-w-0 border border-[#e0ddd4] bg-[#faf9f3] p-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<h3 className="min-w-0 text-sm font-semibold leading-5 text-[#141413]">{gap.display_name}</h3>
<StatusChip tone={riskTone(gap.severity)}>{valueLabel(gap.severity)}</StatusChip>
</div>
<p className="mt-2 text-sm leading-5 text-[#5f5d57]">{gap.summary}</p>
</div>
))}
</div>
</div>
</section>
<section className="border border-[#e0ddd4] bg-white p-5">
<SectionHeader
eyebrow={t('sections.boundaryEyebrow')}
title={t('sections.boundaryTitle')}
detail={t('sections.boundaryDetail')}
/>
<div className="grid gap-3 md:grid-cols-3">
<div className="border border-[#e0ddd4] bg-[#faf9f3] p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-[#141413]">
<ShieldCheck className="h-4 w-4 text-emerald-600" />
{t('boundary.readOnly')}
</div>
<p className="mt-2 text-sm leading-5 text-[#5f5d57]">{t('boundary.readOnlyDetail')}</p>
</div>
<div className="border border-[#e0ddd4] bg-[#faf9f3] p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-[#141413]">
<XCircle className="h-4 w-4 text-rose-600" />
{t('boundary.noRuntime')}
</div>
<p className="mt-2 text-sm leading-5 text-[#5f5d57]">{t('boundary.noRuntimeDetail')}</p>
</div>
<div className="border border-[#e0ddd4] bg-[#faf9f3] p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-[#141413]">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
{t('boundary.sreRoom')}
</div>
<p className="mt-2 text-sm leading-5 text-[#5f5d57]">{t('boundary.sreRoomDetail')}</p>
</div>
</div>
</section>
<section className="min-w-0 border-t border-[#e0ddd4] pt-5">
<SectionHeader
eyebrow={t('sections.drilldownEyebrow')}
title={t('sections.drilldownTitle')}
detail={t('sections.drilldownDetail')}
/>
<PageTabs tabs={tabs} defaultTab="monitoring" syncWithUrl={true} />
</section>
</div>
</main>
</AppLayout>
)
}
function GaugeIcon() {
return <Radar className="h-5 w-5" />
}