feat(web): add delivery closure workbench

This commit is contained in:
ogt
2026-06-26 18:27:51 +08:00
parent 4b0514def5
commit 69f5eb6f52
4 changed files with 711 additions and 0 deletions

View File

@@ -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": "主機、專案、網站、服務與工具全域監控",

View File

@@ -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": "主機、專案、網站、服務與工具全域監控",

View 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>
)
}

View File

@@ -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',