diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index d6a53f3b..a75919e6 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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": "瀏覽", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index d6a53f3b..a75919e6 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -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": "瀏覽", diff --git a/apps/web/src/app/[locale]/knowledge-base/page.tsx b/apps/web/src/app/[locale]/knowledge-base/page.tsx index 57c28c89..c0820c8a 100644 --- a/apps/web/src/app/[locale]/knowledge-base/page.tsx +++ b/apps/web/src/app/[locale]/knowledge-base/page.tsx @@ -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 = ( -
+
{/* 左側分類導航 */}
+ +
+
+
+
+

{t('lineage.title')}

+

{t('lineage.scope')}

+
+
+
+ {lineageNodes.map((node, index) => { + const Icon = node.icon + return ( +
+ {index > 0 && ( +
+ )} +
+
+
+

{t(`lineage.${node.key}`)}

+

{formatCount(node.count)}

+

{node.detail}

+
+
+ ) + })} +
+
+ +
+
+
+

{t('freshness.title')}

+

{t('freshness.scope')}

+
+
+
+ {freshnessQueueRows.map(row => { + const Icon = row.icon + return ( +
+
+ + + + {t('quality.countOfLoaded', { count: formatCount(row.count), total: formatCount(visibleSummary.loaded) })} + +
+
+
+
+

{t(`freshness.${row.key}Action`)}

+
+ ) + })} +
+
+
{/* 結果列表 */} @@ -791,6 +896,55 @@ export default function KnowledgeBasePage({
)} +
+
+
+
+ {[ + { + 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 ( +
+ + + {t(`entryLineage.${item.key}`)} + {item.value} +
+ ) + })} +
+
+ {/* Raw technical tags */} {selectedEntry.tags.length > 0 && (