// Upload / Excavation page — wired to real AWS APIs
// Design preserved exactly from design system output.
// Only data plumbing changed: mock timers → real XHR + API calls.
// ── API endpoint ──────────────────────────────────────────────────────────
// Update this to your actual API Gateway URL after Step 8 (CloudFront wiring).
// During development, use the raw API Gateway URL directly.
const API_BASE = 'https://r5vefiej3l.execute-api.eu-central-1.amazonaws.com/prod';
// ── Helpers ───────────────────────────────────────────────────────────────
function _fireScanNotifications(job_id, elapsed_s, data) {
const prefs = (() => {
try { return JSON.parse(localStorage.getItem('fossyl_notifs') || '{}'); } catch { return {}; }
})();
const token = window.getIdToken ? window.getIdToken() : '';
const post = (path, body) => fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
}).catch(() => {});
// ── Scan completed ──────────────────────────────────────────────────────
if (prefs.scanApp !== false && window.__fossylToast) {
window.__fossylToast('Excavation complete — report ready.', { tone: 'success', duration: 6000 });
}
if (prefs.scanEmail !== false) {
post('/account/notify/scan', { job_id, status: 'done', duration_s: Math.round(elapsed_s) });
}
// ── High-risk artifacts (TTW count > 0) ────────────────────────────────
const ttw = data?.ttw_count || 0;
const div = data?.diverged_count || 0;
if (ttw > 0 || div > 0) {
if (prefs.riskApp !== false && window.__fossylToast) {
window.__fossylToast(
`${ttw} TTW artifact${ttw !== 1 ? 's' : ''} detected — review the risk map.`,
{ tone: 'error', duration: 8000 },
);
}
if (prefs.riskEmail !== false) {
post('/account/notify/risk', { job_id, ttw_count: ttw, diverged_count: div });
}
}
}
function fmt_bytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(i >= 2 ? 1 : 0) + ' ' + units[i];
}
// Upload a single file to a presigned S3 PUT URL via XHR.
// Returns a Promise that resolves when done.
// onProgress(pct) is called with 0–100 as the upload progresses.
function upload_to_s3(presigned_url, file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', presigned_url, true);
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => xhr.status === 200 ? resolve() : reject(new Error(`S3 upload failed: ${xhr.status}`));
xhr.onerror = () => reject(new Error('Network error during S3 upload'));
xhr.send(file);
});
}
// ── Page shell ────────────────────────────────────────────────────────────
function UploadPage() {
const { go } = useNav();
const { toast } = useToast();
// Shared job state threaded between states
const [state, setState] = useState('form');
const [jobCtx, setJobCtx] = useState(null);
const [jobResult, setJobResult] = useState(null);
const handleRun = (ctx) => { setJobCtx(ctx); setState('uploading'); };
const handleUploaded = () => setState('analysis');
const handleDone = (result) => { setJobResult(result); setState('done'); };
const handleError = () => setState('error');
const handleReset = () => { setJobCtx(null); setJobResult(null); setState('form'); };
return (
{state === 'form' &&
}
{state === 'uploading' && }
{state === 'analysis' && }
{state === 'done' && }
{state === 'error' && }
);
}
// ── State 1: Form ─────────────────────────────────────────────────────────
function UploadForm({ onRun }) {
const { paid, role } = useAuth();
const { go } = useNav();
const { toast } = useToast();
const isViewer = role === 'Viewer';
const [repoFile, setRepoFile] = useState(null);
const [zexpFile, setZexpFile] = useState(null);
const [sqlFile, setSqlFile] = useState(null);
const [ai, setAi] = useState(true);
const [fullSource, setFullSource] = useState(false);
const [dpaEnabled, setDpaEnabled] = useState(false);
const [intentTop, setIntentTop] = useState(10);
const [dbPattern, setDbPattern] = useState('riscore_%');
const [dbCount, setDbCount] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// Check if account has DPA signed — only then show full-source toggle
useEffect(() => {
const token = window.getIdToken ? window.getIdToken() : '';
if (!token) return;
fetch(`${API_BASE}/account/profile`, { headers: { Authorization: 'Bearer ' + token } })
.then(r => r.ok ? r.json() : null)
.then(d => { if (d?.profile?.dpa_signed) setDpaEnabled(true); })
.catch(() => {});
}, []);
const canRun = repoFile && zexpFile && !loading;
const handleRun = async () => {
if (!canRun) return;
if (!paid) {
toast('A subscription is required to run excavations.', { tone: 'error', duration: 4000 });
go('billing');
return;
}
if (isViewer) {
toast('Viewer access — you can browse reports but cannot start new excavations.', { tone: 'error', duration: 4000 });
return;
}
setLoading(true);
setError('');
try {
// Step 1: Get presigned upload URLs from fossyl-presign Lambda
const presign_body = {
repo_filename: repoFile.name,
zexp_filename: zexpFile.name,
with_intent: ai,
intent_top: intentTop,
db_pattern: dbPattern,
db_count: dbCount,
};
if (sqlFile) presign_body.sql_filename = sqlFile.name;
const presign_resp = await fetch(`${API_BASE}/presign`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (window.getIdToken ? window.getIdToken() : ''),
},
body: JSON.stringify(presign_body),
});
if (!presign_resp.ok) {
const err = await presign_resp.json().catch(() => ({}));
throw new Error(err.error || `Presign failed (${presign_resp.status})`);
}
const presign_data = await presign_resp.json();
// Pass context to uploading state — it handles the actual S3 uploads
onRun({
job_id: presign_data.job_id,
repo_upload_url: presign_data.repo_upload_url,
zexp_upload_url: presign_data.zexp_upload_url,
sql_upload_url: presign_data.sql_upload_url || null,
repo_s3_key: presign_data.repo_s3_key,
zexp_s3_key: presign_data.zexp_s3_key,
sql_s3_key: presign_data.sql_s3_key || '',
files: [
{ file: repoFile, url: presign_data.repo_upload_url, label: repoFile.name },
{ file: zexpFile, url: presign_data.zexp_upload_url, label: zexpFile.name },
...(sqlFile && presign_data.sql_upload_url
? [{ file: sqlFile, url: presign_data.sql_upload_url, label: sqlFile.name }]
: []),
],
options: {
with_intent: ai,
intent_top: intentTop,
db_pattern: dbPattern,
db_count: dbCount,
analysis_mode: (ai && fullSource && dpaEnabled) ? 'full_source' : 'signatures',
},
});
} catch (e) {
setError(e.message || 'Failed to start excavation. Please try again.');
setLoading(false);
}
};
const total_bytes = (repoFile?.size || 0) + (zexpFile?.size || 0) + (sqlFile?.size || 0);
return (
{/* Access banners */}
{!paid && (
◈
You need an active subscription to run excavations.
go('billing')}>Choose a plan →
)}
{paid && isViewer && (
◈
Viewer access — you can browse reports but cannot start new excavations.
)}
{/* Section: specimens */}
01 — Specimens
two required, one optional
{/* Section: options */}
02 — Options
affects runtime and cost
+ ~11s · + € 0.40}
/>
{ai && dpaEnabled && (
DPA required · active}
/>
)}
{ai && (
Selection strategy
Top-N by coupling × churn
auto — modify in scan settings after run
)}
{}}
right={+ ~2s }
/>
{sqlFile && (
)}
{/* Error message */}
{error && (
{error}
)}
{/* Summary + CTA */}
{(repoFile ? 1 : 0) + (zexpFile ? 1 : 0) + (sqlFile ? 1 : 0)} files · {fmt_bytes(total_bytes)} >}/>
~{ai ? '58' : '47'} seconds >}/>
€ {ai ? '0.40' : '0.00'} in addition to plan >}/>
Files are uploaded directly from your browser to ephemeral object storage via presigned URL. Once the excavation completes, the container and all uploaded artifacts are destroyed. Only the generated HTML report persists.
{loading ? 'Preparing…' : !paid ? 'Subscribe to excavate →' : isViewer ? 'Viewer access — read only' : 'Run excavation →'}
);
}
// ── DropZone — real file input ─────────────────────────────────────────────
function DropZone({ required, label, accept, hint, icon, file, onFile }) {
const inputRef = useRef(null);
const [dragging, setDragging] = useState(false);
const handleFiles = (files) => {
if (files && files[0]) onFile(files[0]);
};
return (
{ e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={e => { e.preventDefault(); setDragging(false); handleFiles(e.dataTransfer.files); }}
>
{/* Hidden real file input */}
handleFiles(e.target.files)}
/>
{icon}
{label}
{required
? required
: optional }
accepts {accept}
{hint}
{file ? (
<>
{file.name}
{fmt_bytes(file.size)}
>
) : (
<>
no file staged
drag & drop or browse
>
)}
{file
? <>
inputRef.current?.click()}>Replace
onFile(null)}>✕
>
: inputRef.current?.click()}>Browse…
}
);
}
function ToggleRow({ label, sub, on, setOn, right }) {
return (
setOn(!on)} style={{
width: 36, height: 20, borderRadius: 10,
border: '1px solid ' + (on ? 'var(--bone)' : 'var(--line-2)'),
background: on ? 'var(--bone)' : 'var(--bg-2)',
position: 'relative', cursor: 'pointer', padding: 0,
}}>
{right}
);
}
function SummaryLine({ label, value }) {
return (
);
}
// ── State 2: Uploading — real XHR to S3 ──────────────────────────────────
function UploadingState({ ctx, onDone, onError }) {
const [progresses, setProgresses] = useState(
() => (ctx?.files || []).map(() => 0)
);
const [elapsed, setElapsed] = useState(0);
const [errMsg, setErrMsg] = useState('');
const startedRef = useRef(false);
// Elapsed timer
useEffect(() => {
const id = setInterval(() => setElapsed(e => +(e + 0.1).toFixed(1)), 100);
return () => clearInterval(id);
}, []);
// Run uploads once
useEffect(() => {
if (startedRef.current || !ctx) return;
startedRef.current = true;
const run = async () => {
try {
const { files } = ctx;
// Upload all files concurrently, tracking per-file progress
await Promise.all(files.map((f, i) =>
upload_to_s3(f.url, f.file, (pct) => {
setProgresses(prev => {
const next = [...prev];
next[i] = pct;
return next;
});
})
));
// All uploaded — trigger the ECS job
const trigger_resp = await fetch(`${API_BASE}/trigger`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (window.getIdToken ? window.getIdToken() : ''),
},
body: JSON.stringify({
job_id: ctx.job_id,
repo_s3_key: ctx.repo_s3_key,
zexp_s3_key: ctx.zexp_s3_key,
sql_s3_key: ctx.sql_s3_key,
options: ctx.options,
}),
});
if (!trigger_resp.ok) {
const err = await trigger_resp.json().catch(() => ({}));
throw new Error(err.error || `Trigger failed (${trigger_resp.status})`);
}
// Small pause then move to analysis
setTimeout(onDone, 400);
} catch (e) {
setErrMsg(e.message || 'Upload failed');
setTimeout(onError, 1500);
}
};
run();
}, []);
const files = ctx?.files || [];
const all_done = progresses.every(p => p >= 100);
return (
{errMsg ? 'Upload failed' : all_done ? 'Specimens transmitted ✓' : 'Transmitting specimens…'}
elapsed {elapsed.toFixed(1)}s · direct-to-storage · presigned
{files.map((f, i) => (
0 ? '1px solid var(--line)' : 'none',
display: 'grid', gridTemplateColumns: '32px 1fr 140px 80px', gap: 16, alignItems: 'center',
}}>
0{i + 1}
{f.label}
= 100 ? 'var(--lichen)' : 'var(--bone)' }}>
{progresses[i] >= 100 ? 'transmitted ✓' : 'transmitting…'}
= 100 ? 'var(--lichen)' : 'var(--bone)', transition: 'width 80ms linear' }}/>
{fmt_bytes(f.file.size)}
= 100 ? 'var(--lichen)' : 'var(--ink)' }}>
{Math.floor(progresses[i])}%
))}
{errMsg && (
{errMsg}
)}
);
}
// ── State 3: Analysis — real polling ──────────────────────────────────────
const STEPS = [
{ k: 'extract', n: '01', t: 'Extracting repository', sub: 'unpacking ZIP, reading filesystem tree' },
{ k: 'fs', n: '02', t: 'Ingesting filesystem', sub: 'classifying artifacts, fingerprinting sources' },
{ k: 'zodb', n: '03', t: 'Parsing ZODB export', sub: 'walking persisted records' },
{ k: 'diverge', n: '04', t: 'Detecting divergence', sub: 'reconciling fs tree against live database' },
{ k: 'graph', n: '05', t: 'Building dependency graph', sub: 'resolving traversal edges' },
{ k: 'schema', n: '06', t: 'Parsing database schema', sub: 'cross-linking tables to Zope types' },
{ k: 'llm', n: '07', t: 'LLM inference', sub: 'generating plain-English field notes' },
{ k: 'emit', n: '08', t: 'Generating HTML report', sub: 'assembling self-contained artifact' },
];
function AnalysisState({ ctx, onDone, onError }) {
const [statusData, setStatusData] = useState({ status: 'queued', progress: 'Queued…', steps_done: 0 });
const [elapsed, setElapsed] = useState(0);
const [currentStepIdx, setCurrentStepIdx] = useState(0);
const pollRef = useRef(null);
// Elapsed timer
useEffect(() => {
const id = setInterval(() => setElapsed(e => +(e + 0.1).toFixed(1)), 100);
return () => clearInterval(id);
}, []);
// Poll /status every 1.5s
useEffect(() => {
if (!ctx?.job_id) return;
const poll = async () => {
try {
const resp = await fetch(`${API_BASE}/status/${ctx.job_id}`, {
headers: { 'Authorization': 'Bearer ' + (window.getIdToken ? window.getIdToken() : '') },
});
if (!resp.ok) return;
const data = await resp.json();
setStatusData(data);
// Derive current step index from progress message
const msg = (data.progress || '').toLowerCase();
const idx = STEPS.findIndex(s => msg.includes(s.t.toLowerCase().split(' ')[0]));
if (idx >= 0) setCurrentStepIdx(idx);
if (data.status === 'done') {
clearInterval(pollRef.current);
_fireScanNotifications(ctx.job_id, elapsed, data);
setTimeout(() => onDone(data), 400);
} else if (data.status === 'error') {
clearInterval(pollRef.current);
setTimeout(onError, 400);
}
} catch (_) {}
};
poll(); // immediate first poll
pollRef.current = setInterval(poll, 1500);
return () => clearInterval(pollRef.current);
}, [ctx?.job_id]);
const steps_done = statusData.steps_done || 0;
const steps_total = statusData.steps_total || STEPS.length;
const pct = Math.min(100, Math.round((steps_done / steps_total) * 100));
const currentStep = STEPS[Math.min(currentStepIdx, STEPS.length - 1)];
return (
Excavation in progress
elapsed {elapsed.toFixed(1)}s · scan_id {ctx?.job_id?.slice(0, 8) || '—'} · container fargate
Current stratum
§{currentStep.n}
{currentStep.t}
▮
{statusData.progress || currentStep.sub}
{/* Strata log */}
Fig. X.1 — Strata processed
polling every 1.5s
{STEPS.map((s, i) => {
const finished = i < currentStepIdx;
const active = i === currentStepIdx;
const pending = i > currentStepIdx;
return (
0 ? '1px solid var(--line)' : 'none',
display: 'grid', gridTemplateColumns: '32px 24px 1fr 200px 80px', gap: 16, alignItems: 'center',
opacity: pending ? 0.4 : 1,
}}>
§{s.n}
{finished ? '✓' : active ? '◈' : '◌'}
{s.t}
{s.sub}
{finished ? '✓' : active ? 'running…' : 'queued'}
);
})}
);
}
// ── State 4: Done — presigned download ───────────────────────────────────
function DoneState({ result, onReset, go }) {
const { toast } = useToast();
const [downloading, setDownloading] = useState(false);
const job_id = result?.job_id || '';
// Store in scan history on mount
useEffect(() => {
if (!job_id) return;
// Save as active job
localStorage.setItem('fossyl_active_job', job_id);
// Append to history (newest first, max 50)
try {
const prev = JSON.parse(localStorage.getItem('fossyl_job_history') || '[]');
const entry = {
job_id,
created_at: result?.created_at || new Date().toISOString(),
status: 'done',
report_size_kb: result?.report_size_kb || 0,
ttw_count: result?.ttw_count || 0,
artifact_count: result?.artifact_count || 0,
edge_count: result?.edge_count || 0,
diverged_count: result?.diverged_count || 0,
synced_count: result?.synced_count || 0,
};
// Don't duplicate
const filtered = prev.filter(j => j.job_id !== job_id);
localStorage.setItem('fossyl_job_history', JSON.stringify([entry, ...filtered].slice(0, 50)));
} catch {}
}, [job_id]);
const handleDownload = async () => {
setDownloading(true);
try {
const resp = await fetch(`${API_BASE}/download/${job_id}`, {
headers: { 'Authorization': 'Bearer ' + (window.getIdToken ? window.getIdToken() : '') },
});
if (!resp.ok) throw new Error('Download URL generation failed');
const data = await resp.json();
window.location.href = data.download_url;
} catch (e) {
toast('Download failed: ' + e.message, { tone: 'error' });
} finally {
setDownloading(false);
}
};
const ttw_count = result?.ttw_count || '—';
const artifacts = result?.artifact_count || '—';
const edges = result?.edge_count || '—';
const diverged = result?.diverged_count || '—';
const synced = result?.synced_count || '—';
const size_kb = result?.report_size_kb || '—';
const elapsed_s = result?.elapsed_s || '—';
return (
Excavation complete
Map generated. {elapsed_s !== '—' ? `${elapsed_s}s.` : '—'}
Report for excavation {job_id.slice(0, 8)}. Self-contained HTML file. {size_kb} KB.
scan_id
{job_id.slice(0, 8)}
Report stored per your plan retention. Re-downloadable from the Reports tab.
go('report')}>View in reports →
{downloading ? 'Generating link…' : 'Download excavation report →'}
);
}
// ── State 5: Error ────────────────────────────────────────────────────────
function ErrorState({ ctx, onRetry }) {
return (
Excavation aborted
scan_id {ctx?.job_id?.slice(0, 8) || '—'}
!
Analysis failed.
The excavation encountered an error. Your uploaded files have already been purged.
Check that your ZEXP file is complete and not truncated, then try again.
Recommended
Re-export the ZODB from ZMI with Export → "Download to local machine" and verify the file size.
Ensure the repository ZIP contains the full codebase, not a partial export.
Your uploaded files have already been purged. Nothing to clean up on your side.
This attempt is not counted against your plan quota.
Try again →
);
}
Object.assign(window, { UploadPage });