diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 16ccc590b..cf1fb2e6a 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -160,6 +160,44 @@ "secret": "secret read: {value}" } }, + "currentP0": { + "title": "當前 P0 主線", + "detail": "直接讀取正式 Delivery workbench;Dashboard 200、UI 可見或 CD success 都不能單獨當成全主線完成。", + "source": "來源", + "readiness": "readiness", + "closed": "已完成 P0", + "closedEmpty": "尚未關閉", + "blockers": "目前阻擋", + "noBlockers": "目前沒有 active blocker", + "truth": { + "eventGate": "事件門檻", + "service": "服務狀態", + "productData": "產品資料", + "backup": "備份核心", + "stock": "StockPlatform", + "github": "GitHub lane" + }, + "flags": { + "runtimeWrite": "runtime write", + "workflowTrigger": "workflow trigger", + "secretCollection": "機密明文收集", + "remoteWrite": "remote write", + "repoCreation": "repo creation", + "activeScan": "active scan" + }, + "values": { + "yes": "是", + "no": "否", + "green": "green", + "blocked": "blocked", + "open": "open", + "closed": "closed" + } + }, + "nextFocus": { + "status": "{percent}% · blocker {blockers}", + "empty": "目前沒有 Delivery workbench 回傳的待處理焦點。" + }, "lanes": { "release": { "title": "乾淨 release 工作流", @@ -214,6 +252,18 @@ "data": "不直接做資料庫、backup、restore 或 migration 寫操作。", "security": "不啟動 Wazuh / Kali 主動回應流程、active scan 或 host containment。" }, + "boundaryFacts": { + "readOnlyApi": "read-only API 回讀", + "runtimeWrite": "runtime write", + "remoteWrite": "remote write", + "workflowTrigger": "workflow trigger", + "secretCollection": "機密明文收集", + "activeScan": "Wazuh / Kali active scan", + "values": { + "open": "open", + "closed": "closed" + } + }, "errors": { "title": "部分資料沒有回讀" } diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 6ed474f35..3458ddc0a 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -160,6 +160,44 @@ "secret": "secret read: {value}" } }, + "currentP0": { + "title": "當前 P0 主線", + "detail": "直接讀取正式 Delivery workbench;Dashboard 200、UI 可見或 CD success 都不能單獨當成全主線完成。", + "source": "來源", + "readiness": "readiness", + "closed": "已完成 P0", + "closedEmpty": "尚未關閉", + "blockers": "目前阻擋", + "noBlockers": "目前沒有 active blocker", + "truth": { + "eventGate": "事件門檻", + "service": "服務狀態", + "productData": "產品資料", + "backup": "備份核心", + "stock": "StockPlatform", + "github": "GitHub lane" + }, + "flags": { + "runtimeWrite": "runtime write", + "workflowTrigger": "workflow trigger", + "secretCollection": "機密明文收集", + "remoteWrite": "remote write", + "repoCreation": "repo creation", + "activeScan": "active scan" + }, + "values": { + "yes": "是", + "no": "否", + "green": "green", + "blocked": "blocked", + "open": "open", + "closed": "closed" + } + }, + "nextFocus": { + "status": "{percent}% · blocker {blockers}", + "empty": "目前沒有 Delivery workbench 回傳的待處理焦點。" + }, "lanes": { "release": { "title": "乾淨 release 工作流", @@ -214,6 +252,18 @@ "data": "不直接做資料庫、backup、restore 或 migration 寫操作。", "security": "不啟動 Wazuh / Kali 主動回應流程、active scan 或 host containment。" }, + "boundaryFacts": { + "readOnlyApi": "read-only API 回讀", + "runtimeWrite": "runtime write", + "remoteWrite": "remote write", + "workflowTrigger": "workflow trigger", + "secretCollection": "機密明文收集", + "activeScan": "Wazuh / Kali active scan", + "values": { + "open": "open", + "closed": "closed" + } + }, "errors": { "title": "部分資料沒有回讀" } diff --git a/apps/web/src/app/[locale]/delivery/page.tsx b/apps/web/src/app/[locale]/delivery/page.tsx index b7bac5e5b..87d46ac30 100644 --- a/apps/web/src/app/[locale]/delivery/page.tsx +++ b/apps/web/src/app/[locale]/delivery/page.tsx @@ -53,6 +53,16 @@ interface DeliveryLane { Icon: typeof Rocket } +interface DeliveryFocusRow { + id: string + title: string + action: string + percent: number + blockerCount: number + tone: DeliveryTone + Icon: typeof Rocket +} + const SOURCE_COUNT = 6 const EMPTY_DATA: DeliveryData = { @@ -164,6 +174,108 @@ function MetricTile({ ) } +function CurrentP0Panel({ + workbench, + t, +}: { + workbench: DeliveryClosureWorkbenchSnapshot | null + t: DeliveryTranslator +}) { + if (!workbench) return null + + const { readback, rollups, operation_boundaries: boundaries } = workbench + const readiness = clampPercent(readback.current_p0_readiness_percent || rollups.current_p0_readiness_percent) + const blockers = readback.current_p0_active_blockers.length > 0 ? readback.current_p0_active_blockers : workbench.active_blockers + const panelTone: DeliveryTone = blockers.length > 0 ? 'danger' : readiness >= 100 ? 'ok' : 'warn' + const closedP0 = readback.closed_p0_workplan_ids.length > 0 + ? readback.closed_p0_workplan_ids.join(' / ') + : t('currentP0.closedEmpty') + const truthRows = [ + { key: 'eventGate', value: rollups.current_p0_blocked_by_fresh_reboot_window_only ? t('currentP0.values.yes') : t('currentP0.values.no') }, + { key: 'service', value: rollups.current_p0_service_green ? t('currentP0.values.green') : t('currentP0.values.blocked') }, + { key: 'productData', value: rollups.current_p0_product_data_green ? t('currentP0.values.green') : t('currentP0.values.blocked') }, + { key: 'backup', value: rollups.current_p0_backup_core_green ? t('currentP0.values.green') : t('currentP0.values.blocked') }, + { key: 'stock', value: `${rollups.current_p0_stockplatform_freshness_status} / ${rollups.current_p0_stockplatform_ingestion_status}` }, + { key: 'github', value: readback.github_lane_status }, + ] + const flagRows = [ + { key: 'runtimeWrite', value: boundaries.runtime_write_allowed || readback.runtime_write_authorized || rollups.runtime_write_authorized }, + { key: 'workflowTrigger', value: boundaries.workflow_trigger_allowed || readback.workflow_trigger_authorized || rollups.workflow_trigger_authorized }, + { key: 'secretCollection', value: boundaries.secret_value_collection_allowed || readback.secret_value_collection_allowed || rollups.secret_values_collected }, + { key: 'remoteWrite', value: boundaries.remote_write_allowed }, + { key: 'repoCreation', value: boundaries.repo_creation_allowed }, + { key: 'activeScan', value: boundaries.active_scan_allowed }, + ] + + return ( +
+
+
+

{t('currentP0.title')}

+

{t('currentP0.detail')}

+
+ +
+ +
+
+
+
+
+

{readback.current_p0_workplan_id}

+

{readback.current_p0_safe_next_step || workbench.safe_next_step}

+
+
+ +
+
+ {t('currentP0.source')} + {readback.current_p0_source_id} +
+
+ {t('currentP0.readiness')} + {readiness}% +
+
+ {t('currentP0.closed')} + {closedP0} +
+
+ +
+ {t('currentP0.blockers')} +
+ {(blockers.length > 0 ? blockers : [t('currentP0.noBlockers')]).map(blocker => ( + 0 ? 'danger' : 'ok'} label={blocker} /> + ))} +
+
+ +
+ {truthRows.map(row => ( +
+ {t(`currentP0.truth.${row.key}`)} + {row.value} +
+ ))} +
+ +
+ {flagRows.map(row => ( + + ))} +
+
+
+
+ ) +} + function DrillPreflightPanel({ preflight, locale, @@ -535,6 +647,55 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) const highRiskBlockers = workbench?.summary.high_risk_blocker_count ?? lanes.reduce((sum, lane) => sum + lane.blockerCount, 0) const averageCompletion = workbench?.summary.average_completion_percent ?? clampPercent(lanes.reduce((sum, lane) => sum + lane.percent, 0) / Math.max(lanes.length, 1)) const pageTone: DeliveryTone = highRiskBlockers > 0 ? 'danger' : loadedCount === sourceTotal ? 'ok' : 'warn' + const activeExecutionCount = workbench + ? [ + workbench.operation_boundaries.runtime_write_allowed, + workbench.operation_boundaries.remote_write_allowed, + workbench.operation_boundaries.workflow_trigger_allowed, + workbench.operation_boundaries.secret_value_collection_allowed, + workbench.operation_boundaries.active_scan_allowed, + ].filter(Boolean).length + : 0 + const focusRows = useMemo(() => { + if (workbench?.next_focus?.length) { + return workbench.next_focus.slice(0, 5).map(focus => { + const lane = lanes.find(item => item.id === focus.lane_id) + const percent = clampPercent(focus.completion_percent) + return { + id: focus.lane_id, + title: lane?.title ?? focus.lane_id, + action: focus.next_action, + percent, + blockerCount: focus.blocker_count, + tone: focus.blocker_count > 0 ? 'danger' : resolveTone(0, percent), + Icon: lane?.Icon ?? Rocket, + } + }) + } + + return lanes + .filter(lane => lane.blockerCount > 0 || lane.percent < 80) + .slice(0, 5) + .map(lane => ({ + id: lane.id, + title: lane.title, + action: lane.nextAction || lane.metric, + percent: lane.percent, + blockerCount: lane.blockerCount, + tone: lane.tone, + Icon: lane.Icon, + })) + }, [lanes, workbench]) + const boundaryRows = workbench + ? [ + { key: 'readOnlyApi', value: workbench.operation_boundaries.read_only_api_allowed, tone: 'ok' as DeliveryTone }, + { key: 'runtimeWrite', value: workbench.operation_boundaries.runtime_write_allowed, tone: workbench.operation_boundaries.runtime_write_allowed ? 'danger' as DeliveryTone : 'ok' as DeliveryTone }, + { key: 'remoteWrite', value: workbench.operation_boundaries.remote_write_allowed, tone: workbench.operation_boundaries.remote_write_allowed ? 'danger' as DeliveryTone : 'ok' as DeliveryTone }, + { key: 'workflowTrigger', value: workbench.operation_boundaries.workflow_trigger_allowed, tone: workbench.operation_boundaries.workflow_trigger_allowed ? 'warn' as DeliveryTone : 'ok' as DeliveryTone }, + { key: 'secretCollection', value: workbench.operation_boundaries.secret_value_collection_allowed, tone: workbench.operation_boundaries.secret_value_collection_allowed ? 'danger' as DeliveryTone : 'ok' as DeliveryTone }, + { key: 'activeScan', value: workbench.operation_boundaries.active_scan_allowed, tone: workbench.operation_boundaries.active_scan_allowed ? 'danger' as DeliveryTone : 'ok' as DeliveryTone }, + ] + : [] return ( @@ -582,9 +743,11 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) = 80 ? 'ok' : 'warn'} /> 0 ? 'danger' : 'ok'} /> - + 0 ? 'danger' : 'ok'} /> + + {errors.length > 0 && ( @@ -618,19 +781,20 @@ export default function DeliveryPage({ params }: { params: { locale: string } })
- {lanes - .filter(lane => lane.blockerCount > 0 || lane.percent < 80) - .slice(0, 5) - .map(lane => ( -
-
@@ -642,13 +806,21 @@ export default function DeliveryPage({ params }: { params: { locale: string } })
- {['secret', 'production', 'repo', 'data', 'security'].map(key => ( -
-
- ))} + {boundaryRows.length > 0 + ? boundaryRows.map(row => ( +
+
+ )) + : ['secret', 'production', 'repo', 'data', 'security'].map(key => ( +
+
+ ))}
@@ -668,6 +840,97 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 12px; } + :global(.delivery-current-p0-grid) { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr); + gap: 14px; + align-items: start; + } + :global(.delivery-current-p0-main) { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + gap: 12px; + min-width: 0; + } + :global(.delivery-current-p0-icon) { + width: 44px; + height: 44px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: #2f7d54; + background: rgba(47, 125, 84, 0.1); + } + :global(.delivery-current-p0-main h3) { + margin: 0; + font-size: 19px; + font-weight: 900; + color: #141413; + overflow-wrap: anywhere; + } + :global(.delivery-current-p0-main p) { + margin: 6px 0 0; + color: #595852; + font-size: 13px; + line-height: 1.55; + overflow-wrap: anywhere; + } + :global(.delivery-current-p0-stats), + :global(.delivery-current-p0-truth) { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + } + :global(.delivery-current-p0-truth) { + grid-column: 1 / -1; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + } + :global(.delivery-current-p0-stats div), + :global(.delivery-current-p0-truth div) { + min-height: 68px; + border: 0.5px solid #e0ddd4; + border-radius: 8px; + padding: 9px; + background: rgba(255, 255, 255, 0.56); + min-width: 0; + } + :global(.delivery-current-p0-stats span), + :global(.delivery-current-p0-truth span) { + display: block; + color: #706f68; + font-size: 11px; + font-weight: 800; + margin-bottom: 6px; + } + :global(.delivery-current-p0-stats strong), + :global(.delivery-current-p0-truth strong) { + color: #141413; + font-size: 15px; + line-height: 1.3; + overflow-wrap: anywhere; + } + :global(.delivery-current-p0-blockers) { + grid-column: 1 / -1; + display: grid; + gap: 8px; + min-width: 0; + } + :global(.delivery-current-p0-blockers > strong) { + color: #141413; + font-size: 13px; + font-weight: 900; + } + :global(.delivery-current-p0-blockers > div), + :global(.delivery-current-p0-flags) { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-width: 0; + } + :global(.delivery-current-p0-flags) { + grid-column: 1 / -1; + } :global(.delivery-drill-panel) { min-width: 0; } @@ -824,6 +1087,16 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) flex-direction: column; gap: 8px; } + .delivery-next-empty { + min-height: 54px; + border: 0.5px solid #e0ddd4; + background: rgba(255, 255, 255, 0.62); + border-radius: 8px; + padding: 12px; + color: #706f68; + font-size: 13px; + line-height: 1.5; + } .delivery-next-row, .delivery-boundary-row { display: grid; @@ -855,10 +1128,11 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) overflow-wrap: anywhere; } .delivery-boundary-row { - grid-template-columns: 20px minmax(0, 1fr) 20px; + grid-template-columns: 20px minmax(0, 1fr) auto; } @media (max-width: 900px) { .delivery-hero, + :global(.delivery-current-p0-grid), :global(.delivery-drill-grid), .delivery-grid-two { grid-template-columns: 1fr; @@ -868,6 +1142,9 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) } } @media (max-width: 560px) { + :global(.delivery-current-p0-stats) { + grid-template-columns: 1fr; + } :global(.delivery-drill-metrics) { grid-template-columns: 1fr; }