feat(web): 顯示知識庫自動化資產總帳
All checks were successful
Code Review / ai-code-review (push) Successful in 16s
CD Pipeline / tests (push) Successful in 1m58s
CD Pipeline / build-and-deploy (push) Successful in 5m31s
CD Pipeline / post-deploy-checks (push) Successful in 1m42s

This commit is contained in:
Your Name
2026-06-18 13:15:01 +08:00
parent 5e9bad6b74
commit 962997d22b
3 changed files with 205 additions and 0 deletions

View File

@@ -1820,6 +1820,34 @@
"categoryDistribution": "分類分佈",
"categoryOther": "其他分類"
},
"assetLedger": {
"title": "自動化資產沉澱總帳",
"subtitle": "把目前 KM 清單與 Hermes owner-review 讀模型,對齊到 PlayBook、腳本、排程、Verifier 五類資產。",
"readOnly": "只讀總覽",
"boundary": "此區塊只顯示可追蹤沉澱狀態,不代表已授權修復、已寫回 KM 或已提高 PlayBook trust。",
"asset": {
"km": {
"title": "KM 條目",
"detail": "可追蹤 {ready} 筆;待刷新 / stale {pending} 筆。"
},
"playbook": {
"title": "PlayBook 關聯",
"detail": "已關聯 {ready} 筆;缺 PlayBook {pending} 筆。"
},
"script": {
"title": "腳本 / Ansible",
"detail": "含執行或 auto-runbook 證據 {ready} 筆;待補 {pending} 筆。"
},
"monitoring": {
"title": "排程 / 監控規則",
"detail": "含告警 / 監控訊號 {ready} 筆owner review 待處理 {pending} 筆。"
},
"verifier": {
"title": "Verifier 回寫",
"detail": "已批准或完成驗證 {ready} 筆;待 owner review {pending} 筆。"
}
}
},
"quality": {
"title": "資料品質軌道",
"scope": "目前列表",

View File

@@ -1820,6 +1820,34 @@
"categoryDistribution": "分類分佈",
"categoryOther": "其他分類"
},
"assetLedger": {
"title": "自動化資產沉澱總帳",
"subtitle": "把目前 KM 清單與 Hermes owner-review 讀模型,對齊到 PlayBook、腳本、排程、Verifier 五類資產。",
"readOnly": "只讀總覽",
"boundary": "此區塊只顯示可追蹤沉澱狀態,不代表已授權修復、已寫回 KM 或已提高 PlayBook trust。",
"asset": {
"km": {
"title": "KM 條目",
"detail": "可追蹤 {ready} 筆;待刷新 / stale {pending} 筆。"
},
"playbook": {
"title": "PlayBook 關聯",
"detail": "已關聯 {ready} 筆;缺 PlayBook {pending} 筆。"
},
"script": {
"title": "腳本 / Ansible",
"detail": "含執行或 auto-runbook 證據 {ready} 筆;待補 {pending} 筆。"
},
"monitoring": {
"title": "排程 / 監控規則",
"detail": "含告警 / 監控訊號 {ready} 筆owner review 待處理 {pending} 筆。"
},
"verifier": {
"title": "Verifier 回寫",
"detail": "已批准或完成驗證 {ready} 筆;待 owner review {pending} 筆。"
}
}
},
"quality": {
"title": "資料品質軌道",
"scope": "目前列表",

View File

@@ -123,6 +123,8 @@ interface KnowledgeGovernanceTelemetry {
completionQueue: KnowledgeStaleOwnerReviewCompletionQueueResponse | null
}
type AutomationAssetKey = 'km' | 'playbook' | 'script' | 'monitoring' | 'verifier'
// =============================================================================
// Category Config
// =============================================================================
@@ -591,6 +593,104 @@ export default function KnowledgeBasePage({
] as const
}, [displayedEntries])
const automationAssetRows = useMemo(() => {
const loaded = displayedEntries.length
const pct = (count: number) => loaded > 0 ? Math.round((count / loaded) * 100) : 0
const countWhere = (predicate: (entry: KnowledgeEntry) => boolean) =>
displayedEntries.filter(predicate).length
const hasTag = (entry: KnowledgeEntry, candidates: string[]) =>
entry.tags.some(tag => candidates.includes(tag))
const kmReady = displayedEntries.length
const playbookReady = countWhere(entry => Boolean(entry.related_playbook_id))
const scriptReady = countWhere(entry =>
entry.entry_type === 'auto_runbook'
|| hasTag(entry, ['execution', 'execution_failed', 'auto_runbook']),
)
const monitoringReady = countWhere(entry =>
['alert_handling', 'flywheel_health', 'host_resource', 'kubernetes'].includes(entry.category)
|| hasTag(entry, ['telegram', 'warning', 'critical']),
)
const verifierReady = countWhere(entry =>
entry.status === 'approved'
|| hasTag(entry, ['human_approved', 'postmortem']),
)
const ownerPending =
governanceTelemetry.ownerReviews?.total
?? governanceTelemetry.completionQueue?.pending_count
?? 0
const ownerBlocked = governanceTelemetry.completionQueue?.blocked_count ?? 0
const staleTotal =
governanceTelemetry.staleCandidates?.total_stale
?? governanceTelemetry.burnDown?.current_snapshot?.stale_count
?? 0
return [
{
key: 'km' as AutomationAssetKey,
icon: BookOpen,
ready: kmReady,
pending: staleTotal,
pct: loaded > 0 ? 100 : 0,
tone: 'border-claw-blue/20 bg-claw-blue/8 text-claw-blue',
},
{
key: 'playbook' as AutomationAssetKey,
icon: ClipboardList,
ready: playbookReady,
pending: Math.max(0, loaded - playbookReady),
pct: pct(playbookReady),
tone: 'border-status-warning/20 bg-status-warning/10 text-status-warning',
},
{
key: 'script' as AutomationAssetKey,
icon: PlayCircle,
ready: scriptReady,
pending: Math.max(0, loaded - scriptReady),
pct: pct(scriptReady),
tone: 'border-purple-200 bg-purple-50 text-purple-600',
},
{
key: 'monitoring' as AutomationAssetKey,
icon: Eye,
ready: monitoringReady,
pending: ownerPending + ownerBlocked,
pct: pct(monitoringReady),
tone: 'border-nothing-gray-200 bg-white text-secondary',
},
{
key: 'verifier' as AutomationAssetKey,
icon: CheckCircle2,
ready: verifierReady,
pending: ownerPending,
pct: pct(verifierReady),
tone: 'border-status-healthy/20 bg-status-healthy/10 text-status-healthy',
},
] as const
}, [displayedEntries, governanceTelemetry])
const formatAutomationAssetDetail = useCallback(
(key: AutomationAssetKey, ready: number, pending: number) => {
const values = {
ready: formatCount(ready),
pending: formatCount(pending),
}
switch (key) {
case 'km':
return t('assetLedger.asset.km.detail', values)
case 'playbook':
return t('assetLedger.asset.playbook.detail', values)
case 'script':
return t('assetLedger.asset.script.detail', values)
case 'monitoring':
return t('assetLedger.asset.monitoring.detail', values)
case 'verifier':
return t('assetLedger.asset.verifier.detail', values)
}
},
[formatCount, t],
)
const governanceSummary = useMemo(() => {
const snapshot = governanceTelemetry.burnDown?.current_snapshot ?? null
const ratio = snapshot ? Math.round(snapshot.stale_ratio * 1000) / 10 : null
@@ -884,6 +984,55 @@ export default function KnowledgeBasePage({
</div>
</div>
<div className="mt-3 rounded-md border border-nothing-gray-200 bg-white/70 px-3 py-2">
<div className="mb-3 flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="text-[10px] font-label uppercase tracking-wider text-muted">{t('assetLedger.title')}</p>
<p className="mt-0.5 text-[10px] font-body text-muted">{t('assetLedger.subtitle')}</p>
</div>
<span className="shrink-0 rounded-full border border-status-healthy/20 bg-status-healthy/10 px-2 py-0.5 text-[10px] font-label text-status-healthy">
{t('assetLedger.readOnly')}
</span>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-5">
{automationAssetRows.map(row => {
const Icon = row.icon
return (
<div key={row.key} className="rounded-md border border-nothing-gray-200 bg-white/70 p-2">
<div className="flex items-start justify-between gap-2">
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-md border', row.tone)}>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
</div>
<span className="rounded-full border border-nothing-gray-200 bg-white px-2 py-0.5 text-[10px] font-label tabular-nums text-muted">
{row.pct}%
</span>
</div>
<p className="mt-2 truncate text-[10px] font-label uppercase tracking-wider text-muted">
{t(`assetLedger.asset.${row.key}.title` as never)}
</p>
<p className="mt-1 text-xl font-heading font-semibold tabular-nums text-primary">
{governanceLoading && row.key !== 'playbook' && row.key !== 'script'
? '--'
: formatCount(row.ready)}
</p>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-nothing-gray-100">
<div
className="h-full rounded-full bg-claw-blue"
style={{ width: `${row.pct}%` }}
/>
</div>
<p className="mt-1 line-clamp-2 text-[10px] font-body leading-4 text-secondary">
{formatAutomationAssetDetail(row.key, row.ready, row.pending)}
</p>
</div>
)
})}
</div>
<p className="mt-2 text-[10px] font-body text-muted">
{t('assetLedger.boundary')}
</p>
</div>
<div className="mt-3 rounded-md border border-nothing-gray-200 bg-white/70 px-3 py-2">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-[10px] font-label uppercase tracking-wider text-muted">{t('quality.title')}</p>