feat(governance): add agent market automation surfaces
Some checks failed
Some checks failed
This commit is contained in:
@@ -22,6 +22,8 @@ import { GlassCard } from '@/components/ui/glass-card'
|
||||
import { SloTab } from './tabs/slo-tab'
|
||||
import { EventsTab } from './tabs/events-tab'
|
||||
import { QueueTab } from './tabs/queue-tab'
|
||||
import { AgentMarketTab } from './tabs/agent-market-tab'
|
||||
import { AutomationInventoryTab } from './tabs/automation-inventory-tab'
|
||||
|
||||
export default function GovernancePage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('governance')
|
||||
@@ -30,6 +32,8 @@ export default function GovernancePage({ params }: { params: { locale: string }
|
||||
{ id: 'slo', label: t('tabs.slo'), content: <SloTab /> },
|
||||
{ id: 'events', label: t('tabs.events'), content: <EventsTab /> },
|
||||
{ id: 'queue', label: t('tabs.queue'), content: <QueueTab /> },
|
||||
{ id: 'agent-market', label: t('tabs.agentMarket'), content: <AgentMarketTab /> },
|
||||
{ id: 'automation-inventory', label: t('tabs.automationInventory'), content: <AutomationInventoryTab /> },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
705
apps/web/src/app/[locale]/governance/tabs/agent-market-tab.tsx
Normal file
705
apps/web/src/app/[locale]/governance/tabs/agent-market-tab.tsx
Normal file
@@ -0,0 +1,705 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AgentMarketTab — AI Agent 市場治理 Tab
|
||||
* =====================================
|
||||
* 消費:GET /api/v1/agents/market-governance-snapshot
|
||||
*
|
||||
* 只讀最新 committed governance snapshot;不提供任何批准或執行操作。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertTriangle, Ban, CalendarClock, CheckCircle2, ListChecks, Lock, RefreshCw, ShieldCheck } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { GlassCard } from '@/components/ui/glass-card'
|
||||
import { StatusOrb } from '@/components/ui/status-orb'
|
||||
import { apiClient, type AgentMarketGovernanceSnapshot } from '@/lib/api-client'
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '--'
|
||||
return date.toLocaleString('zh-TW', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Small UI
|
||||
// =============================================================================
|
||||
|
||||
function MetricCard({ label, value, tone = 'neutral' }: { label: string; value: number | string; tone?: 'neutral' | 'ok' | 'warn' }) {
|
||||
const color = tone === 'ok' ? '#22C55E' : tone === 'warn' ? '#F59E0B' : '#141413'
|
||||
return (
|
||||
<GlassCard variant="subtle" padding="md" className="min-w-0">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<span style={{
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 10,
|
||||
color: '#87867f',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{
|
||||
fontFamily: 'Syne, sans-serif',
|
||||
fontSize: 26,
|
||||
fontWeight: 700,
|
||||
color,
|
||||
lineHeight: 1,
|
||||
}}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
function CandidatePill({ value, muted = false }: { value: string; muted?: boolean }) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 22,
|
||||
padding: '3px 7px',
|
||||
borderRadius: 5,
|
||||
border: `0.5px solid ${muted ? '#e0ddd4' : '#d9775740'}`,
|
||||
background: muted ? '#faf9f3' : 'rgba(217,119,87,0.06)',
|
||||
color: muted ? '#87867f' : '#141413',
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 10,
|
||||
lineHeight: 1.3,
|
||||
maxWidth: '100%',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
overflowWrap: 'normal',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CandidateGroup({ title, items, muted = false }: { title: string; items: string[]; muted?: boolean }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: 'Syne, sans-serif',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: '#141413',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.7px',
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
{items.length > 0 ? items.map(item => (
|
||||
<CandidatePill key={item} value={item} muted={muted} />
|
||||
)) : (
|
||||
<CandidatePill value="--" muted />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PolicyGate({ label, approved }: { label: string; approved: number }) {
|
||||
const isApproved = approved > 0
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
padding: '9px 11px',
|
||||
border: '0.5px solid #e0ddd4',
|
||||
borderRadius: 7,
|
||||
background: '#fff',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 11,
|
||||
color: '#141413',
|
||||
lineHeight: 1.4,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
color: isApproved ? '#F59E0B' : '#22C55E',
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{isApproved ? <AlertTriangle size={12} /> : <Lock size={12} />}
|
||||
{approved}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||
<span style={{
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 10,
|
||||
color: '#87867f',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
<div style={{
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 11,
|
||||
color: '#141413',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function AgentMarketTab() {
|
||||
const t = useTranslations('governance.agentMarket')
|
||||
const [snapshot, setSnapshot] = useState<AgentMarketGovernanceSnapshot | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const fetchSnapshot = () => {
|
||||
setLoading(true)
|
||||
apiClient.getAgentMarketGovernanceSnapshot()
|
||||
.then((data: AgentMarketGovernanceSnapshot) => {
|
||||
setSnapshot(data)
|
||||
setError(false)
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSnapshot()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 20, display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 12 }} className="agent-market-kpi-grid">
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<GlassCard key={i} variant="subtle" padding="md">
|
||||
<div style={{ width: 84, height: 10, borderRadius: 4, background: '#e0ddd4', animation: 'pulse 1.5s infinite', marginBottom: 10, animationDelay: `${i * 0.08}s` }} />
|
||||
<div style={{ width: 52, height: 26, borderRadius: 4, background: '#e0ddd4', animation: 'pulse 1.5s infinite' }} />
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !snapshot) {
|
||||
return (
|
||||
<div style={{ padding: 20 }}>
|
||||
<GlassCard variant="subtle" padding="lg">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, padding: '24px 0' }}>
|
||||
<AlertTriangle size={24} style={{ color: '#F59E0B' }} />
|
||||
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 12, color: '#87867f' }}>
|
||||
{t('error')}
|
||||
</span>
|
||||
<button
|
||||
onClick={fetchSnapshot}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 14px',
|
||||
border: '0.5px solid #d97757',
|
||||
borderRadius: 6,
|
||||
background: 'transparent',
|
||||
color: '#d97757',
|
||||
cursor: 'pointer',
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const summary = snapshot.summary
|
||||
const allApprovals =
|
||||
summary.priority_upgrades_approved +
|
||||
summary.market_scorecard_updates_approved +
|
||||
summary.replay_candidates_approved +
|
||||
summary.sdk_installations_approved +
|
||||
summary.paid_api_calls_approved +
|
||||
summary.production_changes_approved +
|
||||
summary.shadow_or_canary_approved +
|
||||
summary.replacement_decisions_approved
|
||||
const watchHealth = snapshot.market_watch_health
|
||||
const watchHealthHealthy = watchHealth.status === 'healthy'
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<div style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
border: '0.5px solid #22C55E40',
|
||||
background: 'rgba(34,197,94,0.08)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<ShieldCheck size={17} style={{ color: '#22C55E' }} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 3 }}>
|
||||
<StatusOrb status={allApprovals === 0 ? 'healthy' : 'warning'} size="sm" glow />
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 15, fontWeight: 700, color: '#141413' }}>
|
||||
{t('title')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 11,
|
||||
color: '#87867f',
|
||||
maxWidth: '100%',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
overflowWrap: 'normal',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{snapshot.current_decision}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f' }}>
|
||||
{t('generatedAt')} {formatDateTime(snapshot.generated_at)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
|
||||
gap: 12,
|
||||
}} className="agent-market-kpi-grid">
|
||||
<MetricCard label={t('metrics.candidates')} value={summary.candidate_count} />
|
||||
<MetricCard label={t('metrics.sources')} value={summary.source_count} />
|
||||
<MetricCard label={t('metrics.blocked')} value={summary.blocked_from_integration} tone="warn" />
|
||||
<MetricCard label={t('metrics.prescreenReady')} value={summary.eligible_for_market_scorecard_prescreen} tone="ok" />
|
||||
</div>
|
||||
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
{watchHealthHealthy ? (
|
||||
<ShieldCheck size={14} style={{ color: '#22C55E' }} />
|
||||
) : (
|
||||
<AlertTriangle size={14} style={{ color: '#F59E0B' }} />
|
||||
)}
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('health.title')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
|
||||
gap: 12,
|
||||
}} className="agent-market-health-grid">
|
||||
<DetailRow label={t('health.status')}>
|
||||
<span style={{ color: watchHealthHealthy ? '#22C55E' : '#F59E0B', fontWeight: 700 }}>
|
||||
{t(`health.statuses.${watchHealth.status}`)}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label={t('health.freshnessSla')}>
|
||||
{t('health.slaValue', {
|
||||
slaHours: watchHealth.freshness_sla_hours,
|
||||
graceHours: watchHealth.stale_grace_hours,
|
||||
})}
|
||||
</DetailRow>
|
||||
<DetailRow label={t('health.staleAfter')}>
|
||||
{formatDateTime(watchHealth.stale_after)}
|
||||
</DetailRow>
|
||||
<DetailRow label={t('health.priorityGate')}>
|
||||
{watchHealth.source_failures_block_priority_upgrade ? t('health.blocked') : t('health.clear')}
|
||||
</DetailRow>
|
||||
<DetailRow label={t('health.blockedIntegrations')}>
|
||||
{watchHealth.blocked_from_integration}
|
||||
</DetailRow>
|
||||
<DetailRow label={t('health.blockers')}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
{watchHealth.operator_blockers.length > 0 ? (
|
||||
watchHealth.operator_blockers.map(blocker => (
|
||||
<CandidatePill key={blocker} value={blocker} muted />
|
||||
))
|
||||
) : (
|
||||
<CandidatePill value={t('health.noBlockers')} />
|
||||
)}
|
||||
</div>
|
||||
</DetailRow>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<CalendarClock size={14} style={{ color: '#d97757' }} />
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('cadence.title')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||
gap: 12,
|
||||
}} className="agent-market-cadence-grid">
|
||||
<DetailRow label={t('cadence.workflow')}>
|
||||
<CandidatePill value={snapshot.evaluation_cadence.workflow} />
|
||||
</DetailRow>
|
||||
<DetailRow label={t('cadence.schedule')}>
|
||||
<CandidatePill value={snapshot.evaluation_cadence.schedule} />
|
||||
</DetailRow>
|
||||
<DetailRow label={t('cadence.nextRun')}>
|
||||
{formatDateTime(snapshot.evaluation_cadence.next_scheduled_run_at)}
|
||||
</DetailRow>
|
||||
<DetailRow label={t('cadence.sourcePolicy')}>
|
||||
<CandidatePill value={snapshot.evaluation_cadence.primary_source_policy} />
|
||||
</DetailRow>
|
||||
<DetailRow label={t('cadence.reviewGate')}>
|
||||
<CandidatePill value={snapshot.evaluation_cadence.operator_review_gate} muted />
|
||||
</DetailRow>
|
||||
<DetailRow label={t('cadence.triggerModes')}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
{snapshot.evaluation_cadence.trigger_modes.map(mode => (
|
||||
<CandidatePill key={mode} value={mode} />
|
||||
))}
|
||||
</div>
|
||||
</DetailRow>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
||||
gap: 18,
|
||||
}} className="agent-market-groups-grid">
|
||||
<CandidateGroup title={t('groups.baseline')} items={snapshot.candidate_groups.production_baseline} />
|
||||
<CandidateGroup title={t('groups.blocked')} items={snapshot.candidate_groups.replay_or_integration_blocked} muted />
|
||||
<CandidateGroup title={t('groups.watchOnly')} items={snapshot.candidate_groups.watch_only_candidates} />
|
||||
<CandidateGroup title={t('groups.prescreenReady')} items={snapshot.candidate_groups.watch_only_scorecard_prescreen_ready} />
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<ListChecks size={14} style={{ color: '#d97757' }} />
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('decisionQueue.title')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
||||
gap: 10,
|
||||
}} className="agent-market-decision-grid">
|
||||
{snapshot.operator_decision_queue.map(item => {
|
||||
const activeBoundaries = Object.entries(item.approval_boundary)
|
||||
.filter(([, required]) => required)
|
||||
.map(([key]) => key)
|
||||
return (
|
||||
<div
|
||||
key={item.candidate_id}
|
||||
style={{
|
||||
minWidth: 0,
|
||||
padding: 12,
|
||||
border: '0.5px solid #e0ddd4',
|
||||
borderRadius: 7,
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 9,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
|
||||
<span style={{
|
||||
fontFamily: 'Syne, sans-serif',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: '#141413',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{item.display_name}
|
||||
</span>
|
||||
<CandidatePill value={item.candidate_id} muted />
|
||||
</div>
|
||||
<span style={{
|
||||
color: item.queue_status === 'baseline_protected' ? '#22C55E' : '#F59E0B',
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{t('decisionQueue.priority')} {item.priority}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
||||
gap: 8,
|
||||
}} className="agent-market-status-detail-grid">
|
||||
<DetailRow label={t('decisionQueue.status')}>
|
||||
<CandidatePill value={t(`decisionQueue.statuses.${item.queue_status}`)} />
|
||||
</DetailRow>
|
||||
<DetailRow label={t('decisionQueue.nextAction')}>
|
||||
<CandidatePill value={item.recommended_action} muted />
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
<DetailRow label={t('decisionQueue.approvalBoundary')}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
{activeBoundaries.length > 0 ? (
|
||||
activeBoundaries.map(key => (
|
||||
<CandidatePill key={key} value={t(`decisionQueue.boundaries.${key}`)} muted />
|
||||
))
|
||||
) : (
|
||||
<CandidatePill value={t('decisionQueue.none')} muted />
|
||||
)}
|
||||
</div>
|
||||
</DetailRow>
|
||||
|
||||
<DetailRow label={t('decisionQueue.riskNotes')}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
{item.risk_notes.length > 0 ? (
|
||||
item.risk_notes.map(note => <CandidatePill key={note} value={note} muted />)
|
||||
) : (
|
||||
<CandidatePill value={t('decisionQueue.none')} muted />
|
||||
)}
|
||||
</div>
|
||||
</DetailRow>
|
||||
|
||||
<DetailRow label={t('decisionQueue.evidence')}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
{item.evidence_refs.length > 0 ? (
|
||||
item.evidence_refs.map(ref => <CandidatePill key={ref} value={ref} />)
|
||||
) : (
|
||||
<CandidatePill value={t('decisionQueue.none')} muted />
|
||||
)}
|
||||
</div>
|
||||
</DetailRow>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<ShieldCheck size={14} style={{ color: '#d97757' }} />
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('matrix.title')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
||||
gap: 10,
|
||||
}} className="agent-market-status-grid">
|
||||
{snapshot.candidate_statuses.map(candidate => {
|
||||
const evidence = [
|
||||
candidate.evidence.latest_smoke_model,
|
||||
candidate.evidence.latest_replay_summary,
|
||||
candidate.evidence.latest_smoke_gate,
|
||||
].filter((item): item is string => Boolean(item))
|
||||
return (
|
||||
<div
|
||||
key={candidate.candidate_id}
|
||||
style={{
|
||||
minWidth: 0,
|
||||
padding: 12,
|
||||
border: '0.5px solid #e0ddd4',
|
||||
borderRadius: 7,
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 9,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
|
||||
<span style={{
|
||||
fontFamily: 'Syne, sans-serif',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: '#141413',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{candidate.display_name}
|
||||
</span>
|
||||
<CandidatePill value={candidate.candidate_id} muted />
|
||||
</div>
|
||||
<span style={{
|
||||
color: candidate.gate_status === 'production_baseline' ? '#22C55E' : '#F59E0B',
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{t(`matrix.gateStatuses.${candidate.gate_status}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
||||
gap: 8,
|
||||
}} className="agent-market-status-detail-grid">
|
||||
<DetailRow label={t('matrix.role')}>
|
||||
<CandidatePill value={candidate.role || t('matrix.none')} />
|
||||
</DetailRow>
|
||||
<DetailRow label={t('matrix.score')}>
|
||||
{candidate.score === null ? t('matrix.noScore') : candidate.score.toFixed(4)}
|
||||
</DetailRow>
|
||||
<DetailRow label={t('matrix.currentGate')}>
|
||||
<CandidatePill value={candidate.current_gate || t('matrix.none')} />
|
||||
</DetailRow>
|
||||
<DetailRow label={t('matrix.nextGate')}>
|
||||
<CandidatePill value={candidate.required_next_gate || t('matrix.none')} muted />
|
||||
</DetailRow>
|
||||
<DetailRow label={t('matrix.runtimeApprovals')}>
|
||||
{t('matrix.noRuntimeApprovals')}
|
||||
</DetailRow>
|
||||
<DetailRow label={t('matrix.blockers')}>
|
||||
{candidate.operator_blockers.length}
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
<DetailRow label={t('matrix.evidence')}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
{evidence.length > 0 ? (
|
||||
evidence.map(item => <CandidatePill key={item} value={item} />)
|
||||
) : (
|
||||
<CandidatePill value={t('matrix.noEvidence')} muted />
|
||||
)}
|
||||
</div>
|
||||
</DetailRow>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
|
||||
gap: 12,
|
||||
}} className="agent-market-policy-grid">
|
||||
<GlassCard variant="subtle" padding="md" className="min-w-0">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<Ban size={14} style={{ color: '#d97757' }} />
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('policy.title')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 7 }}>
|
||||
<PolicyGate label={t('policy.replacement')} approved={summary.replacement_decisions_approved} />
|
||||
<PolicyGate label={t('policy.replay')} approved={summary.replay_candidates_approved} />
|
||||
<PolicyGate label={t('policy.sdk')} approved={summary.sdk_installations_approved} />
|
||||
<PolicyGate label={t('policy.paidApi')} approved={summary.paid_api_calls_approved} />
|
||||
<PolicyGate label={t('policy.production')} approved={summary.production_changes_approved} />
|
||||
<PolicyGate label={t('policy.shadowCanary')} approved={summary.shadow_or_canary_approved} />
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard variant="subtle" padding="md" className="min-w-0">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 8 }}>
|
||||
<CheckCircle2 size={14} style={{ color: '#22C55E' }} />
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('allowed.title')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{snapshot.next_allowed_actions.map(action => (
|
||||
<CandidatePill key={action} value={action} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 8 }}>
|
||||
<Lock size={14} style={{ color: '#F59E0B' }} />
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('forbidden.title')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{snapshot.forbidden_actions_without_new_approval.map(action => (
|
||||
<CandidatePill key={action} value={action} muted />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@media (max-width: 900px) {
|
||||
.agent-market-kpi-grid,
|
||||
.agent-market-health-grid,
|
||||
.agent-market-cadence-grid,
|
||||
.agent-market-decision-grid,
|
||||
.agent-market-status-grid,
|
||||
.agent-market-status-detail-grid,
|
||||
.agent-market-policy-grid,
|
||||
.agent-market-groups-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AutomationInventoryTab — AI Agent 自動化盤點 Tab
|
||||
* =================================================
|
||||
* 消費:GET /api/v1/agents/automation-inventory-snapshot
|
||||
*
|
||||
* 只讀最新 committed snapshot;不提供批准、執行、回滾或 provider 切換操作。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { AlertTriangle, Boxes, Database, Lock, PackageCheck, RefreshCw, Server, ShieldCheck } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { GlassCard } from '@/components/ui/glass-card'
|
||||
import { StatusOrb } from '@/components/ui/status-orb'
|
||||
import {
|
||||
apiClient,
|
||||
type AiAgentAutomationBacklogSnapshot,
|
||||
type AiAgentAutomationInventorySnapshot,
|
||||
} from '@/lib/api-client'
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '--'
|
||||
return date.toLocaleString('zh-TW', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function toneColor(tone: 'ok' | 'warn' | 'danger' | 'neutral') {
|
||||
if (tone === 'ok') return '#22C55E'
|
||||
if (tone === 'warn') return '#F59E0B'
|
||||
if (tone === 'danger') return '#EF4444'
|
||||
return '#141413'
|
||||
}
|
||||
|
||||
function SmallLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span style={{
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 10,
|
||||
color: '#87867f',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ value, muted = false }: { value: string; muted?: boolean }) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 22,
|
||||
padding: '3px 7px',
|
||||
borderRadius: 5,
|
||||
border: `0.5px solid ${muted ? '#e0ddd4' : '#d9775740'}`,
|
||||
background: muted ? '#faf9f3' : 'rgba(217,119,87,0.06)',
|
||||
color: muted ? '#87867f' : '#141413',
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 10,
|
||||
lineHeight: 1.3,
|
||||
maxWidth: '100%',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
tone = 'neutral',
|
||||
icon,
|
||||
}: {
|
||||
label: string
|
||||
value: number | string
|
||||
tone?: 'ok' | 'warn' | 'danger' | 'neutral'
|
||||
icon: ReactNode
|
||||
}) {
|
||||
const color = toneColor(tone)
|
||||
return (
|
||||
<GlassCard variant="subtle" padding="md" className="min-w-0">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<div style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
border: `0.5px solid ${color}40`,
|
||||
background: `${color}12`,
|
||||
color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{icon}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, minWidth: 0 }}>
|
||||
<SmallLabel>{label}</SmallLabel>
|
||||
<span style={{
|
||||
fontFamily: 'Syne, sans-serif',
|
||||
fontSize: 25,
|
||||
fontWeight: 700,
|
||||
color,
|
||||
lineHeight: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressRow({ label, percent, nextTask }: { label: string; percent: number; nextTask: string }) {
|
||||
const color = percent >= 70 ? '#22C55E' : percent >= 35 ? '#F59E0B' : '#d97757'
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) 48px',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, minWidth: 0 }}>
|
||||
<span style={{
|
||||
fontFamily: 'Syne, sans-serif',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: '#141413',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 10,
|
||||
color: '#87867f',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{nextTask}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 6, borderRadius: 999, background: '#e0ddd4', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${percent}%`, height: '100%', borderRadius: 999, background: color }} />
|
||||
</div>
|
||||
</div>
|
||||
<span style={{
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color,
|
||||
textAlign: 'right',
|
||||
}}>
|
||||
{percent}%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutomationInventoryTab() {
|
||||
const t = useTranslations('governance.automationInventory')
|
||||
const [snapshot, setSnapshot] = useState<AiAgentAutomationInventorySnapshot | null>(null)
|
||||
const [backlog, setBacklog] = useState<AiAgentAutomationBacklogSnapshot | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const fetchSnapshot = () => {
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
apiClient.getAiAgentAutomationInventorySnapshot(),
|
||||
apiClient.getAiAgentAutomationBacklogSnapshot(),
|
||||
])
|
||||
.then(([inventoryData, backlogData]) => {
|
||||
setSnapshot(inventoryData)
|
||||
setBacklog(backlogData)
|
||||
setError(false)
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSnapshot()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const groupedAssets = useMemo(() => {
|
||||
const groups = new Map<string, AiAgentAutomationInventorySnapshot['assets']>()
|
||||
if (!snapshot) return []
|
||||
for (const asset of snapshot.assets) {
|
||||
const current = groups.get(asset.domain_id) ?? []
|
||||
current.push(asset)
|
||||
groups.set(asset.domain_id, current)
|
||||
}
|
||||
return snapshot.asset_domains.map(domain => ({
|
||||
...domain,
|
||||
assets: groups.get(domain.domain_id) ?? [],
|
||||
})).filter(group => group.assets.length > 0)
|
||||
}, [snapshot])
|
||||
|
||||
const groupedBacklog = useMemo(() => {
|
||||
if (!backlog) return []
|
||||
return (['P1', 'P2', 'P3', 'P0'] as const)
|
||||
.map(priority => ({
|
||||
priority,
|
||||
items: backlog.backlog_items.filter(item => item.priority === priority),
|
||||
}))
|
||||
.filter(group => group.items.length > 0)
|
||||
}, [backlog])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 20, display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 12 }} className="automation-inventory-kpi-grid">
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<GlassCard key={i} variant="subtle" padding="md">
|
||||
<div style={{ width: 90, height: 10, borderRadius: 4, background: '#e0ddd4', animation: 'pulse 1.5s infinite', marginBottom: 10, animationDelay: `${i * 0.08}s` }} />
|
||||
<div style={{ width: 54, height: 26, borderRadius: 4, background: '#e0ddd4', animation: 'pulse 1.5s infinite' }} />
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !snapshot || !backlog) {
|
||||
return (
|
||||
<div style={{ padding: 20 }}>
|
||||
<GlassCard variant="subtle" padding="lg">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, padding: '24px 0' }}>
|
||||
<AlertTriangle size={24} style={{ color: '#F59E0B' }} />
|
||||
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 12, color: '#87867f' }}>
|
||||
{t('error')}
|
||||
</span>
|
||||
<button
|
||||
onClick={fetchSnapshot}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 14px',
|
||||
border: '0.5px solid #d97757',
|
||||
borderRadius: 6,
|
||||
background: 'transparent',
|
||||
color: '#d97757',
|
||||
cursor: 'pointer',
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const blockedAssets = snapshot.assets.filter(asset => asset.status === 'blocked').length
|
||||
const criticalAssets = snapshot.assets.filter(asset => asset.risk_level === 'critical').length
|
||||
const completedTasks = snapshot.tasks.filter(task => task.status === 'done').length
|
||||
const p1BacklogCount = backlog.rollups.by_priority.P1 ?? 0
|
||||
const blockedApprovals = Object.entries(snapshot.approval_boundaries)
|
||||
.filter(([, allowed]) => allowed === false)
|
||||
.map(([key]) => key)
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<div style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
border: '0.5px solid #22C55E40',
|
||||
background: 'rgba(34,197,94,0.08)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<ShieldCheck size={17} style={{ color: '#22C55E' }} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 3 }}>
|
||||
<StatusOrb status="healthy" size="sm" glow />
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 15, fontWeight: 700, color: '#141413' }}>
|
||||
{t('title')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 11,
|
||||
color: '#87867f',
|
||||
maxWidth: '100%',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{t('readOnly')} · {snapshot.program_status.current_task_id} → {snapshot.program_status.next_task_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f' }}>
|
||||
{t('generatedAt')} {formatDateTime(snapshot.generated_at)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
|
||||
gap: 12,
|
||||
}} className="automation-inventory-kpi-grid">
|
||||
<MetricCard label={t('metrics.progress')} value={`${snapshot.program_status.overall_completion_percent}%`} tone="ok" icon={<ShieldCheck size={16} />} />
|
||||
<MetricCard label={t('metrics.assets')} value={snapshot.assets.length} icon={<Boxes size={16} />} />
|
||||
<MetricCard label={t('metrics.backlog')} value={backlog.rollups.total_items} tone="warn" icon={<PackageCheck size={16} />} />
|
||||
<MetricCard label={t('metrics.p1Backlog')} value={p1BacklogCount} icon={<Boxes size={16} />} />
|
||||
<MetricCard label={t('metrics.blocked')} value={blockedAssets} tone={blockedAssets > 0 ? 'warn' : 'ok'} icon={<AlertTriangle size={16} />} />
|
||||
<MetricCard label={t('metrics.critical')} value={criticalAssets} tone="danger" icon={<Lock size={16} />} />
|
||||
</div>
|
||||
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<Server size={14} style={{ color: '#d97757' }} />
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('workstreams.title')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12 }} className="automation-inventory-workstream-grid">
|
||||
{snapshot.workstreams.map(workstream => (
|
||||
<div key={workstream.workstream_id} style={{ padding: 11, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', minWidth: 0 }}>
|
||||
<ProgressRow
|
||||
label={workstream.display_name}
|
||||
percent={workstream.completion_percent}
|
||||
nextTask={workstream.next_task_id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<PackageCheck size={14} style={{ color: '#d97757' }} />
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('backlog.title', { total: backlog.rollups.total_items })}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12 }} className="automation-inventory-backlog-grid">
|
||||
{groupedBacklog.map(group => (
|
||||
<div key={group.priority} style={{ padding: 12, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{group.priority}
|
||||
</span>
|
||||
<Chip value={String(group.items.length)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 9, minWidth: 0 }}>
|
||||
{group.items.slice(0, 5).map(item => (
|
||||
<div key={item.item_id} style={{ display: 'flex', flexDirection: 'column', gap: 7, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, minWidth: 0 }}>
|
||||
<span style={{
|
||||
fontFamily: 'Syne, sans-serif',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: '#141413',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{item.title}
|
||||
</span>
|
||||
<Chip value={item.item_id} muted />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
<Chip value={item.owner_agent} />
|
||||
<Chip value={item.gate_status} muted />
|
||||
<Chip value={item.next_review} muted />
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 10,
|
||||
color: '#87867f',
|
||||
lineHeight: 1.45,
|
||||
overflowWrap: 'anywhere',
|
||||
}}>
|
||||
{item.acceptance_criteria[0]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{group.items.length > 5 ? (
|
||||
<Chip value={t('backlog.more', { count: group.items.length - 5 })} muted />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<Database size={14} style={{ color: '#d97757' }} />
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('assets.title')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12 }} className="automation-inventory-domain-grid">
|
||||
{groupedAssets.map(group => (
|
||||
<div key={group.domain_id} style={{ padding: 12, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{group.display_name}
|
||||
</span>
|
||||
<Chip value={String(group.assets.length)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
{group.assets.map(asset => (
|
||||
<Chip
|
||||
key={asset.asset_id}
|
||||
value={`${asset.display_name} · ${asset.gate_status}`}
|
||||
muted={asset.status !== 'blocked'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.2fr) minmax(0, 0.8fr)', gap: 12 }} className="automation-inventory-bottom-grid">
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<PackageCheck size={14} style={{ color: '#d97757' }} />
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('tasks.title', { done: completedTasks, total: snapshot.tasks.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 10 }} className="automation-inventory-task-grid">
|
||||
{snapshot.tasks.map(task => (
|
||||
<div key={task.task_id} style={{ padding: 11, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 11, fontWeight: 700, color: '#141413' }}>
|
||||
{task.task_id}
|
||||
</span>
|
||||
<Chip value={t(`tasks.statuses.${task.status}`)} muted={task.status !== 'done'} />
|
||||
</div>
|
||||
<span style={{
|
||||
fontFamily: 'Syne, sans-serif',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: '#141413',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{task.title}
|
||||
</span>
|
||||
<ProgressRow label={task.owner_agent} percent={task.completion_percent} nextTask={task.gate_status} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<Lock size={14} style={{ color: '#d97757' }} />
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('boundaries.title')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
{blockedApprovals.map(key => (
|
||||
<Chip key={key} value={t(`boundaries.items.${key}`)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
@media (max-width: 900px) {
|
||||
.automation-inventory-kpi-grid,
|
||||
.automation-inventory-workstream-grid,
|
||||
.automation-inventory-domain-grid,
|
||||
.automation-inventory-backlog-grid,
|
||||
.automation-inventory-bottom-grid,
|
||||
.automation-inventory-task-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user