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>
|
||||
)
|
||||
}
|
||||
@@ -112,7 +112,7 @@ export function FlywheelKPICard() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fmt = (n: number | undefined, digits = 0) =>
|
||||
const fmt = (n: number | null | undefined, digits = 0) =>
|
||||
n == null ? '--' : n.toLocaleString(undefined, { maximumFractionDigits: digits })
|
||||
|
||||
const pct = (n: number | null | undefined) =>
|
||||
|
||||
@@ -81,7 +81,7 @@ function HostCard({ host }: { host: HostInfo }) {
|
||||
<div style={{ width: 6, height: 6, borderRadius: '50%', background: accentColor, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>
|
||||
{/* 顯示末段 IP 作為簡短標識,完整名稱放 IP 欄位 */}
|
||||
{host.ip.split('.').pop() ?? host.hostname}
|
||||
{host.ip.includes('.') ? host.ip.split('.').pop() ?? host.ip : host.hostname}
|
||||
</span>
|
||||
{isK3s && (
|
||||
<span style={{
|
||||
@@ -92,8 +92,8 @@ function HostCard({ host }: { host: HostInfo }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: 10, color: '#b0ad9f', fontFamily: 'monospace' }}>{host.ip}</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 10, color: '#b0ad9f', fontFamily: 'monospace' }}>{host.ip}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 2 }}>
|
||||
<span style={{ fontSize: 10, color: '#b0ad9f' }}>CPU <MetricBar value={host.cpuPct} /></span>
|
||||
<span style={{ fontSize: 10, color: '#b0ad9f' }}>RAM <MetricBar value={host.ramPct} /></span>
|
||||
@@ -117,6 +117,9 @@ function HostCard({ host }: { host: HostInfo }) {
|
||||
)
|
||||
}
|
||||
|
||||
const K8S_VIP_INFO_FALLBACK =
|
||||
'K8S VIP topology (ops-only) · kubectl:6443 · web:32335 · api:32334'
|
||||
|
||||
export function HostGrid({ hosts }: HostGridProps) {
|
||||
if (hosts.length === 0) {
|
||||
return <div style={{ padding: 16, textAlign: 'center', color: '#b0ad9f', fontSize: 12 }}>--</div>
|
||||
@@ -150,7 +153,7 @@ export function HostGrid({ hosts }: HostGridProps) {
|
||||
☸ K3S CLUSTER (HA)
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: '#3b7de8' }}>
|
||||
{process.env.NEXT_PUBLIC_K8S_VIP_INFO ?? 'VIP 192.168.0.125 · kubectl :6443 · Web :32335 · API :32334'}
|
||||
{(process.env.NEXT_PUBLIC_K8S_VIP_INFO ?? '').trim() || K8S_VIP_INFO_FALLBACK}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, padding: 6 }}>
|
||||
|
||||
@@ -246,6 +246,21 @@ export const apiClient = {
|
||||
const res = await fetch(`${API_BASE_URL}/errors/ux-audit`)
|
||||
return handleResponse<UXAuditResponse>(res)
|
||||
},
|
||||
|
||||
async getAgentMarketGovernanceSnapshot() {
|
||||
const res = await fetch(`${API_BASE_URL}/agents/market-governance-snapshot`)
|
||||
return handleResponse<AgentMarketGovernanceSnapshot>(res)
|
||||
},
|
||||
|
||||
async getAiAgentAutomationInventorySnapshot() {
|
||||
const res = await fetch(`${API_BASE_URL}/agents/automation-inventory-snapshot`)
|
||||
return handleResponse<AiAgentAutomationInventorySnapshot>(res)
|
||||
},
|
||||
|
||||
async getAiAgentAutomationBacklogSnapshot() {
|
||||
const res = await fetch(`${API_BASE_URL}/agents/automation-backlog-snapshot`)
|
||||
return handleResponse<AiAgentAutomationBacklogSnapshot>(res)
|
||||
},
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -470,3 +485,239 @@ export interface UXAuditResponse {
|
||||
details: UXAuditDetail[]
|
||||
replay_dashboard_url: string
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Agent Market Governance Snapshot
|
||||
// =========================================================================
|
||||
|
||||
export interface AgentMarketGovernanceSnapshot {
|
||||
schema_version: 'agent_market_governance_snapshot_v1'
|
||||
generated_at: string
|
||||
current_decision: string
|
||||
policy: Record<string, boolean>
|
||||
evaluation_cadence: {
|
||||
workflow: string
|
||||
schedule: string
|
||||
timezone: 'Asia/Taipei'
|
||||
next_scheduled_run_at: string
|
||||
trigger_modes: string[]
|
||||
primary_source_policy: string
|
||||
operator_review_gate: string
|
||||
}
|
||||
market_watch_health: {
|
||||
status: 'healthy' | 'blocked'
|
||||
freshness_sla_hours: 168
|
||||
stale_grace_hours: 6
|
||||
stale_after: string
|
||||
source_failures_block_priority_upgrade: boolean
|
||||
blocked_from_integration: number
|
||||
operator_blockers: string[]
|
||||
}
|
||||
summary: {
|
||||
candidate_count: number
|
||||
source_count: number
|
||||
source_failures: number
|
||||
changed_candidates: number
|
||||
integration_queue_count: number
|
||||
blocked_from_integration: number
|
||||
watch_only_candidates_reviewed: number
|
||||
eligible_for_market_scorecard_prescreen: number
|
||||
recommended_watch_additions_remaining: number
|
||||
priority_upgrades_approved: number
|
||||
market_scorecard_updates_approved: number
|
||||
replay_candidates_approved: number
|
||||
sdk_installations_approved: number
|
||||
paid_api_calls_approved: number
|
||||
production_changes_approved: number
|
||||
shadow_or_canary_approved: number
|
||||
replacement_decisions_approved: number
|
||||
}
|
||||
candidate_groups: {
|
||||
production_baseline: string[]
|
||||
replay_or_integration_blocked: string[]
|
||||
watch_only_candidates: string[]
|
||||
watch_only_scorecard_prescreen_ready: string[]
|
||||
}
|
||||
candidate_statuses: Array<{
|
||||
candidate_id: string
|
||||
display_name: string
|
||||
role: string
|
||||
evaluation_priority: string
|
||||
gate_status:
|
||||
| 'production_baseline'
|
||||
| 'integration_blocked'
|
||||
| 'integration_reviewed'
|
||||
| 'watch_only_prescreen_ready'
|
||||
| 'watch_only_blocked'
|
||||
| 'watch_only_monitoring'
|
||||
| 'registered_no_review'
|
||||
current_gate: string
|
||||
required_next_gate: string
|
||||
integration_decision: string
|
||||
score: number | null
|
||||
evidence: {
|
||||
latest_replay_summary: string | null
|
||||
latest_smoke_gate: string | null
|
||||
latest_smoke_matrix: string | null
|
||||
latest_smoke_model: string | null
|
||||
}
|
||||
approvals: {
|
||||
replay: false
|
||||
sdk_install: false
|
||||
paid_api: false
|
||||
shadow_or_canary: false
|
||||
production_routing: false
|
||||
}
|
||||
operator_blockers: string[]
|
||||
}>
|
||||
operator_decision_queue: Array<{
|
||||
candidate_id: string
|
||||
display_name: string
|
||||
priority: number
|
||||
queue_status:
|
||||
| 'baseline_protected'
|
||||
| 'blocked_needs_evidence'
|
||||
| 'operator_review_required'
|
||||
| 'operator_priority_review'
|
||||
| 'watch_only_blocked'
|
||||
| 'watch_only_monitoring'
|
||||
| 'registered_no_review'
|
||||
recommended_action: string
|
||||
approval_boundary: {
|
||||
replacement_adr_required: boolean
|
||||
priority_upgrade_required: boolean
|
||||
market_scorecard_update_required: boolean
|
||||
replay_approval_required: boolean
|
||||
sdk_install_approval_required: boolean
|
||||
paid_api_approval_required: boolean
|
||||
shadow_or_canary_approval_required: boolean
|
||||
production_routing_approval_required: boolean
|
||||
}
|
||||
risk_notes: string[]
|
||||
evidence_refs: string[]
|
||||
}>
|
||||
next_allowed_actions: string[]
|
||||
forbidden_actions_without_new_approval: string[]
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// AI Agent Automation Inventory Snapshot
|
||||
// =========================================================================
|
||||
|
||||
export interface AiAgentAutomationInventorySnapshot {
|
||||
schema_version: 'ai_agent_automation_inventory_snapshot_v1'
|
||||
generated_at: string
|
||||
program_status: {
|
||||
overall_completion_percent: number
|
||||
current_priority: 'P0' | 'P1' | 'P2' | 'P3'
|
||||
current_task_id: string
|
||||
next_task_id: string
|
||||
read_only_mode: true
|
||||
}
|
||||
status_taxonomy: {
|
||||
task_statuses: string[]
|
||||
gate_statuses: string[]
|
||||
priorities: Array<'P0' | 'P1' | 'P2' | 'P3'>
|
||||
}
|
||||
agent_roles: Array<{
|
||||
agent_id: string
|
||||
display_name: string
|
||||
primary_role: string
|
||||
allowed_actions: string[]
|
||||
blocked_actions: string[]
|
||||
}>
|
||||
asset_domains: Array<{
|
||||
domain_id: string
|
||||
display_name: string
|
||||
description: string
|
||||
}>
|
||||
assets: Array<{
|
||||
asset_id: string
|
||||
domain_id: string
|
||||
display_name: string
|
||||
asset_type: string
|
||||
status: string
|
||||
gate_status: string
|
||||
owner_agent: string
|
||||
risk_level: 'low' | 'medium' | 'high' | 'critical'
|
||||
evidence_refs: string[]
|
||||
next_action: string
|
||||
}>
|
||||
workstreams: Array<{
|
||||
workstream_id: string
|
||||
display_name: string
|
||||
completion_percent: number
|
||||
status: string
|
||||
next_task_id: string
|
||||
}>
|
||||
tasks: Array<{
|
||||
task_id: string
|
||||
priority: 'P0' | 'P1' | 'P2' | 'P3'
|
||||
status: string
|
||||
completion_percent: number
|
||||
owner_agent: string
|
||||
title: string
|
||||
output: string
|
||||
gate_status: string
|
||||
next_action: string
|
||||
}>
|
||||
evidence: Array<{
|
||||
evidence_id: string
|
||||
kind: 'schema' | 'test' | 'browser' | 'api' | 'build' | 'doc' | 'runtime'
|
||||
ref: string
|
||||
result: string
|
||||
}>
|
||||
approval_boundaries: Record<
|
||||
| 'sdk_installation_allowed'
|
||||
| 'paid_api_call_allowed'
|
||||
| 'shadow_or_canary_allowed'
|
||||
| 'production_routing_allowed'
|
||||
| 'destructive_operation_allowed',
|
||||
false
|
||||
>
|
||||
}
|
||||
|
||||
export interface AiAgentAutomationBacklogSnapshot {
|
||||
schema_version: 'ai_agent_automation_backlog_v1'
|
||||
generated_at: string
|
||||
source_inventory_snapshot_ref: string
|
||||
program_status: {
|
||||
overall_completion_percent: number
|
||||
current_priority: 'P0' | 'P1' | 'P2' | 'P3'
|
||||
current_task_id: string
|
||||
next_task_id: string
|
||||
read_only_mode: true
|
||||
}
|
||||
rollups: {
|
||||
total_items: number
|
||||
by_priority: Record<string, number>
|
||||
by_status: Record<string, number>
|
||||
by_gate_status: Record<string, number>
|
||||
by_owner_agent: Record<string, number>
|
||||
}
|
||||
backlog_items: Array<{
|
||||
item_id: string
|
||||
priority: 'P0' | 'P1' | 'P2' | 'P3'
|
||||
status: string
|
||||
workstream_id: string
|
||||
source_asset_id: string
|
||||
source_signal_kind: string
|
||||
title: string
|
||||
owner_agent: string
|
||||
recommended_action: string
|
||||
action_class: string
|
||||
gate_status: string
|
||||
risk_level: 'low' | 'medium' | 'high' | 'critical'
|
||||
evidence_refs: string[]
|
||||
acceptance_criteria: string[]
|
||||
next_review: string
|
||||
}>
|
||||
approval_boundaries: Record<
|
||||
| 'sdk_installation_allowed'
|
||||
| 'paid_api_call_allowed'
|
||||
| 'shadow_or_canary_allowed'
|
||||
| 'production_routing_allowed'
|
||||
| 'destructive_operation_allowed',
|
||||
false
|
||||
>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user