fix(web): add knowledge lineage map
This commit is contained in:
@@ -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": "瀏覽",
|
||||
|
||||
@@ -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": "瀏覽",
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user