// 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.
)} {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 && (
Artifacts to analyse
setIntentTop(Math.min(20, Math.max(1, +e.target.value)))} style={{ width: 100, padding: '8px 12px' }}/> max 20
Selection strategy Top-N by coupling × churn auto — modify in scan settings after run
)} {}} right={+ ~2s} /> {sqlFile && (
DB table pattern setDbPattern(e.target.value)} style={{ padding: '8px 12px' }}/>
Instance count setDbCount(Math.max(1, +e.target.value))} style={{ padding: '8px 12px' }}/>
)}
{/* 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.
); } // ── 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 ? <> : }
); } function ToggleRow({ label, sub, on, setOn, right }) { return (
{label}
{sub}
{right}
); } function SummaryLine({ label, value }) { return (
{label}
{value}
); } // ── 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}
Progress
{pct}%
{/* 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.
Run another excavation →
); } // ── 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.
); } Object.assign(window, { UploadPage });