feat(governance): add remediation dry run entrypoint
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / tests (push) Successful in 1m5s
CD Pipeline / build-and-deploy (push) Successful in 3m43s
CD Pipeline / post-deploy-checks (push) Successful in 1m33s

This commit is contained in:
Your Name
2026-05-14 22:20:34 +08:00
parent 102f92dfc3
commit 04fdaee83a
8 changed files with 820 additions and 3 deletions

View File

@@ -15,7 +15,7 @@
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { ShieldCheck, AlertTriangle } from 'lucide-react'
import { ShieldCheck, AlertTriangle, PlayCircle, SearchCheck } from 'lucide-react'
import { SloKpiCard, type SloMetric } from '@/components/governance/slo-kpi-card'
import { SloViolationChart, type ViolationDataPoint } from '@/components/governance/slo-violation-chart'
import { GlassCard } from '@/components/ui/glass-card'
@@ -144,6 +144,28 @@ interface SummaryApiResponse {
days?: number
}
interface RemediationDryRunResponse {
mode?: string
allowed?: boolean
executed?: boolean
verification_result_preview?: string
post_state_summary?: {
tool_count?: number
tools?: string[]
has_state?: boolean
}
mcp_route?: {
agent_id?: string
tool_name?: string
required_scope?: string
} | null
}
interface RemediationActionState {
status: 'loading' | 'done' | 'error'
data?: RemediationDryRunResponse
}
// =============================================================================
// Helpers
// =============================================================================
@@ -220,6 +242,16 @@ function compactLabel(value?: string | null, fallback = '--'): string {
return value.length > 54 ? `${value.slice(0, 54)}...` : value
}
async function requestRemediationDryRun(workItemId: string): Promise<RemediationDryRunResponse> {
const response = await fetch(`${API_BASE}/api/v1/ai/slo/remediation/dry-run`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ work_item_id: workItemId, mode: 'auto' }),
})
if (!response.ok) throw new Error(`dry_run_failed:${response.status}`)
return response.json()
}
function buildMetrics(api: SloApiResponse): SloMetric[] {
const adr100Metrics = api.adr100?.metrics
if (adr100Metrics?.length) {
@@ -276,6 +308,7 @@ function buildMetrics(api: SloApiResponse): SloMetric[] {
function VerificationCoveragePanel({ coverage }: { coverage?: Adr100VerificationCoverage }) {
const t = useTranslations('governance.slo.coverage')
const [actionState, setActionState] = useState<Record<string, RemediationActionState>>({})
const color = coverageTone(coverage?.status)
const rows = [
{ label: t('totalAuto'), value: String(coverage?.total_auto ?? '--') },
@@ -287,6 +320,25 @@ function VerificationCoveragePanel({ coverage }: { coverage?: Adr100Verification
const recentFindings = coverage?.recent_non_success ?? []
const remediationQueue = coverage?.remediation_queue
const handleDryRun = async (workItemId: string) => {
setActionState(prev => ({
...prev,
[workItemId]: { status: 'loading' },
}))
try {
const data = await requestRemediationDryRun(workItemId)
setActionState(prev => ({
...prev,
[workItemId]: { status: 'done', data },
}))
} catch {
setActionState(prev => ({
...prev,
[workItemId]: { status: 'error' },
}))
}
}
return (
<GlassCard variant="subtle" padding="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -379,7 +431,7 @@ function VerificationCoveragePanel({ coverage }: { coverage?: Adr100Verification
{(remediationQueue.items ?? []).slice(0, 4).map(item => (
<div key={item.work_item_id} style={{
display: 'grid',
gridTemplateColumns: 'minmax(130px, 0.8fr) minmax(180px, 1fr) minmax(160px, 1fr)',
gridTemplateColumns: 'minmax(130px, 0.8fr) minmax(180px, 1fr) minmax(150px, 0.9fr) minmax(150px, 0.8fr)',
gap: 10,
alignItems: 'center',
minWidth: 0,
@@ -410,6 +462,62 @@ function VerificationCoveragePanel({ coverage }: { coverage?: Adr100Verification
{compactLabel(item.remediation_reason)}
</div>
</div>
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
<button
type="button"
onClick={() => { void handleDryRun(item.work_item_id) }}
disabled={actionState[item.work_item_id]?.status === 'loading'}
title={t('dryRunButton')}
style={{
width: 'fit-content',
minHeight: 28,
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '5px 9px',
borderRadius: 6,
border: '0.5px solid rgba(20,20,19,0.14)',
background: actionState[item.work_item_id]?.status === 'loading'
? 'rgba(135,134,127,0.08)'
: 'rgba(34,197,94,0.08)',
color: '#141413',
fontFamily: "'DM Mono', monospace",
fontSize: 10,
cursor: actionState[item.work_item_id]?.status === 'loading' ? 'wait' : 'pointer',
}}
>
<PlayCircle size={13} style={{ color: '#22C55E', flexShrink: 0 }} />
<span>{actionState[item.work_item_id]?.status === 'loading' ? t('dryRunLoading') : t('dryRunButton')}</span>
</button>
{actionState[item.work_item_id]?.status === 'done' && (
<div style={{
display: 'flex',
alignItems: 'flex-start',
gap: 5,
fontFamily: "'DM Mono', monospace",
fontSize: 9,
color: actionState[item.work_item_id].data?.allowed === false ? '#7c5a10' : '#166534',
lineHeight: 1.4,
overflowWrap: 'anywhere',
}}>
<SearchCheck size={12} style={{ flexShrink: 0, marginTop: 1 }} />
<span>
{actionState[item.work_item_id].data?.allowed === false
? t('dryRunBlocked')
: t('dryRunResult', {
mode: actionState[item.work_item_id].data?.mode ?? '--',
result: actionState[item.work_item_id].data?.verification_result_preview ?? '--',
tools: actionState[item.work_item_id].data?.post_state_summary?.tool_count ?? 0,
})}
</span>
</div>
)}
{actionState[item.work_item_id]?.status === 'error' && (
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 9, color: '#FF3300', lineHeight: 1.4 }}>
{t('dryRunError')}
</div>
)}
</div>
</div>
))}
</div>