feat(web): add delivery closure workbench
This commit is contained in:
@@ -63,6 +63,7 @@
|
||||
"drift": "漂移偵測",
|
||||
"neuralCommand": "神經指揮中心",
|
||||
"commandCenter": "指令中心",
|
||||
"delivery": "交付閉環",
|
||||
"observability": "可觀測性",
|
||||
"automation": "自動化",
|
||||
"operations": "營運",
|
||||
@@ -84,6 +85,82 @@
|
||||
"iwooos": "IwoooS",
|
||||
"iwooosSecurityCompliance": "IwoooS 安全合規"
|
||||
},
|
||||
"delivery": {
|
||||
"eyebrow": "AWOOOI Delivery",
|
||||
"title": "交付閉環工作台",
|
||||
"subtitle": "把目前真正會推進交付的主線集中在同一頁:乾淨 release、GitHub 私有備援、Gitea / CI/CD、runtime surface、資料與備份。這裡只顯示能推動下一步的狀態,不再把文件數量當成完成度。",
|
||||
"states": {
|
||||
"loading": "讀取中",
|
||||
"ready": "資料鏈正常",
|
||||
"partial": "部分資料待補",
|
||||
"noData": "尚未回讀"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "重新整理"
|
||||
},
|
||||
"sources": {
|
||||
"0": { "error": "狀態清理資料未回讀" },
|
||||
"1": { "error": "GitHub 備援資料未回讀" },
|
||||
"2": { "error": "Gitea / runner 資料未回讀" },
|
||||
"3": { "error": "Runtime surface 資料未回讀" },
|
||||
"4": { "error": "Backup readiness 資料未回讀" }
|
||||
},
|
||||
"metrics": {
|
||||
"loaded": "資料來源",
|
||||
"loadedDetail": "只計入正式 API 成功回讀的來源。",
|
||||
"completion": "平均進度",
|
||||
"completionDetail": "以本頁五條交付主線計算。",
|
||||
"blockers": "高風險阻擋",
|
||||
"blockersDetail": "只統計會影響交付決策的阻擋。",
|
||||
"execution": "執行授權",
|
||||
"executionDetail": "本頁只讀,不執行 runtime 或 remote write。"
|
||||
},
|
||||
"sections": {
|
||||
"lanes": "交付主線",
|
||||
"lanesDetail": "每張卡只回答完成度、阻擋數、下一步入口。",
|
||||
"next": "下一步焦點",
|
||||
"nextDetail": "只列需要處理的主線,不列文件清單。",
|
||||
"boundary": "保留硬邊界",
|
||||
"boundaryDetail": "這些仍需明確授權,但不得阻擋低風險 coding / UI / test。"
|
||||
},
|
||||
"lanes": {
|
||||
"release": {
|
||||
"title": "乾淨 release 工作流",
|
||||
"description": "把可交付的變更切出乾淨分支與可驗證提交,避免髒分支整包推送。",
|
||||
"metric": "阻擋 gate {blocked}"
|
||||
},
|
||||
"github": {
|
||||
"title": "GitHub 私有備援",
|
||||
"description": "所有備援 repo 必須私有,公開可讀或未驗證 private 都不能標綠。",
|
||||
"metric": "已驗證 {verified}/{total}"
|
||||
},
|
||||
"gitea": {
|
||||
"title": "Gitea / CI-CD",
|
||||
"description": "確認 workflow、runner label、通知與 dev / prod 發版線是真實可跑。",
|
||||
"metric": "workflow {count}"
|
||||
},
|
||||
"runtime": {
|
||||
"title": "Runtime surface",
|
||||
"description": "把產品、網站、服務與部署面映射到實際 runtime,不再停在文件描述。",
|
||||
"metric": "surface {total}"
|
||||
},
|
||||
"backup": {
|
||||
"title": "資料與備份",
|
||||
"description": "資料庫、類資料庫、備份與還原演練必須能支撐一鍵上雲與獨立部署。",
|
||||
"metric": "readiness row {rows}"
|
||||
}
|
||||
},
|
||||
"boundaries": {
|
||||
"secret": "不收 secret value、token、private key、cookie 或 private clone credential。",
|
||||
"production": "不直接改 production runtime、public gateway、Nginx、Docker、K8s 或 firewall。",
|
||||
"repo": "不直接建立 GitHub repo、改 visibility、sync refs、force push 或 trigger workflow。",
|
||||
"data": "不直接做資料庫、backup、restore 或 migration 寫操作。",
|
||||
"security": "不啟動 Wazuh / Kali active response、active scan 或 host containment。"
|
||||
},
|
||||
"errors": {
|
||||
"title": "部分資料沒有回讀"
|
||||
}
|
||||
},
|
||||
"observabilityCommand": {
|
||||
"eyebrow": "AWOOOI 可觀測性指揮面板",
|
||||
"title": "主機、專案、網站、服務與工具全域監控",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"drift": "漂移偵測",
|
||||
"neuralCommand": "神經指揮中心",
|
||||
"commandCenter": "指令中心",
|
||||
"delivery": "交付閉環",
|
||||
"observability": "可觀測性",
|
||||
"automation": "自動化",
|
||||
"operations": "營運",
|
||||
@@ -84,6 +85,82 @@
|
||||
"iwooos": "IwoooS",
|
||||
"iwooosSecurityCompliance": "IwoooS 安全合規"
|
||||
},
|
||||
"delivery": {
|
||||
"eyebrow": "AWOOOI Delivery",
|
||||
"title": "交付閉環工作台",
|
||||
"subtitle": "把目前真正會推進交付的主線集中在同一頁:乾淨 release、GitHub 私有備援、Gitea / CI/CD、runtime surface、資料與備份。這裡只顯示能推動下一步的狀態,不再把文件數量當成完成度。",
|
||||
"states": {
|
||||
"loading": "讀取中",
|
||||
"ready": "資料鏈正常",
|
||||
"partial": "部分資料待補",
|
||||
"noData": "尚未回讀"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "重新整理"
|
||||
},
|
||||
"sources": {
|
||||
"0": { "error": "狀態清理資料未回讀" },
|
||||
"1": { "error": "GitHub 備援資料未回讀" },
|
||||
"2": { "error": "Gitea / runner 資料未回讀" },
|
||||
"3": { "error": "Runtime surface 資料未回讀" },
|
||||
"4": { "error": "Backup readiness 資料未回讀" }
|
||||
},
|
||||
"metrics": {
|
||||
"loaded": "資料來源",
|
||||
"loadedDetail": "只計入正式 API 成功回讀的來源。",
|
||||
"completion": "平均進度",
|
||||
"completionDetail": "以本頁五條交付主線計算。",
|
||||
"blockers": "高風險阻擋",
|
||||
"blockersDetail": "只統計會影響交付決策的阻擋。",
|
||||
"execution": "執行授權",
|
||||
"executionDetail": "本頁只讀,不執行 runtime 或 remote write。"
|
||||
},
|
||||
"sections": {
|
||||
"lanes": "交付主線",
|
||||
"lanesDetail": "每張卡只回答完成度、阻擋數、下一步入口。",
|
||||
"next": "下一步焦點",
|
||||
"nextDetail": "只列需要處理的主線,不列文件清單。",
|
||||
"boundary": "保留硬邊界",
|
||||
"boundaryDetail": "這些仍需明確授權,但不得阻擋低風險 coding / UI / test。"
|
||||
},
|
||||
"lanes": {
|
||||
"release": {
|
||||
"title": "乾淨 release 工作流",
|
||||
"description": "把可交付的變更切出乾淨分支與可驗證提交,避免髒分支整包推送。",
|
||||
"metric": "阻擋 gate {blocked}"
|
||||
},
|
||||
"github": {
|
||||
"title": "GitHub 私有備援",
|
||||
"description": "所有備援 repo 必須私有,公開可讀或未驗證 private 都不能標綠。",
|
||||
"metric": "已驗證 {verified}/{total}"
|
||||
},
|
||||
"gitea": {
|
||||
"title": "Gitea / CI-CD",
|
||||
"description": "確認 workflow、runner label、通知與 dev / prod 發版線是真實可跑。",
|
||||
"metric": "workflow {count}"
|
||||
},
|
||||
"runtime": {
|
||||
"title": "Runtime surface",
|
||||
"description": "把產品、網站、服務與部署面映射到實際 runtime,不再停在文件描述。",
|
||||
"metric": "surface {total}"
|
||||
},
|
||||
"backup": {
|
||||
"title": "資料與備份",
|
||||
"description": "資料庫、類資料庫、備份與還原演練必須能支撐一鍵上雲與獨立部署。",
|
||||
"metric": "readiness row {rows}"
|
||||
}
|
||||
},
|
||||
"boundaries": {
|
||||
"secret": "不收 secret value、token、private key、cookie 或 private clone credential。",
|
||||
"production": "不直接改 production runtime、public gateway、Nginx、Docker、K8s 或 firewall。",
|
||||
"repo": "不直接建立 GitHub repo、改 visibility、sync refs、force push 或 trigger workflow。",
|
||||
"data": "不直接做資料庫、backup、restore 或 migration 寫操作。",
|
||||
"security": "不啟動 Wazuh / Kali active response、active scan 或 host containment。"
|
||||
},
|
||||
"errors": {
|
||||
"title": "部分資料沒有回讀"
|
||||
}
|
||||
},
|
||||
"observabilityCommand": {
|
||||
"eyebrow": "AWOOOI 可觀測性指揮面板",
|
||||
"title": "主機、專案、網站、服務與工具全域監控",
|
||||
|
||||
555
apps/web/src/app/[locale]/delivery/page.tsx
Normal file
555
apps/web/src/app/[locale]/delivery/page.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
GitBranch,
|
||||
HardDrive,
|
||||
Lock,
|
||||
PackageCheck,
|
||||
RefreshCw,
|
||||
Rocket,
|
||||
Server,
|
||||
} from 'lucide-react'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { GlassCard } from '@/components/ui/glass-card'
|
||||
import {
|
||||
apiClient,
|
||||
type AwoooIStatusCleanupDashboardSnapshot,
|
||||
type BackupDrReadinessMatrixSnapshot,
|
||||
type GiteaWorkflowRunnerHealthSnapshot,
|
||||
type GithubTargetPrivateBackupEvidenceGateSnapshot,
|
||||
type RuntimeSurfaceInventorySnapshot,
|
||||
} from '@/lib/api-client'
|
||||
|
||||
type DeliveryTone = 'ok' | 'warn' | 'danger' | 'neutral'
|
||||
|
||||
interface DeliveryData {
|
||||
statusCleanup: AwoooIStatusCleanupDashboardSnapshot | null
|
||||
github: GithubTargetPrivateBackupEvidenceGateSnapshot | null
|
||||
gitea: GiteaWorkflowRunnerHealthSnapshot | null
|
||||
runtime: RuntimeSurfaceInventorySnapshot | null
|
||||
backup: BackupDrReadinessMatrixSnapshot | null
|
||||
}
|
||||
|
||||
interface DeliveryLane {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
percent: number
|
||||
status: string
|
||||
metric: string
|
||||
blockerCount: number
|
||||
href: string
|
||||
tone: DeliveryTone
|
||||
Icon: typeof Rocket
|
||||
}
|
||||
|
||||
const EMPTY_DATA: DeliveryData = {
|
||||
statusCleanup: null,
|
||||
github: null,
|
||||
gitea: null,
|
||||
runtime: null,
|
||||
backup: null,
|
||||
}
|
||||
|
||||
const clampPercent = (value: number | null | undefined) => Math.max(0, Math.min(100, Math.round(value ?? 0)))
|
||||
|
||||
const toneColor = (tone: DeliveryTone) => {
|
||||
if (tone === 'ok') return '#2f7d54'
|
||||
if (tone === 'warn') return '#9a6a22'
|
||||
if (tone === 'danger') return '#b2432d'
|
||||
return '#706f68'
|
||||
}
|
||||
|
||||
const toneBackground = (tone: DeliveryTone) => {
|
||||
if (tone === 'ok') return 'rgba(47, 125, 84, 0.10)'
|
||||
if (tone === 'warn') return 'rgba(154, 106, 34, 0.10)'
|
||||
if (tone === 'danger') return 'rgba(178, 67, 45, 0.10)'
|
||||
return 'rgba(112, 111, 104, 0.10)'
|
||||
}
|
||||
|
||||
const resolveTone = (blockerCount: number, percent: number): DeliveryTone => {
|
||||
if (blockerCount > 0) return 'danger'
|
||||
if (percent < 80) return 'warn'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
function StatusPill({ tone, label }: { tone: DeliveryTone; label: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 26,
|
||||
borderRadius: 6,
|
||||
padding: '3px 8px',
|
||||
background: toneBackground(tone),
|
||||
color: toneColor(tone),
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressBar({ percent, tone }: { percent: number; tone: DeliveryTone }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: '#ebe8dd',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${percent}%`,
|
||||
height: '100%',
|
||||
background: toneColor(tone),
|
||||
transition: 'width 240ms ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricTile({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
tone,
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
detail: string
|
||||
tone: DeliveryTone
|
||||
}) {
|
||||
return (
|
||||
<GlassCard variant="subtle" padding="sm" data-testid={`delivery-metric-${label}`}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minHeight: 112 }}>
|
||||
<span style={{ fontSize: 12, color: '#706f68', fontWeight: 700 }}>{label}</span>
|
||||
<strong style={{ fontFamily: 'var(--font-heading), sans-serif', fontSize: 30, color: '#141413', lineHeight: 1 }}>
|
||||
{value}
|
||||
</strong>
|
||||
<span style={{ fontSize: 12, lineHeight: 1.45, color: toneColor(tone), overflowWrap: 'anywhere' }}>
|
||||
{detail}
|
||||
</span>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
function LaneCard({ lane, locale }: { lane: DeliveryLane; locale: string }) {
|
||||
return (
|
||||
<GlassCard variant="default" padding="md" data-testid={`delivery-lane-${lane.id}`}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, minHeight: 230 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: toneBackground(lane.tone),
|
||||
color: toneColor(lane.tone),
|
||||
flex: '0 0 auto',
|
||||
}}
|
||||
>
|
||||
<lane.Icon size={19} aria-hidden="true" />
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<h2 style={{ fontSize: 17, fontWeight: 800, color: '#141413', margin: 0, overflowWrap: 'anywhere' }}>
|
||||
{lane.title}
|
||||
</h2>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.5, color: '#706f68', margin: '5px 0 0', overflowWrap: 'anywhere' }}>
|
||||
{lane.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<StatusPill tone={lane.tone} label={lane.status} />
|
||||
<span style={{ fontSize: 13, fontWeight: 800, color: '#141413' }}>{lane.percent}%</span>
|
||||
</div>
|
||||
<ProgressBar percent={lane.percent} tone={lane.tone} />
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 10, alignItems: 'center', marginTop: 'auto' }}>
|
||||
<span style={{ fontSize: 13, color: '#45443f', lineHeight: 1.45, overflowWrap: 'anywhere' }}>{lane.metric}</span>
|
||||
<StatusPill tone={lane.blockerCount > 0 ? 'danger' : 'ok'} label={String(lane.blockerCount)} />
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/${locale}${lane.href}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
color: '#141413',
|
||||
fontWeight: 800,
|
||||
fontSize: 13,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={15} aria-hidden="true" />
|
||||
<span>{lane.metric}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DeliveryPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('delivery')
|
||||
const [data, setData] = useState<DeliveryData>(EMPTY_DATA)
|
||||
const [errors, setErrors] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
const results = await Promise.allSettled([
|
||||
apiClient.getAwoooIStatusCleanupDashboard(),
|
||||
apiClient.getGithubTargetPrivateBackupEvidenceGate(),
|
||||
apiClient.getGiteaWorkflowRunnerHealth(),
|
||||
apiClient.getRuntimeSurfaceInventory(),
|
||||
apiClient.getBackupDrReadinessMatrix(),
|
||||
])
|
||||
if (cancelled) return
|
||||
|
||||
const nextData: DeliveryData = {
|
||||
statusCleanup: results[0].status === 'fulfilled' ? results[0].value : null,
|
||||
github: results[1].status === 'fulfilled' ? results[1].value : null,
|
||||
gitea: results[2].status === 'fulfilled' ? results[2].value : null,
|
||||
runtime: results[3].status === 'fulfilled' ? results[3].value : null,
|
||||
backup: results[4].status === 'fulfilled' ? results[4].value : null,
|
||||
}
|
||||
const nextErrors = results
|
||||
.map((result, index) => ({ result, index }))
|
||||
.filter(({ result }) => result.status === 'rejected')
|
||||
.map(({ index }) => t(`sources.${index}.error`))
|
||||
|
||||
setData(nextData)
|
||||
setErrors(nextErrors)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const lanes = useMemo<DeliveryLane[]>(() => {
|
||||
const statusBlocked = Number(data.statusCleanup?.summary.blocked_gate_count ?? 0)
|
||||
const statusPercent = clampPercent(data.statusCleanup?.summary.overall_completion_percent)
|
||||
const githubRequired = data.github?.summary.approval_required_target_count ?? 0
|
||||
const githubVerified = data.github?.summary.private_backup_verified_count ?? 0
|
||||
const githubBlocked = data.github?.summary.blocked_target_count ?? 0
|
||||
const githubPercent = githubRequired > 0 ? clampPercent((githubVerified / githubRequired) * 100) : 0
|
||||
const giteaPercent = clampPercent(data.gitea?.program_status.overall_completion_percent)
|
||||
const giteaBlocked = data.gitea?.rollups.runner_contracts_requiring_action.length ?? 0
|
||||
const runtimePercent = clampPercent(data.runtime?.program_status.overall_completion_percent)
|
||||
const runtimeBlocked = (data.runtime?.rollups.action_required_surface_ids.length ?? 0) + (data.runtime?.rollups.secret_surface_ids.length ?? 0)
|
||||
const backupPercent = clampPercent(data.backup?.program_status.overall_completion_percent)
|
||||
const backupBlocked = data.backup?.rollups.blocked_row_ids.length ?? 0
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'release',
|
||||
title: t('lanes.release.title'),
|
||||
description: t('lanes.release.description'),
|
||||
percent: statusPercent,
|
||||
status: data.statusCleanup?.summary.dashboard_status ?? t('states.noData'),
|
||||
metric: t('lanes.release.metric', { blocked: statusBlocked }),
|
||||
blockerCount: statusBlocked,
|
||||
href: '/governance?tab=automation-inventory',
|
||||
tone: resolveTone(statusBlocked, statusPercent),
|
||||
Icon: Rocket,
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
title: t('lanes.github.title'),
|
||||
description: t('lanes.github.description'),
|
||||
percent: githubPercent,
|
||||
status: data.github?.status ?? t('states.noData'),
|
||||
metric: t('lanes.github.metric', { verified: githubVerified, total: githubRequired }),
|
||||
blockerCount: githubBlocked,
|
||||
href: '/governance?tab=automation-inventory',
|
||||
tone: resolveTone(githubBlocked, githubPercent),
|
||||
Icon: GitBranch,
|
||||
},
|
||||
{
|
||||
id: 'gitea',
|
||||
title: t('lanes.gitea.title'),
|
||||
description: t('lanes.gitea.description'),
|
||||
percent: giteaPercent,
|
||||
status: data.gitea?.program_status.current_task_id ?? t('states.noData'),
|
||||
metric: t('lanes.gitea.metric', { count: data.gitea?.rollups.total_workflows ?? 0 }),
|
||||
blockerCount: giteaBlocked,
|
||||
href: '/deployments',
|
||||
tone: resolveTone(giteaBlocked, giteaPercent),
|
||||
Icon: PackageCheck,
|
||||
},
|
||||
{
|
||||
id: 'runtime',
|
||||
title: t('lanes.runtime.title'),
|
||||
description: t('lanes.runtime.description'),
|
||||
percent: runtimePercent,
|
||||
status: data.runtime?.program_status.current_task_id ?? t('states.noData'),
|
||||
metric: t('lanes.runtime.metric', { total: data.runtime?.rollups.total_surfaces ?? 0 }),
|
||||
blockerCount: runtimeBlocked,
|
||||
href: '/governance?tab=automation-inventory',
|
||||
tone: resolveTone(runtimeBlocked, runtimePercent),
|
||||
Icon: Server,
|
||||
},
|
||||
{
|
||||
id: 'backup',
|
||||
title: t('lanes.backup.title'),
|
||||
description: t('lanes.backup.description'),
|
||||
percent: backupPercent,
|
||||
status: data.backup?.program_status.current_task_id ?? t('states.noData'),
|
||||
metric: t('lanes.backup.metric', { rows: data.backup?.rollups.total_rows ?? 0 }),
|
||||
blockerCount: backupBlocked,
|
||||
href: '/operations',
|
||||
tone: resolveTone(backupBlocked, backupPercent),
|
||||
Icon: HardDrive,
|
||||
},
|
||||
]
|
||||
}, [data, t])
|
||||
|
||||
const loadedCount = Object.values(data).filter(Boolean).length
|
||||
const highRiskBlockers = lanes.reduce((sum, lane) => sum + lane.blockerCount, 0)
|
||||
const averageCompletion = clampPercent(lanes.reduce((sum, lane) => sum + lane.percent, 0) / Math.max(lanes.length, 1))
|
||||
const pageTone: DeliveryTone = highRiskBlockers > 0 ? 'danger' : loadedCount === lanes.length ? 'ok' : 'warn'
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale}>
|
||||
<div data-testid="delivery-page" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<section className="delivery-hero">
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#d97757', fontSize: 12, fontWeight: 800 }}>
|
||||
<Rocket size={16} aria-hidden="true" />
|
||||
<span>{t('eyebrow')}</span>
|
||||
</div>
|
||||
<h1 style={{ fontSize: 34, lineHeight: 1.1, fontWeight: 900, color: '#141413', margin: '8px 0 8px' }}>
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p style={{ maxWidth: 780, fontSize: 15, lineHeight: 1.65, color: '#595852', margin: 0 }}>
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<StatusPill tone={pageTone} label={loading ? t('states.loading') : errors.length > 0 ? t('states.partial') : t('states.ready')} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
minHeight: 34,
|
||||
borderRadius: 7,
|
||||
border: '0.5px solid #d8d3c5',
|
||||
background: '#fffaf0',
|
||||
color: '#141413',
|
||||
padding: '0 10px',
|
||||
fontSize: 13,
|
||||
fontWeight: 800,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={15} aria-hidden="true" />
|
||||
{t('actions.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="delivery-metrics">
|
||||
<MetricTile label={t('metrics.loaded')} value={`${loadedCount}/5`} detail={t('metrics.loadedDetail')} tone={loadedCount === 5 ? 'ok' : 'warn'} />
|
||||
<MetricTile label={t('metrics.completion')} value={`${averageCompletion}%`} detail={t('metrics.completionDetail')} tone={averageCompletion >= 80 ? 'ok' : 'warn'} />
|
||||
<MetricTile label={t('metrics.blockers')} value={highRiskBlockers} detail={t('metrics.blockersDetail')} tone={highRiskBlockers > 0 ? 'danger' : 'ok'} />
|
||||
<MetricTile label={t('metrics.execution')} value="0" detail={t('metrics.executionDetail')} tone="ok" />
|
||||
</section>
|
||||
|
||||
{errors.length > 0 && (
|
||||
<section className="delivery-alert" data-testid="delivery-partial-errors">
|
||||
<AlertTriangle size={18} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{t('errors.title')}</strong>
|
||||
<p>{errors.join(' · ')}</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<div className="delivery-section-heading">
|
||||
<div>
|
||||
<h2>{t('sections.lanes')}</h2>
|
||||
<p>{t('sections.lanesDetail')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="delivery-lanes">
|
||||
{lanes.map(lane => <LaneCard key={lane.id} lane={lane} locale={params.locale} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="delivery-grid-two">
|
||||
<div>
|
||||
<div className="delivery-section-heading">
|
||||
<div>
|
||||
<h2>{t('sections.next')}</h2>
|
||||
<p>{t('sections.nextDetail')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="delivery-next-list">
|
||||
{lanes
|
||||
.filter(lane => lane.blockerCount > 0 || lane.percent < 80)
|
||||
.slice(0, 5)
|
||||
.map(lane => (
|
||||
<div key={lane.id} className="delivery-next-row">
|
||||
<lane.Icon size={17} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{lane.title}</strong>
|
||||
<span>{lane.metric}</span>
|
||||
</div>
|
||||
<StatusPill tone={lane.tone} label={`${lane.percent}%`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="delivery-section-heading">
|
||||
<div>
|
||||
<h2>{t('sections.boundary')}</h2>
|
||||
<p>{t('sections.boundaryDetail')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="delivery-boundary-list">
|
||||
{['secret', 'production', 'repo', 'data', 'security'].map(key => (
|
||||
<div key={key} className="delivery-boundary-row">
|
||||
<Lock size={16} aria-hidden="true" />
|
||||
<span>{t(`boundaries.${key}`)}</span>
|
||||
<CheckCircle2 size={16} aria-hidden="true" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.delivery-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.delivery-metrics,
|
||||
.delivery-lanes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.delivery-section-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 4px 0 10px;
|
||||
}
|
||||
.delivery-section-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
color: #141413;
|
||||
}
|
||||
.delivery-section-heading p {
|
||||
margin: 4px 0 0;
|
||||
color: #706f68;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.delivery-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
border: 0.5px solid rgba(178, 67, 45, 0.22);
|
||||
background: rgba(178, 67, 45, 0.08);
|
||||
color: #7f2f21;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.delivery-alert p {
|
||||
margin: 3px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.delivery-grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.delivery-next-list,
|
||||
.delivery-boundary-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.delivery-next-row,
|
||||
.delivery-boundary-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 54px;
|
||||
border: 0.5px solid #e0ddd4;
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
color: #45443f;
|
||||
}
|
||||
.delivery-next-row div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.delivery-next-row strong {
|
||||
color: #141413;
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.delivery-next-row span,
|
||||
.delivery-boundary-row span {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.delivery-boundary-row {
|
||||
grid-template-columns: 20px minmax(0, 1fr) 20px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.delivery-hero,
|
||||
.delivery-grid-two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Package,
|
||||
PlayCircle,
|
||||
Radar,
|
||||
Rocket,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
@@ -49,6 +50,7 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [
|
||||
sectionKey: 'workspaces',
|
||||
items: [
|
||||
{ id: 'command-center', href: '/', labelKey: 'commandCenter', Icon: LayoutDashboard },
|
||||
{ id: 'delivery', href: '/delivery', labelKey: 'delivery', Icon: Rocket },
|
||||
{
|
||||
id: 'awooop-overview',
|
||||
href: '/awooop',
|
||||
|
||||
Reference in New Issue
Block a user