feat(web): render automation blueprint diagrams
This commit is contained in:
@@ -406,6 +406,82 @@
|
||||
"km": "KM / PlayBook"
|
||||
}
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"eyebrow": "Live Blueprint",
|
||||
"title": "AI Automation Operating Map",
|
||||
"subtitle": "This view puts process, runtime, decision table, and evidence lineage in one operating surface so the homepage can show where work is, what is blocked, and who continues it.",
|
||||
"flow": {
|
||||
"title": "BPMN / Swimlane Flow",
|
||||
"subtitle": "The main path from alert intake through analysis, investigation, approval, execution, verification, and learning.",
|
||||
"stages": {
|
||||
"signal": "Alert / Sentry / SigNoz",
|
||||
"intake": "AwoooP Intake",
|
||||
"ai": "OpenClaw / Hermes",
|
||||
"mcp": "MCP Evidence",
|
||||
"playbook": "PlayBook Gate",
|
||||
"ansible": "Ansible Check",
|
||||
"approval": "Approval / Apply",
|
||||
"verify": "Verify / KM"
|
||||
}
|
||||
},
|
||||
"topology": {
|
||||
"title": "C4 / Runtime Topology",
|
||||
"subtitle": "Runtime relationships across product, data, executors, MCP, and model providers.",
|
||||
"layers": {
|
||||
"channels": "Channels",
|
||||
"product": "Product",
|
||||
"data": "Data",
|
||||
"execution": "Execution",
|
||||
"providers": "AI Providers"
|
||||
}
|
||||
},
|
||||
"decision": {
|
||||
"title": "DMN Decision Table",
|
||||
"subtitle": "Auditable conditions for whether AI can safely auto-repair.",
|
||||
"headers": {
|
||||
"signal": "Signal",
|
||||
"value": "Current value",
|
||||
"outcome": "Decision"
|
||||
},
|
||||
"rows": {
|
||||
"claim": "Production claim",
|
||||
"qualityGate": "Quality gate",
|
||||
"ansible": "Ansible runtime",
|
||||
"aiRoute": "AI route",
|
||||
"km": "KM freshness",
|
||||
"callback": "Callback trace"
|
||||
},
|
||||
"outcomes": {
|
||||
"claimReady": "Full loop can be claimed",
|
||||
"claimBlocked": "Full loop cannot be claimed",
|
||||
"fillEvidence": "Fill execution / repair / approval / learning evidence",
|
||||
"ansibleReady": "Ready for check-mode",
|
||||
"ansibleBlocked": "Fix Ansible runtime first",
|
||||
"monitor": "Primary lane is monitored",
|
||||
"ownerReview": "Hermes drafts, owner reviews",
|
||||
"watchDecay": "Wait for 24h backlog decay"
|
||||
}
|
||||
},
|
||||
"lineage": {
|
||||
"title": "Trace / Lineage Evidence",
|
||||
"subtitle": "Every Telegram alert, button, Run, KM, and PlayBook should link back to one evidence chain.",
|
||||
"nodes": {
|
||||
"telegram": "Telegram Message",
|
||||
"callback": "Callback Evidence",
|
||||
"db": "DB Truth",
|
||||
"run": "Run Timeline",
|
||||
"km": "KM / PlayBook"
|
||||
}
|
||||
},
|
||||
"values": {
|
||||
"verified": "verified {verified}/{evaluated}",
|
||||
"topGate": "{gate} missing {count}",
|
||||
"ansible": "check-mode {checkMode}, pending {pending}, blocker {blocker}",
|
||||
"aiRoute": "{lane} / {provider}",
|
||||
"km": "{stale} stale over {days} days",
|
||||
"callback": "missing {missing}, 1h {recent1h}, 24h {recent24h}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -407,6 +407,82 @@
|
||||
"km": "KM / PlayBook"
|
||||
}
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"eyebrow": "Live Blueprint",
|
||||
"title": "AI 自動化完整作戰圖",
|
||||
"subtitle": "這一區把流程、Runtime、決策表與證據鏈放在同一個作戰視圖,讓首頁能直接回答目前跑到哪裡、卡在哪一關、該由誰接續。",
|
||||
"flow": {
|
||||
"title": "BPMN / Swimlane 流程",
|
||||
"subtitle": "告警進來後,從分析、調查、審批、執行到驗證的主幹流程。",
|
||||
"stages": {
|
||||
"signal": "Alert / Sentry / SigNoz",
|
||||
"intake": "AwoooP Intake",
|
||||
"ai": "OpenClaw / Hermes",
|
||||
"mcp": "MCP Evidence",
|
||||
"playbook": "PlayBook Gate",
|
||||
"ansible": "Ansible Check",
|
||||
"approval": "Approval / Apply",
|
||||
"verify": "Verify / KM"
|
||||
}
|
||||
},
|
||||
"topology": {
|
||||
"title": "C4 / Runtime 拓樸",
|
||||
"subtitle": "產品、資料、執行器、MCP 與模型供應商的 runtime 關係。",
|
||||
"layers": {
|
||||
"channels": "Channels",
|
||||
"product": "Product",
|
||||
"data": "Data",
|
||||
"execution": "Execution",
|
||||
"providers": "AI Providers"
|
||||
}
|
||||
},
|
||||
"decision": {
|
||||
"title": "DMN 決策表",
|
||||
"subtitle": "把 AI 是否能自動修復的判斷拆成可稽核條件。",
|
||||
"headers": {
|
||||
"signal": "Signal",
|
||||
"value": "Current value",
|
||||
"outcome": "Decision"
|
||||
},
|
||||
"rows": {
|
||||
"claim": "Production claim",
|
||||
"qualityGate": "Quality gate",
|
||||
"ansible": "Ansible runtime",
|
||||
"aiRoute": "AI route",
|
||||
"km": "KM freshness",
|
||||
"callback": "Callback trace"
|
||||
},
|
||||
"outcomes": {
|
||||
"claimReady": "可宣稱完整閉環",
|
||||
"claimBlocked": "不可宣稱完整閉環",
|
||||
"fillEvidence": "補 execution / repair / approval / learning evidence",
|
||||
"ansibleReady": "可進 check-mode",
|
||||
"ansibleBlocked": "先修 Ansible runtime",
|
||||
"monitor": "Primary lane 監控中",
|
||||
"ownerReview": "Hermes 產草稿,owner 審核",
|
||||
"watchDecay": "等待 24h backlog 歸零"
|
||||
}
|
||||
},
|
||||
"lineage": {
|
||||
"title": "Trace / Lineage 證據鏈",
|
||||
"subtitle": "每一則 Telegram 告警、按鈕、Run、KM 與 PlayBook 都要能串回同一條證據。",
|
||||
"nodes": {
|
||||
"telegram": "Telegram Message",
|
||||
"callback": "Callback Evidence",
|
||||
"db": "DB Truth",
|
||||
"run": "Run Timeline",
|
||||
"km": "KM / PlayBook"
|
||||
}
|
||||
},
|
||||
"values": {
|
||||
"verified": "verified {verified}/{evaluated}",
|
||||
"topGate": "{gate} 缺 {count}",
|
||||
"ansible": "check-mode {checkMode},pending {pending},blocker {blocker}",
|
||||
"aiRoute": "{lane} / {provider}",
|
||||
"km": "{stale} stale over {days} days",
|
||||
"callback": "missing {missing},1h {recent1h},24h {recent24h}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -963,6 +963,220 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
],
|
||||
},
|
||||
]
|
||||
const automationFlowStages: HomepageWorkItemSummary[] = [
|
||||
{
|
||||
key: 'signal',
|
||||
title: tDashboard('automationDiagrams.workspace.flow.stages.signal'),
|
||||
status: tDashboard('automationDelivery.status.live'),
|
||||
detail: tDashboard('automationDiagrams.workspace.values.callback', {
|
||||
missing: formatAutomationNumber(callbackTraceSummary?.outbound_reply_markup_missing_trace_ref_total, automationBriefLoaded),
|
||||
recent1h: formatAutomationNumber(callbackTraceSummary?.outbound_reply_markup_missing_trace_ref_recent_1h_total, automationBriefLoaded),
|
||||
recent24h: formatAutomationNumber(callbackTraceSummary?.outbound_reply_markup_missing_trace_ref_recent_24h_total, automationBriefLoaded),
|
||||
}),
|
||||
href: `/${locale}/awooop/runs?project_id=awoooi#tg-callback-evidence`,
|
||||
tone: 'live',
|
||||
},
|
||||
{
|
||||
key: 'intake',
|
||||
title: tDashboard('automationDiagrams.workspace.flow.stages.intake'),
|
||||
status: tDashboard('automationDelivery.status.live'),
|
||||
detail: tDashboard('automationDelivery.delivered.cicdTimeline.detail'),
|
||||
href: `/${locale}/awooop/runs`,
|
||||
tone: 'live',
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
title: tDashboard('automationDiagrams.workspace.flow.stages.ai'),
|
||||
status: hasAiRouteStatus ? tDashboard('automationDelivery.status.live') : unavailableStatus,
|
||||
detail: tDashboard('automationDiagrams.workspace.values.aiRoute', {
|
||||
lane: aiRouteLaneMode,
|
||||
provider: aiRouteSelectedProvider,
|
||||
}),
|
||||
href: `/${locale}/awooop/work-items?project_id=awoooi`,
|
||||
tone: hasAiRouteStatus ? 'live' : 'watching',
|
||||
},
|
||||
{
|
||||
key: 'mcp',
|
||||
title: tDashboard('automationDiagrams.workspace.flow.stages.mcp'),
|
||||
status: tDashboard('automationDelivery.status.watching'),
|
||||
detail: tDashboard('automationDiagrams.cards.incidentFlow.detail'),
|
||||
href: `/${locale}/awooop/runs?project_id=awoooi`,
|
||||
tone: 'watching',
|
||||
},
|
||||
{
|
||||
key: 'playbook',
|
||||
title: tDashboard('automationDiagrams.workspace.flow.stages.playbook'),
|
||||
status: topAutomationGate ? tDashboard('automationDelivery.status.blocked') : tDashboard('automationDelivery.status.live'),
|
||||
detail: tDashboard('automationDiagrams.workspace.values.topGate', {
|
||||
gate: topAutomationGate?.gate ?? (automationQualityLoaded ? unavailableValue : loadingStatus),
|
||||
count: formatAutomationNumber(topAutomationGate?.total, automationQualityLoaded),
|
||||
}),
|
||||
href: `/${locale}/awooop/work-items?project_id=awoooi`,
|
||||
tone: topAutomationGate ? 'blocked' : automationQualityAvailable ? 'live' : 'watching',
|
||||
},
|
||||
{
|
||||
key: 'ansible',
|
||||
title: tDashboard('automationDiagrams.workspace.flow.stages.ansible'),
|
||||
status: ansibleRuntime?.can_run_check_mode ? tDashboard('automationDelivery.status.live') : tDashboard('automationDelivery.status.blocked'),
|
||||
detail: tDashboard('automationDiagrams.workspace.values.ansible', {
|
||||
checkMode: formatAutomationNumber(executionBackend?.ansible_check_mode_total, automationQualityLoaded),
|
||||
pending: formatAutomationNumber(executionBackend?.ansible_pending_check_mode_total, automationQualityLoaded),
|
||||
blocker: ansibleRuntime?.blockers?.join(' / ') || (automationQualityLoaded ? unavailableValue : loadingStatus),
|
||||
}),
|
||||
href: `/${locale}/awooop/work-items?project_id=awoooi`,
|
||||
tone: ansibleRuntime?.can_run_check_mode ? 'live' : automationQualityAvailable ? 'blocked' : 'watching',
|
||||
},
|
||||
{
|
||||
key: 'approval',
|
||||
title: tDashboard('automationDiagrams.workspace.flow.stages.approval'),
|
||||
status: canClaimFullAutoRepair ? tDashboard('automationDelivery.status.live') : tDashboard('automationDelivery.status.blocked'),
|
||||
detail: tDashboard('automationDiagrams.workspace.values.verified', {
|
||||
verified: formatAutomationNumber(verifiedAutomationTotal, automationQualityLoaded),
|
||||
evaluated: formatAutomationNumber(evaluatedAutomationTotal, automationQualityLoaded),
|
||||
}),
|
||||
href: `/${locale}/awooop/approvals`,
|
||||
tone: canClaimFullAutoRepair ? 'live' : automationQualityAvailable ? 'blocked' : 'watching',
|
||||
},
|
||||
{
|
||||
key: 'verify',
|
||||
title: tDashboard('automationDiagrams.workspace.flow.stages.verify'),
|
||||
status: typeof kmStaleTotal === 'number' && kmStaleTotal > 0 ? tDashboard('automationDelivery.status.progress') : tDashboard('automationDelivery.status.live'),
|
||||
detail: tDashboard('automationDiagrams.workspace.values.km', {
|
||||
stale: formatAutomationNumber(kmStaleTotal, automationBriefLoaded),
|
||||
days: formatAutomationNumber(kmStaleThreshold, automationBriefLoaded),
|
||||
}),
|
||||
href: `/${locale}/knowledge-base`,
|
||||
tone: typeof kmStaleTotal === 'number' && kmStaleTotal > 0 ? 'progress' : hasKmStaleCandidates ? 'live' : 'watching',
|
||||
},
|
||||
]
|
||||
const runtimeTopologyLayers = [
|
||||
{
|
||||
key: 'channels',
|
||||
title: tDashboard('automationDiagrams.workspace.topology.layers.channels'),
|
||||
items: [
|
||||
tDashboard('automationDiagrams.cards.incidentFlow.nodes.alert'),
|
||||
tDashboard('automationDiagrams.cards.evidenceLineage.nodes.telegram'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'product',
|
||||
title: tDashboard('automationDiagrams.workspace.topology.layers.product'),
|
||||
items: [
|
||||
tDashboard('automationDiagrams.cards.c4Runtime.nodes.web'),
|
||||
tDashboard('automationDiagrams.cards.c4Runtime.nodes.api'),
|
||||
tDashboard('automationDiagrams.cards.evidenceLineage.nodes.trace'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'data',
|
||||
title: tDashboard('automationDiagrams.workspace.topology.layers.data'),
|
||||
items: [
|
||||
tDashboard('automationDiagrams.cards.evidenceLineage.nodes.db'),
|
||||
tDashboard('automationDiagrams.cards.evidenceLineage.nodes.km'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'execution',
|
||||
title: tDashboard('automationDiagrams.workspace.topology.layers.execution'),
|
||||
items: [
|
||||
tDashboard('automationDiagrams.cards.incidentFlow.nodes.playbook'),
|
||||
tDashboard('automationDiagrams.workspace.flow.stages.ansible'),
|
||||
tDashboard('automationDiagrams.cards.c4Runtime.nodes.k8s'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'providers',
|
||||
title: tDashboard('automationDiagrams.workspace.topology.layers.providers'),
|
||||
items: [
|
||||
aiRouteSelectedProvider,
|
||||
tDashboard('automationDelivery.delivered.aiRoute.detail', {
|
||||
lane: aiRouteLaneMode,
|
||||
provider: aiRouteSelectedProvider,
|
||||
}),
|
||||
],
|
||||
},
|
||||
]
|
||||
const decisionRows: Array<{
|
||||
key: string
|
||||
signal: string
|
||||
value: string
|
||||
outcome: string
|
||||
tone: HomepageWorkTone
|
||||
}> = [
|
||||
{
|
||||
key: 'claim',
|
||||
signal: tDashboard('automationDiagrams.workspace.decision.rows.claim'),
|
||||
value: tDashboard('automationDiagrams.workspace.values.verified', {
|
||||
verified: formatAutomationNumber(verifiedAutomationTotal, automationQualityLoaded),
|
||||
evaluated: formatAutomationNumber(evaluatedAutomationTotal, automationQualityLoaded),
|
||||
}),
|
||||
outcome: canClaimFullAutoRepair
|
||||
? tDashboard('automationDiagrams.workspace.decision.outcomes.claimReady')
|
||||
: tDashboard('automationDiagrams.workspace.decision.outcomes.claimBlocked'),
|
||||
tone: canClaimFullAutoRepair ? 'live' : automationQualityAvailable ? 'blocked' : 'watching',
|
||||
},
|
||||
{
|
||||
key: 'qualityGate',
|
||||
signal: tDashboard('automationDiagrams.workspace.decision.rows.qualityGate'),
|
||||
value: tDashboard('automationDiagrams.workspace.values.topGate', {
|
||||
gate: topAutomationGate?.gate ?? (automationQualityLoaded ? unavailableValue : loadingStatus),
|
||||
count: formatAutomationNumber(topAutomationGate?.total, automationQualityLoaded),
|
||||
}),
|
||||
outcome: tDashboard('automationDiagrams.workspace.decision.outcomes.fillEvidence'),
|
||||
tone: topAutomationGate ? 'blocked' : automationQualityAvailable ? 'live' : 'watching',
|
||||
},
|
||||
{
|
||||
key: 'ansible',
|
||||
signal: tDashboard('automationDiagrams.workspace.decision.rows.ansible'),
|
||||
value: tDashboard('automationDiagrams.workspace.values.ansible', {
|
||||
checkMode: formatAutomationNumber(executionBackend?.ansible_check_mode_total, automationQualityLoaded),
|
||||
pending: formatAutomationNumber(executionBackend?.ansible_pending_check_mode_total, automationQualityLoaded),
|
||||
blocker: ansibleRuntime?.blockers?.join(' / ') || (automationQualityLoaded ? unavailableValue : loadingStatus),
|
||||
}),
|
||||
outcome: ansibleRuntime?.can_run_check_mode
|
||||
? tDashboard('automationDiagrams.workspace.decision.outcomes.ansibleReady')
|
||||
: tDashboard('automationDiagrams.workspace.decision.outcomes.ansibleBlocked'),
|
||||
tone: ansibleRuntime?.can_run_check_mode ? 'live' : automationQualityAvailable ? 'blocked' : 'watching',
|
||||
},
|
||||
{
|
||||
key: 'aiRoute',
|
||||
signal: tDashboard('automationDiagrams.workspace.decision.rows.aiRoute'),
|
||||
value: tDashboard('automationDiagrams.workspace.values.aiRoute', {
|
||||
lane: aiRouteLaneMode,
|
||||
provider: aiRouteSelectedProvider,
|
||||
}),
|
||||
outcome: tDashboard('automationDiagrams.workspace.decision.outcomes.monitor'),
|
||||
tone: hasAiRouteStatus ? 'live' : 'watching',
|
||||
},
|
||||
{
|
||||
key: 'km',
|
||||
signal: tDashboard('automationDiagrams.workspace.decision.rows.km'),
|
||||
value: tDashboard('automationDiagrams.workspace.values.km', {
|
||||
stale: formatAutomationNumber(kmStaleTotal, automationBriefLoaded),
|
||||
days: formatAutomationNumber(kmStaleThreshold, automationBriefLoaded),
|
||||
}),
|
||||
outcome: tDashboard('automationDiagrams.workspace.decision.outcomes.ownerReview'),
|
||||
tone: typeof kmStaleTotal === 'number' && kmStaleTotal > 0 ? 'progress' : hasKmStaleCandidates ? 'live' : 'watching',
|
||||
},
|
||||
{
|
||||
key: 'callback',
|
||||
signal: tDashboard('automationDiagrams.workspace.decision.rows.callback'),
|
||||
value: tDashboard('automationDiagrams.workspace.values.callback', {
|
||||
missing: formatAutomationNumber(callbackTraceSummary?.outbound_reply_markup_missing_trace_ref_total, automationBriefLoaded),
|
||||
recent1h: formatAutomationNumber(callbackTraceSummary?.outbound_reply_markup_missing_trace_ref_recent_1h_total, automationBriefLoaded),
|
||||
recent24h: formatAutomationNumber(callbackTraceSummary?.outbound_reply_markup_missing_trace_ref_recent_24h_total, automationBriefLoaded),
|
||||
}),
|
||||
outcome: tDashboard('automationDiagrams.workspace.decision.outcomes.watchDecay'),
|
||||
tone: callbackTraceSummary && missingTraceRecent24h > 0 ? 'progress' : callbackTraceSummary ? 'live' : 'watching',
|
||||
},
|
||||
]
|
||||
const lineageNodes = [
|
||||
tDashboard('automationDiagrams.workspace.lineage.nodes.telegram'),
|
||||
tDashboard('automationDiagrams.workspace.lineage.nodes.callback'),
|
||||
tDashboard('automationDiagrams.workspace.lineage.nodes.db'),
|
||||
tDashboard('automationDiagrams.workspace.lineage.nodes.run'),
|
||||
tDashboard('automationDiagrams.workspace.lineage.nodes.km'),
|
||||
]
|
||||
|
||||
// ── 5 KPI Cards (Sprint 5R 設計稿批准版) ────────────────────────────────────
|
||||
|
||||
@@ -1041,6 +1255,7 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
const [activeTabId, setActiveTabId] = useState('overview')
|
||||
const [infraView, setInfraView] = useState<'host' | 'topo'>('topo')
|
||||
const [selectedHost, setSelectedHost] = useState<Host | null>(null)
|
||||
const [compactViewport, setCompactViewport] = useState(false)
|
||||
|
||||
// I1 修正: popstate 取代 100ms 輪詢
|
||||
useEffect(() => {
|
||||
@@ -1055,6 +1270,29 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
return () => { window.removeEventListener('popstate', syncTab); clearInterval(fallback) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(max-width: 720px)')
|
||||
const syncViewport = () => setCompactViewport(media.matches)
|
||||
|
||||
syncViewport()
|
||||
media.addEventListener('change', syncViewport)
|
||||
|
||||
return () => {
|
||||
media.removeEventListener('change', syncViewport)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const primarySectionMargin = compactViewport ? '10px 8px 0' : '14px 20px 0'
|
||||
const secondarySectionMargin = compactViewport ? '10px 8px 0' : '12px 20px 0'
|
||||
const sectionPadding = compactViewport ? 10 : 14
|
||||
const dashboardGridTemplate = compactViewport ? '1fr' : 'repeat(auto-fit, minmax(320px, 1fr))'
|
||||
const diagramCardGridTemplate = compactViewport ? '1fr' : 'repeat(auto-fit, minmax(240px, 1fr))'
|
||||
const diagramWorkspaceGridTemplate = compactViewport ? '1fr' : 'repeat(auto-fit, minmax(min(360px, 100%), 1fr))'
|
||||
const flowStageGridTemplate = compactViewport ? '1fr' : 'repeat(auto-fit, minmax(138px, 1fr))'
|
||||
const topologyGridTemplate = compactViewport ? '1fr' : 'repeat(auto-fit, minmax(150px, 1fr))'
|
||||
const decisionGridTemplate = compactViewport ? '1fr' : '0.8fr 1.15fr 1.05fr'
|
||||
const workItemGridTemplate = compactViewport ? '1fr' : '1fr auto'
|
||||
|
||||
return (
|
||||
<AppLayout locale={locale} showBackground={false} fullBleed>
|
||||
{/* Sprint 5: Tab Bar */}
|
||||
@@ -1078,7 +1316,7 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
}}>
|
||||
|
||||
<section style={{
|
||||
margin: '14px 20px 0',
|
||||
margin: primarySectionMargin,
|
||||
background: '#fff',
|
||||
border: '0.5px solid #d8d3c7',
|
||||
borderRadius: 10,
|
||||
@@ -1088,10 +1326,11 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: compactViewport ? 'column' : 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
padding: '14px 16px',
|
||||
padding: compactViewport ? '12px 10px' : '14px 16px',
|
||||
borderBottom: '0.5px solid #e0ddd4',
|
||||
background: '#faf9f3',
|
||||
}}>
|
||||
@@ -1099,7 +1338,7 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
<div style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, color: '#77736a', fontWeight: 700 }}>
|
||||
{tDashboard('automationDelivery.eyebrow')}
|
||||
</div>
|
||||
<h1 style={{ margin: '4px 0 0', fontSize: 22, lineHeight: 1.15, fontWeight: 800, color: '#141413' }}>
|
||||
<h1 style={{ margin: '4px 0 0', fontSize: compactViewport ? 20 : 22, lineHeight: 1.15, fontWeight: 800, color: '#141413' }}>
|
||||
{tDashboard('automationDelivery.title')}
|
||||
</h1>
|
||||
<p style={{ margin: '6px 0 0', maxWidth: 840, fontSize: 13, lineHeight: 1.6, color: '#5f5b52' }}>
|
||||
@@ -1107,7 +1346,8 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
</p>
|
||||
</div>
|
||||
<div style={{
|
||||
minWidth: 220,
|
||||
minWidth: compactViewport ? 0 : 220,
|
||||
width: compactViewport ? '100%' : undefined,
|
||||
border: `0.5px solid ${automationDeliveryClaimTone.border}`,
|
||||
background: automationDeliveryClaimTone.bg,
|
||||
borderRadius: 8,
|
||||
@@ -1131,7 +1371,7 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 14, padding: 14 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: dashboardGridTemplate, gap: compactViewport ? 10 : 14, padding: sectionPadding }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 14, fontWeight: 800, color: '#141413' }}>
|
||||
@@ -1150,7 +1390,7 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
href={item.href}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto',
|
||||
gridTemplateColumns: workItemGridTemplate,
|
||||
gap: 10,
|
||||
padding: '10px 12px',
|
||||
border: '0.5px solid #e0ddd4',
|
||||
@@ -1166,6 +1406,7 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
</div>
|
||||
<span style={{
|
||||
alignSelf: 'start',
|
||||
justifySelf: compactViewport ? 'start' : 'auto',
|
||||
border: `0.5px solid ${tone.border}`,
|
||||
background: tone.bg,
|
||||
color: tone.color,
|
||||
@@ -1201,7 +1442,7 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
href={item.href}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto',
|
||||
gridTemplateColumns: workItemGridTemplate,
|
||||
gap: 10,
|
||||
padding: '10px 12px',
|
||||
border: `0.5px solid ${tone.border}`,
|
||||
@@ -1217,6 +1458,7 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
</div>
|
||||
<span style={{
|
||||
alignSelf: 'start',
|
||||
justifySelf: compactViewport ? 'start' : 'auto',
|
||||
border: `0.5px solid ${tone.border}`,
|
||||
background: '#fff',
|
||||
color: tone.color,
|
||||
@@ -1237,7 +1479,7 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
</section>
|
||||
|
||||
<section style={{
|
||||
margin: '12px 20px 0',
|
||||
margin: secondarySectionMargin,
|
||||
background: '#fff',
|
||||
border: '0.5px solid #d8d3c7',
|
||||
borderRadius: 10,
|
||||
@@ -1247,10 +1489,11 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: compactViewport ? 'column' : 'row',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '12px 16px',
|
||||
padding: compactViewport ? '12px 10px' : '12px 16px',
|
||||
borderBottom: '0.5px solid #e0ddd4',
|
||||
background: '#faf9f3',
|
||||
}}>
|
||||
@@ -1266,7 +1509,7 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
{tDashboard('automationDiagrams.openTopology')}
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 10, padding: 14 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: diagramCardGridTemplate, gap: 10, padding: sectionPadding }}>
|
||||
{productDiagramCards.map(card => (
|
||||
<a
|
||||
key={card.key}
|
||||
@@ -1331,10 +1574,233 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ borderTop: '0.5px solid #e0ddd4', padding: sectionPadding, background: '#fffdf8' }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 800, color: '#1f5b9b', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{tDashboard('automationDiagrams.workspace.eyebrow')}
|
||||
</div>
|
||||
<h3 style={{ margin: '3px 0 0', fontSize: 15, fontWeight: 800, color: '#141413' }}>
|
||||
{tDashboard('automationDiagrams.workspace.title')}
|
||||
</h3>
|
||||
<p style={{ margin: '5px 0 0', fontSize: 11, lineHeight: 1.55, color: '#5f5b52', maxWidth: 980 }}>
|
||||
{tDashboard('automationDiagrams.workspace.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: diagramWorkspaceGridTemplate, gap: 12 }}>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<div style={{
|
||||
border: '0.5px solid #e0ddd4',
|
||||
borderRadius: 8,
|
||||
background: '#fbfaf6',
|
||||
padding: 12,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', marginBottom: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, color: '#141413' }}>
|
||||
{tDashboard('automationDiagrams.workspace.flow.title')}
|
||||
</div>
|
||||
<div style={{ marginTop: 3, fontSize: 11, color: '#5f5b52', lineHeight: 1.5 }}>
|
||||
{tDashboard('automationDiagrams.workspace.flow.subtitle')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: flowStageGridTemplate,
|
||||
gap: 8,
|
||||
}}>
|
||||
{automationFlowStages.map((stage, index) => {
|
||||
const tone = automationWorkToneStyle[stage.tone]
|
||||
return (
|
||||
<a
|
||||
key={stage.key}
|
||||
href={stage.href}
|
||||
style={{
|
||||
position: 'relative',
|
||||
minHeight: 104,
|
||||
border: `0.5px solid ${tone.border}`,
|
||||
borderRadius: 8,
|
||||
background: tone.bg,
|
||||
padding: '9px 10px',
|
||||
color: 'inherit',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 999,
|
||||
border: `0.5px solid ${tone.border}`,
|
||||
background: '#fff',
|
||||
color: tone.color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
}}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<span style={{ fontSize: 10, fontWeight: 800, color: tone.color, whiteSpace: 'nowrap' }}>
|
||||
{stage.status}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, fontWeight: 800, color: '#141413', lineHeight: 1.3 }}>
|
||||
{stage.title}
|
||||
</div>
|
||||
<div style={{ marginTop: 5, fontSize: 10, color: '#5f5b52', lineHeight: 1.45, overflowWrap: 'anywhere' }}>
|
||||
{stage.detail}
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: '0.5px solid #e0ddd4',
|
||||
borderRadius: 8,
|
||||
background: '#fbfaf6',
|
||||
padding: 12,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, color: '#141413' }}>
|
||||
{tDashboard('automationDiagrams.workspace.topology.title')}
|
||||
</div>
|
||||
<div style={{ marginTop: 3, fontSize: 11, color: '#5f5b52', lineHeight: 1.5 }}>
|
||||
{tDashboard('automationDiagrams.workspace.topology.subtitle')}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, display: 'grid', gridTemplateColumns: topologyGridTemplate, gap: 8 }}>
|
||||
{runtimeTopologyLayers.map((layer) => (
|
||||
<div key={layer.key} style={{
|
||||
border: '0.5px solid #d8d3c7',
|
||||
borderRadius: 8,
|
||||
background: '#fff',
|
||||
padding: 9,
|
||||
}}>
|
||||
<div style={{ fontSize: 11, fontWeight: 800, color: '#1f5b9b', marginBottom: 7 }}>
|
||||
{layer.title}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 5 }}>
|
||||
{layer.items.map((item) => (
|
||||
<div key={`${layer.key}-${item}`} style={{
|
||||
border: '0.5px solid #e0ddd4',
|
||||
borderRadius: 6,
|
||||
background: '#fbfaf6',
|
||||
padding: '5px 7px',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: '#2e2b26',
|
||||
lineHeight: 1.35,
|
||||
overflowWrap: 'anywhere',
|
||||
}}>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<div style={{
|
||||
border: '0.5px solid #e0ddd4',
|
||||
borderRadius: 8,
|
||||
background: '#fbfaf6',
|
||||
padding: 12,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, color: '#141413' }}>
|
||||
{tDashboard('automationDiagrams.workspace.decision.title')}
|
||||
</div>
|
||||
<div style={{ marginTop: 3, fontSize: 11, color: '#5f5b52', lineHeight: 1.5 }}>
|
||||
{tDashboard('automationDiagrams.workspace.decision.subtitle')}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, display: 'grid', gap: 6 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: decisionGridTemplate, gap: 6, fontSize: 9, fontWeight: 800, color: '#77736a', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
<div>{tDashboard('automationDiagrams.workspace.decision.headers.signal')}</div>
|
||||
<div>{tDashboard('automationDiagrams.workspace.decision.headers.value')}</div>
|
||||
<div>{tDashboard('automationDiagrams.workspace.decision.headers.outcome')}</div>
|
||||
</div>
|
||||
{decisionRows.map((row) => {
|
||||
const tone = automationWorkToneStyle[row.tone]
|
||||
return (
|
||||
<div key={row.key} style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: decisionGridTemplate,
|
||||
gap: 6,
|
||||
alignItems: 'stretch',
|
||||
fontSize: 10,
|
||||
}}>
|
||||
<div style={{ border: '0.5px solid #e0ddd4', background: '#fff', borderRadius: 6, padding: '6px 7px', fontWeight: 800, color: '#141413' }}>
|
||||
{row.signal}
|
||||
</div>
|
||||
<div style={{ border: '0.5px solid #e0ddd4', background: '#fff', borderRadius: 6, padding: '6px 7px', color: '#5f5b52', overflowWrap: 'anywhere' }}>
|
||||
{row.value}
|
||||
</div>
|
||||
<div style={{ border: `0.5px solid ${tone.border}`, background: tone.bg, borderRadius: 6, padding: '6px 7px', color: tone.color, fontWeight: 800, overflowWrap: 'anywhere' }}>
|
||||
{row.outcome}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: '0.5px solid #e0ddd4',
|
||||
borderRadius: 8,
|
||||
background: '#fbfaf6',
|
||||
padding: 12,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, color: '#141413' }}>
|
||||
{tDashboard('automationDiagrams.workspace.lineage.title')}
|
||||
</div>
|
||||
<div style={{ marginTop: 3, fontSize: 11, color: '#5f5b52', lineHeight: 1.5 }}>
|
||||
{tDashboard('automationDiagrams.workspace.lineage.subtitle')}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, display: 'grid', gap: 7 }}>
|
||||
{lineageNodes.map((node, index) => (
|
||||
<div key={node} style={{ display: 'grid', gridTemplateColumns: '24px 1fr', gap: 8, alignItems: 'center' }}>
|
||||
<div style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 999,
|
||||
border: '0.5px solid #9bb6d9',
|
||||
background: '#eef5ff',
|
||||
color: '#1f5b9b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
}}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div style={{
|
||||
border: '0.5px solid #d8d3c7',
|
||||
background: '#fff',
|
||||
borderRadius: 7,
|
||||
padding: '7px 9px',
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
color: '#2e2b26',
|
||||
}}>
|
||||
{node}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── KPI Strip (5 卡片 — Sprint 5R 設計稿) ──────────────────────── */}
|
||||
<div style={{ display: 'flex', gap: 12, padding: '10px 20px', flexShrink: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: compactViewport ? '1fr' : 'repeat(5, minmax(0, 1fr))', gap: compactViewport ? 8 : 12, padding: compactViewport ? '10px 8px' : '10px 20px', flexShrink: 0 }}>
|
||||
{/* 系統健康 */}
|
||||
<div style={{ flex: 1, background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 8, padding: '8px 12px' }}>
|
||||
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#87867f', fontWeight: 500 }}>{tDashboard('serviceHealth')}</div>
|
||||
@@ -1400,11 +1866,11 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
</div>
|
||||
|
||||
{/* ── 主體 2 欄 ─────────────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', flex: '0 0 auto', overflow: 'visible', padding: '14px 20px', gap: 20, background: '#f5f4ed' }}>
|
||||
<div style={{ display: 'flex', flexDirection: compactViewport ? 'column' : 'row', flex: '0 0 auto', overflow: 'visible', padding: compactViewport ? '10px 8px' : '14px 20px', gap: compactViewport ? 12 : 20, background: '#f5f4ed' }}>
|
||||
|
||||
{/* ── 左欄 (60%): 活躍事件 + 處置統計 + 最近活動 ─────────────── */}
|
||||
<div style={{
|
||||
flex: 6, minWidth: 0, overflowY: 'visible',
|
||||
flex: compactViewport ? 'none' : 6, minWidth: 0, overflowY: 'visible',
|
||||
display: 'flex', flexDirection: 'column', gap: 14,
|
||||
paddingBottom: 40,
|
||||
}}>
|
||||
@@ -1510,7 +1976,7 @@ export default function Home({ params }: { params: { locale: string } }) {
|
||||
|
||||
{/* ── 右欄 (40%): OpenClaw + 基礎架構 + 監控工具 ─────────────── */}
|
||||
<div style={{
|
||||
flex: 4, minWidth: 0,
|
||||
flex: compactViewport ? 'none' : 4, minWidth: 0,
|
||||
overflowY: 'visible',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
@@ -38,6 +38,7 @@ interface AppLayoutProps {
|
||||
// =============================================================================
|
||||
|
||||
const SIDEBAR_STATE_KEY = 'awoooi-sidebar-collapsed'
|
||||
const MOBILE_SHELL_MEDIA = '(max-width: 768px)'
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
@@ -50,6 +51,7 @@ export function AppLayout({
|
||||
fullBleed = false,
|
||||
}: AppLayoutProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mobileShell, setMobileShell] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Phase 19 修復: 全局 SSE 連接
|
||||
@@ -64,6 +66,18 @@ export function AppLayout({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(MOBILE_SHELL_MEDIA)
|
||||
const updateMobileShell = () => setMobileShell(media.matches)
|
||||
|
||||
updateMobileShell()
|
||||
media.addEventListener('change', updateMobileShell)
|
||||
|
||||
return () => {
|
||||
media.removeEventListener('change', updateMobileShell)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Phase 19 修復: 全局啟動 SSE 連接 (所有頁面共享)
|
||||
useEffect(() => {
|
||||
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || ''
|
||||
@@ -88,6 +102,7 @@ export function AppLayout({
|
||||
// Keep the navigation shell in the server-rendered HTML. If a rolling deploy
|
||||
// or stale browser cache delays hydration, the operator still has navigation.
|
||||
const effectiveCollapsed = mounted ? collapsed : false
|
||||
const shellCollapsed = mobileShell || effectiveCollapsed
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-nothing-gray-50">
|
||||
@@ -104,14 +119,14 @@ export function AppLayout({
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
locale={locale}
|
||||
collapsed={effectiveCollapsed}
|
||||
collapsed={shellCollapsed}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<Header
|
||||
locale={locale}
|
||||
sidebarCollapsed={effectiveCollapsed}
|
||||
sidebarCollapsed={shellCollapsed}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
@@ -120,7 +135,7 @@ export function AppLayout({
|
||||
'relative z-10',
|
||||
'pt-[68px]',
|
||||
'transition-all duration-300 ease-out',
|
||||
effectiveCollapsed ? 'ml-16' : 'ml-[224px]'
|
||||
shellCollapsed ? 'ml-16' : 'ml-[224px]'
|
||||
)}
|
||||
>
|
||||
{fullBleed ? (
|
||||
|
||||
Reference in New Issue
Block a user