fix(web): add knowledge lineage map
All checks were successful
CD Pipeline / tests (push) Successful in 1m25s
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / build-and-deploy (push) Successful in 3m35s
CD Pipeline / post-deploy-checks (push) Successful in 1m47s

This commit is contained in:
Your Name
2026-06-03 08:33:40 +08:00
parent 35939bb746
commit a1cc38288b
3 changed files with 219 additions and 1 deletions

View File

@@ -1428,6 +1428,38 @@
"playbookLinked": "Playbook 關聯",
"countOfLoaded": "{count} / {total}"
},
"lineage": {
"title": "引用鏈圖",
"scope": "來源、事故、Playbook 與審核覆蓋率",
"source": "來源",
"knowledge": "KM",
"incident": "Incident",
"playbook": "Playbook",
"review": "審核",
"sourceDetail": "AI {ai} / 人工 {human}",
"knowledgeDetail": "已載入 {count}",
"countDetail": "{count} / {total}"
},
"freshness": {
"title": "陳舊處理佇列",
"scope": "目前列表的補齊與審核缺口",
"stale": "7 天未更新",
"staleAction": "反查 Incident / Sentry / SigNoz 後產生更新草稿",
"missingIncident": "缺 Incident",
"missingIncidentAction": "補齊來源事故或標記為通用知識",
"missingPlaybook": "缺 Playbook",
"missingPlaybookAction": "匹配既有 Playbook 或建立草稿",
"reviewBacklog": "待 Owner 審核",
"reviewBacklogAction": "高影響條目先人工覆核再批准"
},
"entryLineage": {
"title": "單筆證據鏈",
"source": "來源",
"incident": "Incident",
"playbook": "Playbook",
"review": "審核",
"missing": "尚未關聯"
},
"noResults": "找不到相關知識條目",
"createEntry": "新增條目",
"viewCount": "瀏覽",

View File

@@ -1428,6 +1428,38 @@
"playbookLinked": "Playbook 關聯",
"countOfLoaded": "{count} / {total}"
},
"lineage": {
"title": "引用鏈圖",
"scope": "來源、事故、Playbook 與審核覆蓋率",
"source": "來源",
"knowledge": "KM",
"incident": "Incident",
"playbook": "Playbook",
"review": "審核",
"sourceDetail": "AI {ai} / 人工 {human}",
"knowledgeDetail": "已載入 {count}",
"countDetail": "{count} / {total}"
},
"freshness": {
"title": "陳舊處理佇列",
"scope": "目前列表的補齊與審核缺口",
"stale": "7 天未更新",
"staleAction": "反查 Incident / Sentry / SigNoz 後產生更新草稿",
"missingIncident": "缺 Incident",
"missingIncidentAction": "補齊來源事故或標記為通用知識",
"missingPlaybook": "缺 Playbook",
"missingPlaybookAction": "匹配既有 Playbook 或建立草稿",
"reviewBacklog": "待 Owner 審核",
"reviewBacklogAction": "高影響條目先人工覆核再批准"
},
"entryLineage": {
"title": "單筆證據鏈",
"source": "來源",
"incident": "Incident",
"playbook": "Playbook",
"review": "審核",
"missing": "尚未關聯"
},
"noResults": "找不到相關知識條目",
"createEntry": "新增條目",
"viewCount": "瀏覽",

View File

@@ -18,6 +18,7 @@ import { cn } from '@/lib/utils'
import {
Search, BookOpen, FileText, Shield, Cpu,
Server, Eye, Bot, ChevronRight, Plus, Sparkles, ClipboardList, Tag, TriangleAlert,
GitBranch, CheckCircle2, Clock3, Link2, FileSearch,
} from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -413,8 +414,47 @@ export default function KnowledgeBasePage({
] as const
}, [displayedEntries])
const lineageNodes = useMemo(() => {
const loaded = displayedEntries.length
const pct = (count: number) => loaded > 0 ? Math.round((count / loaded) * 100) : 0
const aiExtracted = displayedEntries.filter(entry => entry.source === 'ai_extracted').length
const humanCreated = displayedEntries.filter(entry => entry.source === 'human').length
const incidentLinked = displayedEntries.filter(entry => Boolean(entry.related_incident_id)).length
const playbookLinked = displayedEntries.filter(entry => Boolean(entry.related_playbook_id)).length
const approved = displayedEntries.filter(entry => entry.status === 'approved').length
return [
{ key: 'source', count: aiExtracted, pct: pct(aiExtracted), icon: Bot, tone: 'border-purple-200 bg-purple-50 text-purple-600', detail: t('lineage.sourceDetail', { ai: formatCount(aiExtracted), human: formatCount(humanCreated) }) },
{ key: 'knowledge', count: loaded, pct: loaded > 0 ? 100 : 0, icon: BookOpen, tone: 'border-claw-blue/20 bg-claw-blue/8 text-claw-blue', detail: t('lineage.knowledgeDetail', { count: formatCount(loaded) }) },
{ key: 'incident', count: incidentLinked, pct: pct(incidentLinked), icon: TriangleAlert, tone: 'border-status-warning/20 bg-status-warning/10 text-status-warning', detail: t('lineage.countDetail', { count: formatCount(incidentLinked), total: formatCount(loaded) }) },
{ key: 'playbook', count: playbookLinked, pct: pct(playbookLinked), icon: ClipboardList, tone: 'border-nothing-gray-200 bg-white text-secondary', detail: t('lineage.countDetail', { count: formatCount(playbookLinked), total: formatCount(loaded) }) },
{ key: 'review', count: approved, pct: pct(approved), icon: CheckCircle2, tone: 'border-status-healthy/20 bg-status-healthy/10 text-status-healthy', detail: t('lineage.countDetail', { count: formatCount(approved), total: formatCount(loaded) }) },
] as const
}, [displayedEntries, formatCount, t])
const freshnessQueueRows = useMemo(() => {
const loaded = displayedEntries.length
const now = Date.now()
const staleWindowMs = 7 * 24 * 60 * 60 * 1000
const pct = (count: number) => loaded > 0 ? Math.round((count / loaded) * 100) : 0
const stale = displayedEntries.filter(entry => {
const updatedAt = Date.parse(entry.updated_at)
return !Number.isFinite(updatedAt) || now - updatedAt > staleWindowMs
}).length
const missingIncident = displayedEntries.filter(entry => !entry.related_incident_id).length
const missingPlaybook = displayedEntries.filter(entry => !entry.related_playbook_id).length
const reviewBacklog = displayedEntries.filter(entry => entry.status === 'draft' || entry.status === 'review').length
return [
{ key: 'stale', count: stale, pct: pct(stale), icon: Clock3, tone: 'bg-status-critical' },
{ key: 'missingIncident', count: missingIncident, pct: pct(missingIncident), icon: Link2, tone: 'bg-status-warning' },
{ key: 'missingPlaybook', count: missingPlaybook, pct: pct(missingPlaybook), icon: FileSearch, tone: 'bg-claw-blue' },
{ key: 'reviewBacklog', count: reviewBacklog, pct: pct(reviewBacklog), icon: Shield, tone: 'bg-purple-500' },
] as const
}, [displayedEntries])
const content = (
<div className="flex h-[calc(100vh-64px)] flex-col lg:flex-row">
<div className="flex min-h-[calc(100vh-64px)] flex-col lg:h-[calc(100vh-64px)] lg:flex-row">
{/* 左側分類導航 */}
<aside className="w-full flex-shrink-0 border-b border-nothing-gray-200/50 bg-white/50 p-3 lg:w-52 lg:border-b-0 lg:border-r">
@@ -590,6 +630,71 @@ export default function KnowledgeBasePage({
))}
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-3 xl:grid-cols-[minmax(0,1.25fr)_minmax(260px,0.75fr)]">
<div className="rounded-md border border-nothing-gray-200 bg-white/70 px-3 py-2">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-[10px] font-label uppercase tracking-wider text-muted">{t('lineage.title')}</p>
<p className="mt-0.5 truncate text-[10px] font-body text-muted">{t('lineage.scope')}</p>
</div>
<GitBranch className="h-4 w-4 shrink-0 text-claw-blue" aria-hidden="true" />
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-5">
{lineageNodes.map((node, index) => {
const Icon = node.icon
return (
<div key={node.key} className="relative min-w-0">
{index > 0 && (
<div className="pointer-events-none absolute -left-2 top-5 hidden h-px w-4 bg-nothing-gray-200 sm:block" />
)}
<div className={cn('h-full rounded-md border px-2.5 py-2', node.tone)}>
<div className="flex items-center justify-between gap-2">
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="text-[10px] font-label tabular-nums">{node.pct}%</span>
</div>
<p className="mt-2 truncate text-[10px] font-label uppercase tracking-wider">{t(`lineage.${node.key}`)}</p>
<p className="mt-1 text-lg font-heading font-semibold tabular-nums">{formatCount(node.count)}</p>
<p className="mt-0.5 truncate text-[10px] font-body opacity-80">{node.detail}</p>
</div>
</div>
)
})}
</div>
</div>
<div className="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">
<div className="min-w-0">
<p className="text-[10px] font-label uppercase tracking-wider text-muted">{t('freshness.title')}</p>
<p className="mt-0.5 truncate text-[10px] font-body text-muted">{t('freshness.scope')}</p>
</div>
<Clock3 className="h-4 w-4 shrink-0 text-status-warning" aria-hidden="true" />
</div>
<div className="space-y-2">
{freshnessQueueRows.map(row => {
const Icon = row.icon
return (
<div key={row.key} className="min-w-0">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-1.5 text-[10px] font-body text-secondary">
<Icon className="h-3 w-3 shrink-0 text-muted" aria-hidden="true" />
<span className="truncate">{t(`freshness.${row.key}`)}</span>
</span>
<span className="shrink-0 text-[10px] font-label tabular-nums text-muted">
{t('quality.countOfLoaded', { count: formatCount(row.count), total: formatCount(visibleSummary.loaded) })}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-nothing-gray-100">
<div className={cn('h-full rounded-full', row.tone)} style={{ width: `${row.pct}%` }} />
</div>
<p className="mt-0.5 truncate text-[10px] font-body text-muted">{t(`freshness.${row.key}Action`)}</p>
</div>
)
})}
</div>
</div>
</div>
</section>
{/* 結果列表 */}
@@ -791,6 +896,55 @@ export default function KnowledgeBasePage({
</div>
)}
<div className="mb-4 rounded-md border border-nothing-gray-200 bg-white/70 p-3">
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-label uppercase tracking-wider text-muted">
<GitBranch className="h-3 w-3" aria-hidden="true" />
{t('entryLineage.title')}
</div>
<div className="space-y-1.5">
{[
{
key: 'source',
icon: selectedEntry.source === 'ai_extracted' ? Bot : FileText,
ok: true,
value: t(`source.${selectedEntry.source}`),
},
{
key: 'incident',
icon: TriangleAlert,
ok: Boolean(selectedEntry.related_incident_id),
value: selectedEntry.related_incident_id ?? t('entryLineage.missing'),
},
{
key: 'playbook',
icon: ClipboardList,
ok: Boolean(selectedEntry.related_playbook_id),
value: selectedEntry.related_playbook_id ?? t('entryLineage.missing'),
},
{
key: 'review',
icon: selectedEntry.status === 'approved' ? CheckCircle2 : Clock3,
ok: selectedEntry.status === 'approved',
value: t(`status.${selectedEntry.status}`),
},
].map(item => {
const Icon = item.icon
return (
<div key={item.key} className="grid grid-cols-[18px_86px_minmax(0,1fr)] items-center gap-2">
<span className={cn(
'flex h-4 w-4 items-center justify-center rounded-full border',
item.ok ? 'border-status-healthy/30 bg-status-healthy/10 text-status-healthy' : 'border-status-warning/30 bg-status-warning/10 text-status-warning',
)}>
<Icon className="h-2.5 w-2.5" aria-hidden="true" />
</span>
<span className="truncate text-[10px] font-label uppercase tracking-wider text-muted">{t(`entryLineage.${item.key}`)}</span>
<span className="truncate text-[10px] font-body text-secondary" title={item.value}>{item.value}</span>
</div>
)
})}
</div>
</div>
{/* Raw technical tags */}
{selectedEntry.tags.length > 0 && (
<details className="mb-4 rounded-md border border-nothing-gray-200 bg-white/50 p-3">