feat(governance): 新增 AI Provider 路由矩陣
This commit is contained in:
@@ -33,6 +33,7 @@ import { GlassCard } from '@/components/ui/glass-card'
|
||||
import { StatusOrb } from '@/components/ui/status-orb'
|
||||
import {
|
||||
apiClient,
|
||||
type AiProviderRouteMatrixSnapshot,
|
||||
type AiAgentAutomationBacklogSnapshot,
|
||||
type AiAgentAutomationInventorySnapshot,
|
||||
type BackupDrReadinessMatrixSnapshot,
|
||||
@@ -305,6 +306,7 @@ export function AutomationInventoryTab() {
|
||||
const [runtimeSurface, setRuntimeSurface] = useState<RuntimeSurfaceInventorySnapshot | null>(null)
|
||||
const [giteaHealth, setGiteaHealth] = useState<GiteaWorkflowRunnerHealthSnapshot | null>(null)
|
||||
const [observabilityMatrix, setObservabilityMatrix] = useState<ObservabilityContractMatrixSnapshot | null>(null)
|
||||
const [providerRouteMatrix, setProviderRouteMatrix] = useState<AiProviderRouteMatrixSnapshot | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
@@ -320,6 +322,7 @@ export function AutomationInventoryTab() {
|
||||
apiClient.getRuntimeSurfaceInventory(),
|
||||
apiClient.getGiteaWorkflowRunnerHealth(),
|
||||
apiClient.getObservabilityContractMatrix(),
|
||||
apiClient.getAiProviderRouteMatrix(),
|
||||
] as const
|
||||
|
||||
Promise.allSettled(requests)
|
||||
@@ -334,6 +337,7 @@ export function AutomationInventoryTab() {
|
||||
runtimeSurfaceResult,
|
||||
giteaHealthResult,
|
||||
observabilityMatrixResult,
|
||||
providerRouteMatrixResult,
|
||||
] = results
|
||||
|
||||
setSnapshot(inventoryResult.status === 'fulfilled' ? inventoryResult.value : null)
|
||||
@@ -345,6 +349,7 @@ export function AutomationInventoryTab() {
|
||||
setRuntimeSurface(runtimeSurfaceResult.status === 'fulfilled' ? runtimeSurfaceResult.value : null)
|
||||
setGiteaHealth(giteaHealthResult.status === 'fulfilled' ? giteaHealthResult.value : null)
|
||||
setObservabilityMatrix(observabilityMatrixResult.status === 'fulfilled' ? observabilityMatrixResult.value : null)
|
||||
setProviderRouteMatrix(providerRouteMatrixResult.status === 'fulfilled' ? providerRouteMatrixResult.value : null)
|
||||
setError([
|
||||
inventoryResult,
|
||||
backlogResult,
|
||||
@@ -354,6 +359,7 @@ export function AutomationInventoryTab() {
|
||||
offsiteEscrowResult,
|
||||
giteaHealthResult,
|
||||
observabilityMatrixResult,
|
||||
providerRouteMatrixResult,
|
||||
].some(result => result.status === 'rejected'))
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
@@ -489,6 +495,17 @@ export function AutomationInventoryTab() {
|
||||
})
|
||||
}, [observabilityMatrix])
|
||||
|
||||
const visibleProviderRoutes = useMemo(() => {
|
||||
if (!providerRouteMatrix) return []
|
||||
const priority = { action_required: 0, blocked: 1, verified: 2 } as Record<string, number>
|
||||
return [...providerRouteMatrix.provider_routes].sort((a, b) => {
|
||||
const left = priority[a.status] ?? 3
|
||||
const right = priority[b.status] ?? 3
|
||||
if (left !== right) return left - right
|
||||
return a.route_id.localeCompare(b.route_id)
|
||||
})
|
||||
}, [providerRouteMatrix])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 20, display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 12 }} className="automation-inventory-kpi-grid">
|
||||
@@ -502,7 +519,7 @@ export function AutomationInventoryTab() {
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !snapshot || !backlog || !backupTargets || !backupReadiness || !backupPolicy || !offsiteEscrow || !giteaHealth || !observabilityMatrix) {
|
||||
if (error || !snapshot || !backlog || !backupTargets || !backupReadiness || !backupPolicy || !offsiteEscrow || !giteaHealth || !observabilityMatrix || !providerRouteMatrix) {
|
||||
return (
|
||||
<div style={{ padding: 20 }}>
|
||||
<GlassCard variant="subtle" padding="lg">
|
||||
@@ -559,6 +576,16 @@ export function AutomationInventoryTab() {
|
||||
const observabilityProposalCount = observabilityMatrix.rollups.noise_reduction_opportunities_total
|
||||
const observabilityClassificationGaps = observabilityMatrix.rollups.classification_gap_ids.length
|
||||
const observabilityApprovalRequired = observabilityMatrix.rollups.approval_required_opportunity_ids.length
|
||||
const providerRouteActions = providerRouteMatrix.rollups.route_ids_requiring_action.length
|
||||
const providerRouteGateApprovals = providerRouteMatrix.rollups.candidate_gate_ids_requiring_approval.length
|
||||
const providerSourceGaps = providerRouteMatrix.rollups.source_gap_ids.length
|
||||
const providerBlockedRoutes = providerRouteMatrix.rollups.by_status.blocked ?? 0
|
||||
const providerRouteDeniedCount = (
|
||||
providerRouteMatrix.rollups.provider_switch_allowed_count
|
||||
+ providerRouteMatrix.rollups.paid_api_call_allowed_count
|
||||
+ providerRouteMatrix.rollups.shadow_or_canary_allowed_count
|
||||
+ providerRouteMatrix.rollups.runtime_route_change_allowed_count
|
||||
)
|
||||
const backlogProgressPercent = backlog.progress_summary.overall_percent
|
||||
const explicitApprovalItemCount = backlog.item_approval_boundary_rollup.items_requiring_explicit_approval.length
|
||||
const taskBoundaryCount = snapshot.task_approval_boundary_rollup.total_tasks
|
||||
@@ -683,6 +710,14 @@ export function AutomationInventoryTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const providerRouteValueLabel = (value: string) => {
|
||||
try {
|
||||
return t(`providerRoute.values.${value}` as never)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="automation-inventory-tab-root" style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 16, minWidth: 0 }}>
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
@@ -1653,6 +1688,172 @@ export function AutomationInventoryTab() {
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
|
||||
<Route size={14} style={{ color: '#d97757' }} />
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('providerRoute.title')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f' }}>
|
||||
{t('providerRoute.source', {
|
||||
generated: formatDateTime(providerRouteMatrix.generated_at),
|
||||
current: providerRouteMatrix.program_status.current_task_id,
|
||||
next: providerRouteMatrix.program_status.next_task_id,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 12 }} className="automation-inventory-provider-kpi-grid">
|
||||
<MetricCard label={t('providerRoute.metrics.routes')} value={providerRouteMatrix.rollups.total_routes} icon={<Route size={16} />} />
|
||||
<MetricCard label={t('providerRoute.metrics.actions')} value={providerRouteActions} tone={providerRouteActions > 0 ? 'warn' : 'ok'} icon={<AlertTriangle size={16} />} />
|
||||
<MetricCard label={t('providerRoute.metrics.gates')} value={providerRouteGateApprovals} tone="warn" icon={<Lock size={16} />} />
|
||||
<MetricCard label={t('providerRoute.metrics.blocked')} value={providerBlockedRoutes} tone={providerBlockedRoutes > 0 ? 'danger' : 'ok'} icon={<ShieldCheck size={16} />} />
|
||||
<MetricCard label={t('providerRoute.metrics.denied')} value={providerRouteDeniedCount} tone="ok" icon={<BellOff size={16} />} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 10 }} className="automation-inventory-provider-map-grid">
|
||||
<SummaryTile
|
||||
label={t('providerRoute.map.ollama')}
|
||||
value="3"
|
||||
detail={t('providerRoute.map.ollamaDetail')}
|
||||
tone="ok"
|
||||
icon={<GitBranch size={16} />}
|
||||
/>
|
||||
<SummaryTile
|
||||
label={t('providerRoute.map.candidate')}
|
||||
value={`${providerBlockedRoutes}`}
|
||||
detail={t('providerRoute.map.candidateDetail')}
|
||||
tone="danger"
|
||||
icon={<Target size={16} />}
|
||||
/>
|
||||
<SummaryTile
|
||||
label={t('providerRoute.map.cost')}
|
||||
value={`${providerRouteGateApprovals}`}
|
||||
detail={t('providerRoute.map.costDetail')}
|
||||
tone="warn"
|
||||
icon={<Gauge size={16} />}
|
||||
/>
|
||||
<SummaryTile
|
||||
label={t('providerRoute.map.safeBoundary')}
|
||||
value="0"
|
||||
detail={t('providerRoute.map.safeBoundaryDetail')}
|
||||
tone="ok"
|
||||
icon={<ShieldCheck size={16} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.35fr) minmax(0, 0.65fr)', gap: 12 }} className="automation-inventory-provider-grid">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 10 }} className="automation-inventory-provider-route-grid">
|
||||
{visibleProviderRoutes.map(routeItem => (
|
||||
<div key={routeItem.route_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, minWidth: 0 }}>
|
||||
<span style={{
|
||||
fontFamily: 'Syne, sans-serif',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: '#141413',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{routeItem.display_name}
|
||||
</span>
|
||||
<Chip value={providerRouteValueLabel(routeItem.status)} muted={routeItem.status === 'verified'} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
<Chip value={providerRouteValueLabel(routeItem.kind)} />
|
||||
<Chip value={providerRouteValueLabel(routeItem.route_gate)} muted={routeItem.route_gate === 'route_preserved'} />
|
||||
<Chip value={`${t('providerRoute.labels.evidence')}: ${providerRouteValueLabel(routeItem.evidence_status)}`} muted />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
{routeItem.provider_order.map(provider => (
|
||||
<Chip key={`${routeItem.route_id}-${provider}`} value={providerRouteValueLabel(provider)} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f', lineHeight: 1.45, overflowWrap: 'anywhere' }}>
|
||||
{routeItem.current_policy}
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f', lineHeight: 1.45, overflowWrap: 'anywhere' }}>
|
||||
{routeItem.fallback_policy}
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#141413', lineHeight: 1.45, overflowWrap: 'anywhere' }}>
|
||||
{routeItem.next_action}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
<Chip value={routeItem.evidence_refs[0] ?? t('backupEvidence.noEvidence')} muted />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 }}>
|
||||
<div style={{ padding: 12, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('providerRoute.gatesTitle')}
|
||||
</span>
|
||||
{providerRouteMatrix.candidate_gates.map(gate => (
|
||||
<div key={gate.gate_id} style={{ display: 'flex', flexDirection: 'column', gap: 6, 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', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{gate.display_name}
|
||||
</span>
|
||||
<Chip value={providerRouteValueLabel(gate.status)} />
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f', lineHeight: 1.45, overflowWrap: 'anywhere' }}>
|
||||
{gate.summary}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 12, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('providerRoute.gapsTitle', { count: providerSourceGaps })}
|
||||
</span>
|
||||
{providerRouteMatrix.source_gaps.map(gap => (
|
||||
<div key={gap.gap_id} style={{ display: 'flex', flexDirection: 'column', gap: 6, 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', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{gap.display_name}
|
||||
</span>
|
||||
<Chip value={providerRouteValueLabel(gap.status)} muted={gap.status === 'proposal_required'} />
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f', lineHeight: 1.45, overflowWrap: 'anywhere' }}>
|
||||
{gap.summary}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 12, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('providerRoute.contractTitle')}
|
||||
</span>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f', lineHeight: 1.5, overflowWrap: 'anywhere' }}>
|
||||
{providerRouteMatrix.operator_contract.provider_switch_policy}
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f', lineHeight: 1.5, overflowWrap: 'anywhere' }}>
|
||||
{providerRouteMatrix.operator_contract.cost_policy}
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f', lineHeight: 1.5, overflowWrap: 'anywhere' }}>
|
||||
{providerRouteMatrix.operator_contract.runtime_policy}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
{providerRouteMatrix.operator_contract.must_not_interpret_as.slice(0, 6).map(item => (
|
||||
<Chip key={item} value={item} />
|
||||
))}
|
||||
</div>
|
||||
</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 }}>
|
||||
@@ -1771,6 +1972,10 @@ export function AutomationInventoryTab() {
|
||||
.automation-inventory-observability-map-grid,
|
||||
.automation-inventory-observability-grid,
|
||||
.automation-inventory-observability-surface-grid,
|
||||
.automation-inventory-provider-kpi-grid,
|
||||
.automation-inventory-provider-map-grid,
|
||||
.automation-inventory-provider-grid,
|
||||
.automation-inventory-provider-route-grid,
|
||||
.automation-inventory-bottom-grid,
|
||||
.automation-inventory-task-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
|
||||
@@ -277,6 +277,11 @@ export const apiClient = {
|
||||
return handleResponse<ObservabilityContractMatrixSnapshot>(res)
|
||||
},
|
||||
|
||||
async getAiProviderRouteMatrix() {
|
||||
const res = await fetch(`${API_BASE_URL}/agents/ai-provider-route-matrix`)
|
||||
return handleResponse<AiProviderRouteMatrixSnapshot>(res)
|
||||
},
|
||||
|
||||
async getBackupDrTargetInventory() {
|
||||
const res = await fetch(`${API_BASE_URL}/agents/backup-dr-target-inventory`)
|
||||
return handleResponse<BackupDrTargetInventorySnapshot>(res)
|
||||
@@ -1025,6 +1030,81 @@ export interface ObservabilityContractMatrixSnapshot {
|
||||
approval_boundaries: Record<string, false>
|
||||
}
|
||||
|
||||
export interface AiProviderRouteMatrixSnapshot {
|
||||
schema_version: 'ai_provider_route_matrix_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
|
||||
}
|
||||
source_refs: string[]
|
||||
rollups: {
|
||||
total_routes: number
|
||||
by_kind: Record<string, number>
|
||||
by_status: Record<string, number>
|
||||
by_route_gate: Record<string, number>
|
||||
route_ids_requiring_action: string[]
|
||||
candidate_gate_ids_requiring_approval: string[]
|
||||
source_gap_ids: string[]
|
||||
read_only_denials_total: number
|
||||
provider_switch_allowed_count: number
|
||||
paid_api_call_allowed_count: number
|
||||
shadow_or_canary_allowed_count: number
|
||||
runtime_route_change_allowed_count: number
|
||||
}
|
||||
provider_routes: Array<{
|
||||
route_id: string
|
||||
display_name: string
|
||||
kind: string
|
||||
status: 'verified' | 'action_required' | 'blocked'
|
||||
risk_level: 'low' | 'medium' | 'high' | 'critical'
|
||||
route_gate: string
|
||||
evidence_status: string
|
||||
current_policy: string
|
||||
provider_order: string[]
|
||||
fallback_policy: string
|
||||
evidence_refs: string[]
|
||||
next_action: string
|
||||
}>
|
||||
candidate_gates: Array<{
|
||||
gate_id: string
|
||||
display_name: string
|
||||
status: string
|
||||
approval_required: boolean
|
||||
summary: string
|
||||
evidence_refs: string[]
|
||||
next_action: string
|
||||
}>
|
||||
source_gaps: Array<{
|
||||
gap_id: string
|
||||
display_name: string
|
||||
status: string
|
||||
severity: 'low' | 'medium' | 'high' | 'critical'
|
||||
summary: string
|
||||
evidence_refs: string[]
|
||||
next_action: string
|
||||
}>
|
||||
latest_observations: Array<{
|
||||
observation_id: string
|
||||
status: string
|
||||
summary: string
|
||||
evidence_refs: string[]
|
||||
}>
|
||||
operator_contract: {
|
||||
display_mode: 'read_only_ai_provider_route_matrix'
|
||||
must_not_interpret_as: string[]
|
||||
secret_display_policy: string
|
||||
provider_switch_policy: string
|
||||
cost_policy: string
|
||||
runtime_policy: string
|
||||
}
|
||||
operation_boundaries: Record<string, boolean>
|
||||
approval_boundaries: Record<string, false>
|
||||
}
|
||||
|
||||
export interface BackupDrTargetInventorySnapshot {
|
||||
schema_version: 'backup_dr_target_inventory_v1'
|
||||
generated_at: string
|
||||
|
||||
Reference in New Issue
Block a user