@aion0/forge 0.5.48 → 0.5.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +0 -1
- package/RELEASE_NOTES.md +8 -3
- package/app/api/tasks/[id]/log/entry/route.ts +13 -0
- package/app/api/tasks/[id]/log/route.ts +23 -0
- package/app/api/tasks/route.ts +2 -2
- package/components/ProjectDetail.tsx +1 -16
- package/components/TaskDetail.tsx +201 -51
- package/lib/help-docs/CLAUDE.md +0 -2
- package/lib/task-manager.ts +110 -0
- package/package.json +1 -1
- package/src/types/index.ts +7 -0
- package/app/api/migration/config/route.ts +0 -19
- package/app/api/migration/discover/route.ts +0 -26
- package/app/api/migration/failures/route.ts +0 -35
- package/app/api/migration/fix/route.ts +0 -82
- package/app/api/migration/run/route.ts +0 -22
- package/app/api/migration/run-batch/route.ts +0 -86
- package/components/MigrationCockpit.tsx +0 -541
- package/lib/help-docs/14-migration.md +0 -154
- package/lib/migration/differ.ts +0 -193
- package/lib/migration/discoverer.ts +0 -363
- package/lib/migration/openapi.ts +0 -137
- package/lib/migration/runner.ts +0 -219
- package/lib/migration/store.ts +0 -89
- package/lib/migration/types.ts +0 -115
|
@@ -1,541 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
4
|
-
import type { Endpoint, RunResult, MigrationConfig, FailureCluster } from '@/lib/migration/types';
|
|
5
|
-
|
|
6
|
-
// ─── Helpers ─────────────────────────────────────────────
|
|
7
|
-
|
|
8
|
-
const STATUS_COLORS: Record<string, string> = {
|
|
9
|
-
migrated: 'text-emerald-400',
|
|
10
|
-
tested: 'text-emerald-300',
|
|
11
|
-
'in-progress': 'text-yellow-400',
|
|
12
|
-
pending: 'text-gray-400',
|
|
13
|
-
skip: 'text-gray-500',
|
|
14
|
-
defer: 'text-orange-400',
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
const MATCH_COLORS: Record<string, string> = {
|
|
18
|
-
pass: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
|
|
19
|
-
fail: 'bg-red-500/20 text-red-300 border-red-500/40',
|
|
20
|
-
'stub-ok': 'bg-blue-500/20 text-blue-300 border-blue-500/40',
|
|
21
|
-
error: 'bg-orange-500/20 text-orange-300 border-orange-500/40',
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
interface Props {
|
|
25
|
-
projectPath: string;
|
|
26
|
-
projectName: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export default function MigrationCockpit({ projectPath, projectName }: Props) {
|
|
30
|
-
const [config, setConfig] = useState<MigrationConfig | null>(null);
|
|
31
|
-
const [endpoints, setEndpoints] = useState<Endpoint[]>([]);
|
|
32
|
-
const [discoverInfo, setDiscoverInfo] = useState<{ warnings: string[]; sources: { file: string; count: number }[] } | null>(null);
|
|
33
|
-
const [results, setResults] = useState<Record<string, RunResult>>({});
|
|
34
|
-
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number; running: boolean } | null>(null);
|
|
35
|
-
const [failures, setFailures] = useState<FailureCluster[]>([]);
|
|
36
|
-
const [filter, setFilter] = useState<'all' | 'fail' | 'pass' | 'untested' | 'stubbed' | 'pending' | 'migrated'>('all');
|
|
37
|
-
const [search, setSearch] = useState('');
|
|
38
|
-
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
39
|
-
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
40
|
-
const [showConfig, setShowConfig] = useState(false);
|
|
41
|
-
const [busy, setBusy] = useState(false);
|
|
42
|
-
const [toast, setToast] = useState<string | null>(null);
|
|
43
|
-
const sseRef = useRef<EventSource | null>(null);
|
|
44
|
-
|
|
45
|
-
const flash = useCallback((msg: string) => {
|
|
46
|
-
setToast(msg);
|
|
47
|
-
setTimeout(() => setToast(null), 2500);
|
|
48
|
-
}, []);
|
|
49
|
-
|
|
50
|
-
// ─── Data loading ──────────────────────────────────────
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
let cancelled = false;
|
|
53
|
-
(async () => {
|
|
54
|
-
const [cRes, dRes, fRes] = await Promise.all([
|
|
55
|
-
fetch(`/api/migration/config?projectPath=${encodeURIComponent(projectPath)}`),
|
|
56
|
-
fetch(`/api/migration/discover?projectPath=${encodeURIComponent(projectPath)}`),
|
|
57
|
-
fetch(`/api/migration/failures?projectPath=${encodeURIComponent(projectPath)}`),
|
|
58
|
-
]);
|
|
59
|
-
const c = await cRes.json();
|
|
60
|
-
const d = await dRes.json();
|
|
61
|
-
const f = await fRes.json();
|
|
62
|
-
if (cancelled) return;
|
|
63
|
-
setConfig(c);
|
|
64
|
-
setEndpoints(d.endpoints || []);
|
|
65
|
-
setFailures(f.clusters || []);
|
|
66
|
-
})();
|
|
67
|
-
return () => { cancelled = true; };
|
|
68
|
-
}, [projectPath]);
|
|
69
|
-
|
|
70
|
-
const saveConfig = useCallback(async (cfg: MigrationConfig) => {
|
|
71
|
-
setConfig(cfg);
|
|
72
|
-
await fetch('/api/migration/config', {
|
|
73
|
-
method: 'POST',
|
|
74
|
-
headers: { 'Content-Type': 'application/json' },
|
|
75
|
-
body: JSON.stringify({ projectPath, config: cfg }),
|
|
76
|
-
});
|
|
77
|
-
flash('Config saved');
|
|
78
|
-
}, [projectPath, flash]);
|
|
79
|
-
|
|
80
|
-
const discover = useCallback(async () => {
|
|
81
|
-
setBusy(true);
|
|
82
|
-
try {
|
|
83
|
-
const res = await fetch('/api/migration/discover', {
|
|
84
|
-
method: 'POST',
|
|
85
|
-
headers: { 'Content-Type': 'application/json' },
|
|
86
|
-
body: JSON.stringify({ projectPath }),
|
|
87
|
-
});
|
|
88
|
-
const d = await res.json();
|
|
89
|
-
setEndpoints(d.endpoints || []);
|
|
90
|
-
setDiscoverInfo({ warnings: d.warnings || [], sources: d.sources || [] });
|
|
91
|
-
flash(`Discovered ${d.total || 0} endpoints`);
|
|
92
|
-
} finally {
|
|
93
|
-
setBusy(false);
|
|
94
|
-
}
|
|
95
|
-
}, [projectPath, flash]);
|
|
96
|
-
|
|
97
|
-
const refreshFailures = useCallback(async () => {
|
|
98
|
-
const res = await fetch(`/api/migration/failures?projectPath=${encodeURIComponent(projectPath)}`);
|
|
99
|
-
const f = await res.json();
|
|
100
|
-
setFailures(f.clusters || []);
|
|
101
|
-
}, [projectPath]);
|
|
102
|
-
|
|
103
|
-
// ─── Filtering ─────────────────────────────────────────
|
|
104
|
-
const filtered = useMemo(() => {
|
|
105
|
-
const q = search.trim().toLowerCase();
|
|
106
|
-
return endpoints.filter(e => {
|
|
107
|
-
if (q && !`${e.method} ${e.path} ${e.controller}`.toLowerCase().includes(q)) return false;
|
|
108
|
-
const r = results[e.id];
|
|
109
|
-
switch (filter) {
|
|
110
|
-
case 'all': return true;
|
|
111
|
-
case 'untested': return !r;
|
|
112
|
-
case 'stubbed': return e.isStubbed;
|
|
113
|
-
case 'pending': return e.status === 'pending';
|
|
114
|
-
case 'migrated': return e.status === 'migrated' && !e.isStubbed;
|
|
115
|
-
case 'pass': return r?.match === 'pass' || r?.match === 'stub-ok';
|
|
116
|
-
case 'fail': return r?.match === 'fail' || r?.match === 'error';
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
}, [endpoints, results, filter, search]);
|
|
120
|
-
|
|
121
|
-
// ─── Run ───────────────────────────────────────────────
|
|
122
|
-
const runOne = useCallback(async (ep: Endpoint) => {
|
|
123
|
-
const res = await fetch('/api/migration/run', {
|
|
124
|
-
method: 'POST',
|
|
125
|
-
headers: { 'Content-Type': 'application/json' },
|
|
126
|
-
body: JSON.stringify({ projectPath, endpointId: ep.id }),
|
|
127
|
-
});
|
|
128
|
-
const r = await res.json();
|
|
129
|
-
setResults(prev => ({ ...prev, [ep.id]: r }));
|
|
130
|
-
setExpandedId(ep.id);
|
|
131
|
-
}, [projectPath]);
|
|
132
|
-
|
|
133
|
-
const runBatch = useCallback(async (endpointIds?: string[]) => {
|
|
134
|
-
if (sseRef.current) sseRef.current.close();
|
|
135
|
-
setBatchProgress({ done: 0, total: endpointIds?.length ?? endpoints.length, running: true });
|
|
136
|
-
|
|
137
|
-
// POST + read stream via fetch (EventSource doesn't support POST)
|
|
138
|
-
try {
|
|
139
|
-
const res = await fetch('/api/migration/run-batch', {
|
|
140
|
-
method: 'POST',
|
|
141
|
-
headers: { 'Content-Type': 'application/json' },
|
|
142
|
-
body: JSON.stringify({ projectPath, endpointIds }),
|
|
143
|
-
});
|
|
144
|
-
if (!res.body) throw new Error('No SSE body');
|
|
145
|
-
const reader = res.body.getReader();
|
|
146
|
-
const decoder = new TextDecoder();
|
|
147
|
-
let buffer = '';
|
|
148
|
-
while (true) {
|
|
149
|
-
const { value, done } = await reader.read();
|
|
150
|
-
if (done) break;
|
|
151
|
-
buffer += decoder.decode(value, { stream: true });
|
|
152
|
-
const events = buffer.split('\n\n');
|
|
153
|
-
buffer = events.pop() || '';
|
|
154
|
-
for (const block of events) {
|
|
155
|
-
const eMatch = block.match(/^event: (\w+)/m);
|
|
156
|
-
const dMatch = block.match(/^data: (.+)$/m);
|
|
157
|
-
if (!eMatch || !dMatch) continue;
|
|
158
|
-
const event = eMatch[1];
|
|
159
|
-
const data = JSON.parse(dMatch[1]);
|
|
160
|
-
if (event === 'start') setBatchProgress({ done: 0, total: data.total, running: true });
|
|
161
|
-
else if (event === 'progress') {
|
|
162
|
-
setBatchProgress(p => p ? { ...p, done: data.done, total: data.total } : null);
|
|
163
|
-
setResults(prev => ({ ...prev, [data.result.endpointId]: data.result }));
|
|
164
|
-
}
|
|
165
|
-
else if (event === 'done') {
|
|
166
|
-
setBatchProgress(p => p ? { ...p, running: false } : null);
|
|
167
|
-
flash(`Batch done: ${data.pass} pass, ${data.fail} fail, ${data.stubOk} stub-ok, ${data.error} error`);
|
|
168
|
-
await refreshFailures();
|
|
169
|
-
}
|
|
170
|
-
else if (event === 'error') {
|
|
171
|
-
flash('Batch error: ' + data.message);
|
|
172
|
-
setBatchProgress(null);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
} catch (e: any) {
|
|
177
|
-
flash('Stream error: ' + (e?.message || String(e)));
|
|
178
|
-
setBatchProgress(null);
|
|
179
|
-
}
|
|
180
|
-
}, [projectPath, endpoints.length, flash, refreshFailures]);
|
|
181
|
-
|
|
182
|
-
// ─── AI Fix ────────────────────────────────────────────
|
|
183
|
-
const requestFix = useCallback(async (ids: string[], mode: 'inject' | 'task') => {
|
|
184
|
-
let sessionName: string | undefined;
|
|
185
|
-
if (mode === 'inject') {
|
|
186
|
-
sessionName = window.prompt('tmux session name to inject into (e.g. mw-projectname):') || undefined;
|
|
187
|
-
if (!sessionName) return;
|
|
188
|
-
}
|
|
189
|
-
const res = await fetch('/api/migration/fix', {
|
|
190
|
-
method: 'POST',
|
|
191
|
-
headers: { 'Content-Type': 'application/json' },
|
|
192
|
-
body: JSON.stringify({ projectPath, projectName, mode, endpointIds: ids, sessionName }),
|
|
193
|
-
});
|
|
194
|
-
const r = await res.json();
|
|
195
|
-
if (r.ok) flash(mode === 'task' ? `Task created: ${r.taskId}` : `Sent to ${r.sessionName}`);
|
|
196
|
-
else flash('Fix failed: ' + (r.error || 'unknown'));
|
|
197
|
-
}, [projectPath, projectName, flash]);
|
|
198
|
-
|
|
199
|
-
// ─── Selection ─────────────────────────────────────────
|
|
200
|
-
const toggleSelect = (id: string) => {
|
|
201
|
-
setSelectedIds(prev => {
|
|
202
|
-
const next = new Set(prev);
|
|
203
|
-
if (next.has(id)) next.delete(id); else next.add(id);
|
|
204
|
-
return next;
|
|
205
|
-
});
|
|
206
|
-
};
|
|
207
|
-
const selectAll = () => setSelectedIds(new Set(filtered.map(e => e.id)));
|
|
208
|
-
const clearSel = () => setSelectedIds(new Set());
|
|
209
|
-
|
|
210
|
-
// ─── Stats ─────────────────────────────────────────────
|
|
211
|
-
const stats = useMemo(() => {
|
|
212
|
-
const total = endpoints.length;
|
|
213
|
-
let pass = 0, fail = 0, stub = 0, untested = 0, stubbed = 0, pending = 0, withSchema = 0;
|
|
214
|
-
for (const e of endpoints) {
|
|
215
|
-
if (e.isStubbed) stubbed++;
|
|
216
|
-
if (e.status === 'pending') pending++;
|
|
217
|
-
if (e.hasResponseSchema) withSchema++;
|
|
218
|
-
const r = results[e.id];
|
|
219
|
-
if (!r) { untested++; continue; }
|
|
220
|
-
if (r.match === 'pass') pass++;
|
|
221
|
-
else if (r.match === 'stub-ok') stub++;
|
|
222
|
-
else if (r.match === 'fail' || r.match === 'error') fail++;
|
|
223
|
-
}
|
|
224
|
-
return { total, pass, fail, stub, untested, stubbed, pending, withSchema };
|
|
225
|
-
}, [endpoints, results]);
|
|
226
|
-
|
|
227
|
-
if (!config) return <div className="p-4 text-xs text-[var(--text-secondary)]">Loading…</div>;
|
|
228
|
-
|
|
229
|
-
return (
|
|
230
|
-
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
|
231
|
-
{/* Toolbar */}
|
|
232
|
-
<div className="border-b border-[var(--border)] px-4 py-2 flex items-center gap-2 bg-[var(--bg-secondary)]">
|
|
233
|
-
<button onClick={discover} disabled={busy}
|
|
234
|
-
className="text-xs px-2.5 py-1 rounded bg-[var(--accent)]/20 text-[var(--accent)] hover:bg-[var(--accent)]/30 disabled:opacity-50">
|
|
235
|
-
{busy ? 'Discovering…' : 'Discover from docs'}
|
|
236
|
-
</button>
|
|
237
|
-
<button onClick={() => runBatch()} disabled={!!batchProgress?.running || endpoints.length === 0}
|
|
238
|
-
className="text-xs px-2.5 py-1 rounded bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30 disabled:opacity-50">
|
|
239
|
-
Run all ({endpoints.length})
|
|
240
|
-
</button>
|
|
241
|
-
{selectedIds.size > 0 && (
|
|
242
|
-
<>
|
|
243
|
-
<button onClick={() => runBatch([...selectedIds])} disabled={!!batchProgress?.running}
|
|
244
|
-
className="text-xs px-2.5 py-1 rounded bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30">
|
|
245
|
-
Run selected ({selectedIds.size})
|
|
246
|
-
</button>
|
|
247
|
-
<button onClick={() => requestFix([...selectedIds], 'task')}
|
|
248
|
-
className="text-xs px-2.5 py-1 rounded bg-purple-500/20 text-purple-300 hover:bg-purple-500/30">
|
|
249
|
-
AI fix → task
|
|
250
|
-
</button>
|
|
251
|
-
<button onClick={() => requestFix([...selectedIds], 'inject')}
|
|
252
|
-
className="text-xs px-2.5 py-1 rounded bg-purple-500/20 text-purple-300 hover:bg-purple-500/30">
|
|
253
|
-
AI fix → inject
|
|
254
|
-
</button>
|
|
255
|
-
</>
|
|
256
|
-
)}
|
|
257
|
-
<div className="flex-1" />
|
|
258
|
-
<input
|
|
259
|
-
value={search} onChange={e => setSearch(e.target.value)}
|
|
260
|
-
placeholder="Search controller / path…"
|
|
261
|
-
className="text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 w-48"
|
|
262
|
-
/>
|
|
263
|
-
<select value={filter} onChange={e => setFilter(e.target.value as any)}
|
|
264
|
-
className="text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1">
|
|
265
|
-
<option value="all">All</option>
|
|
266
|
-
<option value="untested">Untested</option>
|
|
267
|
-
<option value="pass">Pass</option>
|
|
268
|
-
<option value="fail">Fail</option>
|
|
269
|
-
<option value="stubbed">Stubbed</option>
|
|
270
|
-
<option value="migrated">Migrated</option>
|
|
271
|
-
<option value="pending">Pending (no doc)</option>
|
|
272
|
-
</select>
|
|
273
|
-
<button onClick={() => setShowConfig(v => !v)}
|
|
274
|
-
className="text-xs px-2 py-1 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]">
|
|
275
|
-
{showConfig ? 'Hide config' : 'Config'}
|
|
276
|
-
</button>
|
|
277
|
-
</div>
|
|
278
|
-
|
|
279
|
-
{/* Stats bar */}
|
|
280
|
-
<div className="px-4 py-1.5 flex items-center gap-4 text-[11px] border-b border-[var(--border)] bg-[var(--bg-tertiary)]/40">
|
|
281
|
-
<span><b className="text-[var(--text-primary)]">{stats.total}</b> total</span>
|
|
282
|
-
<span className="text-emerald-400">{stats.pass} pass</span>
|
|
283
|
-
<span className="text-blue-400">{stats.stub} stub-ok</span>
|
|
284
|
-
<span className="text-red-400">{stats.fail} fail</span>
|
|
285
|
-
<span className="text-gray-400">{stats.untested} untested</span>
|
|
286
|
-
<span className="text-gray-500">({stats.stubbed} stub · {stats.pending} pending · {stats.withSchema} w/ schema)</span>
|
|
287
|
-
<span className="text-purple-400">mode: {config.diffMode || 'shape'}</span>
|
|
288
|
-
{batchProgress && (
|
|
289
|
-
<span className={batchProgress.running ? 'text-yellow-400' : 'text-emerald-400'}>
|
|
290
|
-
{batchProgress.running ? '⏳' : '✓'} {batchProgress.done}/{batchProgress.total}
|
|
291
|
-
</span>
|
|
292
|
-
)}
|
|
293
|
-
<div className="flex-1" />
|
|
294
|
-
{discoverInfo?.warnings && discoverInfo.warnings.length > 0 && (
|
|
295
|
-
<span className="text-yellow-400" title={discoverInfo.warnings.join('\n')}>
|
|
296
|
-
{discoverInfo.warnings.length} warning{discoverInfo.warnings.length > 1 ? 's' : ''}
|
|
297
|
-
</span>
|
|
298
|
-
)}
|
|
299
|
-
</div>
|
|
300
|
-
|
|
301
|
-
{/* Config panel */}
|
|
302
|
-
{showConfig && (
|
|
303
|
-
<ConfigPanel config={config} onSave={saveConfig} onClose={() => setShowConfig(false)} />
|
|
304
|
-
)}
|
|
305
|
-
|
|
306
|
-
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
307
|
-
{/* Endpoint list */}
|
|
308
|
-
<div className="flex-1 overflow-y-auto">
|
|
309
|
-
{filtered.length === 0 ? (
|
|
310
|
-
<div className="p-6 text-center text-xs text-[var(--text-secondary)]">
|
|
311
|
-
{endpoints.length === 0 ? (
|
|
312
|
-
<>
|
|
313
|
-
No endpoints. Click <b>Discover from docs</b> to scan{' '}
|
|
314
|
-
<code className="text-[var(--accent)]">{config.endpointSource.primary}</code>
|
|
315
|
-
{config.endpointSource.fallback && <> + <code className="text-[var(--accent)]">{config.endpointSource.fallback}</code></>}.
|
|
316
|
-
</>
|
|
317
|
-
) : 'No endpoints match the filter.'}
|
|
318
|
-
</div>
|
|
319
|
-
) : (
|
|
320
|
-
<div className="text-[11px]">
|
|
321
|
-
<div className="sticky top-0 z-10 bg-[var(--bg-secondary)] border-b border-[var(--border)] px-3 py-1.5 flex items-center gap-2">
|
|
322
|
-
<input type="checkbox"
|
|
323
|
-
checked={selectedIds.size === filtered.length && filtered.length > 0}
|
|
324
|
-
onChange={() => selectedIds.size === filtered.length ? clearSel() : selectAll()}
|
|
325
|
-
/>
|
|
326
|
-
<span className="text-[var(--text-secondary)]">Select all visible ({filtered.length})</span>
|
|
327
|
-
</div>
|
|
328
|
-
{filtered.map(ep => {
|
|
329
|
-
const r = results[ep.id];
|
|
330
|
-
const exp = expandedId === ep.id;
|
|
331
|
-
return (
|
|
332
|
-
<div key={ep.id} className="border-b border-[var(--border)]/50">
|
|
333
|
-
<div className="px-3 py-1.5 flex items-center gap-2 hover:bg-[var(--bg-secondary)]/50">
|
|
334
|
-
<input type="checkbox" checked={selectedIds.has(ep.id)} onChange={() => toggleSelect(ep.id)} />
|
|
335
|
-
<span className={`font-mono font-bold w-12 text-right ${methodColor(ep.method)}`}>{ep.method}</span>
|
|
336
|
-
<span className="font-mono flex-1 truncate">{ep.path}</span>
|
|
337
|
-
<span className="text-[10px] text-[var(--text-secondary)] w-32 truncate">{ep.controller}</span>
|
|
338
|
-
{ep.isStubbed && <span className="text-[9px] px-1 rounded bg-blue-500/20 text-blue-300">501</span>}
|
|
339
|
-
<span className={`text-[9px] ${STATUS_COLORS[ep.status] || ''}`}>{ep.status}</span>
|
|
340
|
-
{r && (
|
|
341
|
-
<span className={`text-[9px] px-1.5 py-0.5 rounded border ${MATCH_COLORS[r.match]}`}>
|
|
342
|
-
{r.match}
|
|
343
|
-
</span>
|
|
344
|
-
)}
|
|
345
|
-
<button onClick={() => runOne(ep)}
|
|
346
|
-
className="text-[10px] px-2 py-0.5 rounded bg-[var(--accent)]/20 text-[var(--accent)] hover:bg-[var(--accent)]/30">
|
|
347
|
-
Run
|
|
348
|
-
</button>
|
|
349
|
-
<button onClick={() => setExpandedId(exp ? null : ep.id)}
|
|
350
|
-
className="text-[10px] px-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
|
|
351
|
-
{exp ? '▼' : '▶'}
|
|
352
|
-
</button>
|
|
353
|
-
</div>
|
|
354
|
-
{exp && r && <RunResultDetail r={r} />}
|
|
355
|
-
</div>
|
|
356
|
-
);
|
|
357
|
-
})}
|
|
358
|
-
</div>
|
|
359
|
-
)}
|
|
360
|
-
</div>
|
|
361
|
-
|
|
362
|
-
{/* Failures sidebar */}
|
|
363
|
-
{failures.length > 0 && (
|
|
364
|
-
<div className="w-72 border-l border-[var(--border)] overflow-y-auto bg-[var(--bg-secondary)]/30">
|
|
365
|
-
<div className="px-3 py-2 border-b border-[var(--border)] text-[11px] font-medium text-[var(--text-primary)] flex items-center justify-between">
|
|
366
|
-
<span>Failure clusters</span>
|
|
367
|
-
<button onClick={refreshFailures} className="text-[9px] text-[var(--accent)]">refresh</button>
|
|
368
|
-
</div>
|
|
369
|
-
{failures.map(c => (
|
|
370
|
-
<div key={c.errorType} className="border-b border-[var(--border)]/50 px-3 py-2">
|
|
371
|
-
<div className="flex items-center justify-between mb-1">
|
|
372
|
-
<span className="text-[11px] font-mono text-red-400">{c.errorType}</span>
|
|
373
|
-
<span className="text-[10px] text-[var(--text-secondary)]">{c.count}</span>
|
|
374
|
-
</div>
|
|
375
|
-
{c.controllers.slice(0, 5).map(cc => (
|
|
376
|
-
<div key={cc.controller} className="flex items-center justify-between text-[10px] py-0.5">
|
|
377
|
-
<span className="truncate text-[var(--text-secondary)]">{cc.controller}</span>
|
|
378
|
-
<span className="text-[9px] text-[var(--text-secondary)]">{cc.failures.length}</span>
|
|
379
|
-
</div>
|
|
380
|
-
))}
|
|
381
|
-
<button
|
|
382
|
-
onClick={() => requestFix(c.controllers.flatMap(cc => cc.failures.map(f => f.endpointId)), 'task')}
|
|
383
|
-
className="mt-1 text-[10px] w-full py-1 rounded bg-purple-500/20 text-purple-300 hover:bg-purple-500/30">
|
|
384
|
-
Fix cluster → task
|
|
385
|
-
</button>
|
|
386
|
-
</div>
|
|
387
|
-
))}
|
|
388
|
-
</div>
|
|
389
|
-
)}
|
|
390
|
-
</div>
|
|
391
|
-
|
|
392
|
-
{toast && (
|
|
393
|
-
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 px-3 py-1.5 text-xs rounded bg-[var(--bg-tertiary)] border border-[var(--border)] shadow-lg">
|
|
394
|
-
{toast}
|
|
395
|
-
</div>
|
|
396
|
-
)}
|
|
397
|
-
</div>
|
|
398
|
-
);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// ─── Config panel ────────────────────────────────────────
|
|
402
|
-
|
|
403
|
-
function ConfigPanel({ config, onSave, onClose }: { config: MigrationConfig; onSave: (c: MigrationConfig) => void; onClose: () => void }) {
|
|
404
|
-
const [draft, setDraft] = useState(config);
|
|
405
|
-
return (
|
|
406
|
-
<div className="border-b border-[var(--border)] bg-[var(--bg-secondary)]/40 px-4 py-3 grid grid-cols-2 gap-3 text-[11px]">
|
|
407
|
-
<Field label="Legacy base URL">
|
|
408
|
-
<input className="cfg-input" value={draft.legacy.baseUrl}
|
|
409
|
-
onChange={e => setDraft({ ...draft, legacy: { baseUrl: e.target.value } })} />
|
|
410
|
-
</Field>
|
|
411
|
-
<Field label="New base URL">
|
|
412
|
-
<input className="cfg-input" value={draft.next.baseUrl}
|
|
413
|
-
onChange={e => setDraft({ ...draft, next: { ...draft.next, baseUrl: e.target.value } })} />
|
|
414
|
-
</Field>
|
|
415
|
-
<Field label="Auth mode">
|
|
416
|
-
<select className="cfg-input" value={draft.auth.mode}
|
|
417
|
-
onChange={e => setDraft({ ...draft, auth: { ...draft.auth, mode: e.target.value as any } })}>
|
|
418
|
-
<option value="skip">skip</option>
|
|
419
|
-
<option value="bearer">bearer (token from env)</option>
|
|
420
|
-
<option value="basic">basic</option>
|
|
421
|
-
</select>
|
|
422
|
-
</Field>
|
|
423
|
-
<Field label="Token env var">
|
|
424
|
-
<input className="cfg-input" value={draft.auth.tokenEnv || ''}
|
|
425
|
-
onChange={e => setDraft({ ...draft, auth: { ...draft.auth, tokenEnv: e.target.value } })}
|
|
426
|
-
placeholder="FORTINAC_TOKEN" />
|
|
427
|
-
</Field>
|
|
428
|
-
<Field label="OpenAPI spec (primary source)">
|
|
429
|
-
<input className="cfg-input" value={draft.endpointSource.openApiSpec || ''}
|
|
430
|
-
onChange={e => setDraft({ ...draft, endpointSource: { ...draft.endpointSource, openApiSpec: e.target.value } })}
|
|
431
|
-
placeholder="docs/fnac-rest-schema-7.6.json" />
|
|
432
|
-
</Field>
|
|
433
|
-
<Field label="Diff mode">
|
|
434
|
-
<select className="cfg-input" value={draft.diffMode || 'shape'}
|
|
435
|
-
onChange={e => setDraft({ ...draft, diffMode: e.target.value as any })}>
|
|
436
|
-
<option value="shape">shape — validate new vs OpenAPI schema (legacy not needed)</option>
|
|
437
|
-
<option value="exact">exact — deep-equal both sides (legacy required)</option>
|
|
438
|
-
<option value="both">both — deep-equal + schema validation</option>
|
|
439
|
-
</select>
|
|
440
|
-
</Field>
|
|
441
|
-
<Field label="Per-controller docs dir (annotation)">
|
|
442
|
-
<input className="cfg-input" value={draft.endpointSource.primary}
|
|
443
|
-
onChange={e => setDraft({ ...draft, endpointSource: { ...draft.endpointSource, primary: e.target.value } })} />
|
|
444
|
-
</Field>
|
|
445
|
-
<Field label="History fallback (annotation)">
|
|
446
|
-
<input className="cfg-input" value={draft.endpointSource.fallback || ''}
|
|
447
|
-
onChange={e => setDraft({ ...draft, endpointSource: { ...draft.endpointSource, fallback: e.target.value } })} />
|
|
448
|
-
</Field>
|
|
449
|
-
<Field label="Ignore JSON paths (one per line)">
|
|
450
|
-
<textarea className="cfg-input min-h-[60px]" value={draft.ignorePaths.join('\n')}
|
|
451
|
-
onChange={e => setDraft({ ...draft, ignorePaths: e.target.value.split('\n').filter(Boolean) })} />
|
|
452
|
-
</Field>
|
|
453
|
-
<Field label="Path placeholder substitutions">
|
|
454
|
-
<textarea className="cfg-input min-h-[60px]"
|
|
455
|
-
value={Object.entries(draft.pathSubstitutions || {}).map(([k, v]) => `${k}=${v}`).join('\n')}
|
|
456
|
-
onChange={e => {
|
|
457
|
-
const subs: Record<string, string> = {};
|
|
458
|
-
for (const line of e.target.value.split('\n')) {
|
|
459
|
-
const [k, ...rest] = line.split('=');
|
|
460
|
-
if (k && rest.length) subs[k.trim()] = rest.join('=').trim();
|
|
461
|
-
}
|
|
462
|
-
setDraft({ ...draft, pathSubstitutions: subs });
|
|
463
|
-
}} />
|
|
464
|
-
</Field>
|
|
465
|
-
<div className="col-span-2 flex justify-end gap-2 mt-1">
|
|
466
|
-
<button onClick={onClose} className="text-xs px-3 py-1 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]">Cancel</button>
|
|
467
|
-
<button onClick={() => { onSave(draft); onClose(); }}
|
|
468
|
-
className="text-xs px-3 py-1 rounded bg-[var(--accent)]/30 text-[var(--accent)] hover:bg-[var(--accent)]/40">Save</button>
|
|
469
|
-
</div>
|
|
470
|
-
<style jsx>{`
|
|
471
|
-
.cfg-input { background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 4px; padding: 4px 8px; width: 100%; font-size: 11px; font-family: ui-monospace, monospace; }
|
|
472
|
-
`}</style>
|
|
473
|
-
</div>
|
|
474
|
-
);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
478
|
-
return (
|
|
479
|
-
<label className="flex flex-col gap-1">
|
|
480
|
-
<span className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">{label}</span>
|
|
481
|
-
{children}
|
|
482
|
-
</label>
|
|
483
|
-
);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// ─── Run result detail ───────────────────────────────────
|
|
487
|
-
|
|
488
|
-
function RunResultDetail({ r }: { r: RunResult }) {
|
|
489
|
-
return (
|
|
490
|
-
<div className="px-6 py-2 bg-[var(--bg-tertiary)]/40 text-[10px] font-mono space-y-1">
|
|
491
|
-
<div className="grid grid-cols-2 gap-3">
|
|
492
|
-
<SidePane label="Legacy" side={r.legacy} />
|
|
493
|
-
<SidePane label="New" side={r.next} />
|
|
494
|
-
</div>
|
|
495
|
-
{r.diff && r.diff.length > 0 && (
|
|
496
|
-
<div className="mt-2 border-t border-[var(--border)] pt-2">
|
|
497
|
-
<div className="text-[10px] text-yellow-400 mb-1">Diffs ({r.diff.length}):</div>
|
|
498
|
-
<div className="max-h-40 overflow-y-auto">
|
|
499
|
-
{r.diff.map((d, i) => (
|
|
500
|
-
<div key={i} className="flex gap-2 py-0.5">
|
|
501
|
-
<span className="text-cyan-300 w-32 truncate" title={d.jsonPath}>{d.jsonPath}</span>
|
|
502
|
-
<span className="text-red-300 truncate flex-1" title={JSON.stringify(d.legacy)}>L: {JSON.stringify(d.legacy)}</span>
|
|
503
|
-
<span className="text-emerald-300 truncate flex-1" title={JSON.stringify(d.next)}>N: {JSON.stringify(d.next)}</span>
|
|
504
|
-
</div>
|
|
505
|
-
))}
|
|
506
|
-
</div>
|
|
507
|
-
</div>
|
|
508
|
-
)}
|
|
509
|
-
{r.errorMessage && <div className="text-red-400">⚠ {r.errorType}: {r.errorMessage}</div>}
|
|
510
|
-
</div>
|
|
511
|
-
);
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
function SidePane({ label, side }: { label: string; side: any }) {
|
|
515
|
-
return (
|
|
516
|
-
<div className="space-y-0.5">
|
|
517
|
-
<div className="text-[10px] text-[var(--text-secondary)] flex items-center gap-2">
|
|
518
|
-
<span>{label}</span>
|
|
519
|
-
<span className={side.ok ? 'text-emerald-400' : 'text-red-400'}>{side.status || side.error}</span>
|
|
520
|
-
<span className="text-[9px] opacity-60">{side.durationMs}ms</span>
|
|
521
|
-
</div>
|
|
522
|
-
<div className="text-[9px] text-[var(--text-secondary)] truncate" title={side.url}>{side.url}</div>
|
|
523
|
-
{side.bodyExcerpt && (
|
|
524
|
-
<pre className="text-[9px] max-h-24 overflow-auto whitespace-pre-wrap break-words bg-[var(--bg-primary)] border border-[var(--border)] rounded p-1">
|
|
525
|
-
{side.bodyExcerpt.slice(0, 800)}
|
|
526
|
-
</pre>
|
|
527
|
-
)}
|
|
528
|
-
</div>
|
|
529
|
-
);
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
function methodColor(m: string): string {
|
|
533
|
-
switch (m) {
|
|
534
|
-
case 'GET': return 'text-emerald-400';
|
|
535
|
-
case 'POST': return 'text-yellow-400';
|
|
536
|
-
case 'PUT': return 'text-blue-400';
|
|
537
|
-
case 'DELETE': return 'text-red-400';
|
|
538
|
-
case 'PATCH': return 'text-purple-400';
|
|
539
|
-
default: return 'text-gray-400';
|
|
540
|
-
}
|
|
541
|
-
}
|