feat(web): 升級可觀測性指揮面板
This commit is contained in:
@@ -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": "繁體中文",
|
||||
|
||||
@@ -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": "繁體中文",
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user