/* Pipeline screen */ function SchermPipeline({ gemeenten, kaartmateriaal, defaultGemeente }) { const [providers, setProviders] = useState({}); const [taken, setTaken] = useState([]); const [form, setForm] = useState(() => ({ gemeente_code: defaultGemeente || '', ai_provider: 'anthropic', ai_model: 'claude-sonnet-4-6', detectie_types: ['contour'] })); const [pipelineModus, setPipelineModus] = useState('perceel_scan'); const [perceelScan, setPerceelScan] = useState({ min_hoogte_m: '1.5', min_oppervlakte_m2: '5', pand_buffer_m: '0.8', gebruik_ahn: true }); const [gebiedScan, setGebiedScan] = useState({ tegel_grootte_m: '160', overlap_m: '20', foto_px: '1024', max_tegels: '', min_cv_score: '0.45', ai_confirmatie: true }); const [bezig, setBezig] = useState(false); const [fout, setFout] = useState(null); const [maxPanden, setMaxPanden] = useState(''); const [alleenInGebruik, setAlleenInGebruik] = useState(true); const [luchtfotoBronId, setLuchtfotoBronId] = useState(null); const [promptKeuze, setPromptKeuze] = useState({}); const [beschikbarePrompts, setBeschikbarePrompts] = useState({}); const [pandIds, setPandIds] = useState(''); const [perceelIds, setPerceelIds] = useState(''); const [perceelControle, setPerceelControle] = useState(null); const [perceelControleBezig, setPerceelControleBezig] = useState(false); const [perceelBackfill, setPerceelBackfill] = useState(null); const [perceelBackfillBezig, setPerceelBackfillBezig] = useState(false); const [traceAan, setTraceAan] = useState(false); const [traceMaxPanden, setTraceMaxPanden] = useState('0'); const [tracePandIds, setTracePandIds] = useState(''); const [lopendeTaakId, setLopendeTaakId] = useState(null); const [lopendeTaakStatus, setLopendeTaakStatus] = useState(null); const [voortgang, setVoortgang] = useState({verwerkt: 0, totaal: 0}); const [logRegels, setLogRegels] = useState([]); const [logSeq, setLogSeq] = useState(0); const [ollamaModellen, setOllamaModellen] = useState([]); const [ollamaLaden, setOllamaLaden] = useState(false); const [ollamaFout, setOllamaFout] = useState(null); const [providerModellen, setProviderModellen] = useState(window.MODELLEN || {}); const logRef = React.useRef(null); const esRef = React.useRef(null); const laadTaken = useCallback(async () => { const data = await fetch(`${API}/pipeline`).then(r => r.json()).catch(() => []); setTaken(data); }, []); useEffect(() => { if (!lopendeTaakId || lopendeTaakStatus !== 'bezig') return; const timer = setInterval(async () => { try { const r = await fetch(`${API}/pipeline/${lopendeTaakId}/status`); if (!r.ok) return; const data = await r.json(); setVoortgang(data.voortgang || {verwerkt: 0, totaal: 0}); setLopendeTaakStatus(data.status); if (data.status !== 'bezig') laadTaken(); } catch (e) {} }, 2000); return () => clearInterval(timer); }, [lopendeTaakId, lopendeTaakStatus, laadTaken]); // SSE log stream useEffect(() => { const es = new EventSource(`${API}/logs/stream?since=0`); esRef.current = es; es.onmessage = (e) => { try { const rec = JSON.parse(e.data); setLogRegels(prev => { const nieuw = [...prev, rec]; return nieuw.length > 500 ? nieuw.slice(-500) : nieuw; }); setLogSeq(rec.seq); } catch (_) {} }; return () => es.close(); }, []); useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [logRegels]); function exportLogs() { if (logRegels.length === 0) return; const inhoud = logRegels.map(r => { const tijd = r.timestamp ? r.timestamp.replace('T', ' ').slice(0, 19) : ''; const level = r.level || 'INFO'; return `${tijd} [${level}] ${r.message || ''}`.trim(); }).join('\n'); const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const blob = new Blob([inhoud + '\n'], {type: 'text/plain;charset=utf-8'}); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `pipeline-logs-${stamp}.txt`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } useEffect(() => { fetch(`${API}/config/providers`).then(r => r.json()).then(setProviders).catch(() => {}); laadTaken(); fetch(`${API}/config/standaard`).then(r => r.ok ? r.json() : null).then(d => { if (d && d.provider) { setForm(p => ({ ...p, ai_provider: d.provider, ai_model: d.model || p.ai_model })); } }).catch(() => {}); fetch(`${API}/config/providers/models`).then(r => r.ok ? r.json() : null).then(d => { if (d) { setProviderModellen(d); window.MODELLEN = { ...window.MODELLEN, ...d }; } }).catch(() => {}); }, []); useEffect(() => { const bron = kaartmateriaal.find(k => k.voor_pipeline); if (bron && !luchtfotoBronId) setLuchtfotoBronId(bron.id); }, [kaartmateriaal]); useEffect(() => { form.detectie_types.forEach(type => { fetch(`${API}/prompts?detectie_type=${type}`).then(r => r.json()) .then(data => setBeschikbarePrompts(prev => ({...prev, [type]: data}))).catch(() => {}); }); }, [form.detectie_types]); const laadOllamaModellen = useCallback(async () => { setOllamaLaden(true); setOllamaFout(null); try { const data = await fetch(`${API}/config/providers/ollama/models`) .then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))) const modellen = data.models || []; setOllamaModellen(modellen); if (modellen.length > 0) { const voorkeur = modellen.includes('qwen3-vl:latest') ? 'qwen3-vl:latest' : modellen[0]; setForm(p => p.ai_provider === 'ollama' && !modellen.includes(p.ai_model) ? {...p, ai_model: voorkeur} : p); } } catch(e) { setOllamaModellen([]); setOllamaFout(e.message || 'Ollama-modellen ophalen mislukt'); } finally { setOllamaLaden(false); } }, []); useEffect(() => { if (form.ai_provider === 'ollama') laadOllamaModellen(); }, [form.ai_provider, laadOllamaModellen]); const modelOpties = form.ai_provider === 'ollama' && ollamaModellen.length > 0 ? ollamaModellen : (providerModellen[form.ai_provider] || []); async function stopTaak(taak) { if (!confirm(`Taak #${taak.id} stoppen? Lopend pand wordt afgemaakt, daarna stopt de pipeline.`)) return; const r = await fetch(`${API}/pipeline/${taak.id}/stop`, { method: 'POST' }); if (r.ok) { await laadTaken(); setLopendeTaakStatus('gestopt'); } else { const e = await r.json(); alert(e.detail || 'Stoppen mislukt'); } } async function hervatTaak(taak) { const r = await fetch(`${API}/pipeline/${taak.id}/hervat`, { method: 'POST' }); if (r.ok) { const data = await r.json(); setLopendeTaakId(data.taak_id); setLopendeTaakStatus('bezig'); setVoortgang(taak.voortgang || { verwerkt: 0, totaal: 0 }); setPipelineModus(taak.modus || 'pand_scan'); await laadTaken(); } else { const e = await r.json().catch(() => ({})); alert(e.detail || 'Hervatten mislukt'); } } async function verwijderTaak(taak) { const bezig = taak.status === 'bezig'; const msg = bezig ? `Taak #${taak.id} is nog bezig! Forceer verwijderen inclusief alle resultaten en afbeeldingen?` : `Taak #${taak.id} (${taak.gemeente_code}) verwijderen inclusief alle resultaten en afbeeldingen?`; if (!confirm(msg)) return; const url = bezig ? `${API}/pipeline/${taak.id}?force=true` : `${API}/pipeline/${taak.id}`; const r = await fetch(url, { method: 'DELETE' }); if (r.ok) laadTaken(); else { const e = await r.json(); alert(e.detail || 'Verwijderen mislukt'); } } function toggleDetectieType(type) { if (pipelineModus === 'gebied_scan' && type !== 'zonnepanelen') return; setForm(prev => ({ ...prev, detectie_types: prev.detectie_types.includes(type) ? prev.detectie_types.filter(t => t !== type) : [...prev.detectie_types, type] })); } function wijzigPipelineModus(modus) { setPipelineModus(modus); if (modus === 'gebied_scan') { setForm(prev => ({...prev, detectie_types: ['zonnepanelen']})); setPerceelIds(''); setPerceelControle(null); } } function parseIdLijst(waarde) { return waarde.split(/[\n,;]+/).map(s => s.trim()).filter(Boolean); } function perceelBackfillScopeTekst(status) { const scope = status?.scope || {}; if (scope.type === 'pand_ids') return `${fmtNum(scope.pand_ids_count || 0)} pand-ID's`; if (scope.type === 'max_panden') return `max. ${fmtNum(scope.max_panden || 0)} panden`; if (scope.type === 'hele_gemeente') return 'hele gemeente'; return status?.gemeente_code ? 'laatste run' : 'onbekend'; } async function controleerPerceelIds() { const perceelIdLijst = parseIdLijst(perceelIds); if (perceelIdLijst.length === 0) { setPerceelControle(null); setFout('Vul eerst perceel-ID\'s in'); return; } setFout(null); setPerceelControleBezig(true); try { const r = await fetch(`${API}/percelen/controle`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({perceel_ids: perceelIdLijst}), }); if (!r.ok) { const e = await r.json(); throw new Error(e.detail || 'Perceel-ID controle mislukt'); } setPerceelControle(await r.json()); } catch(e) { setFout(e.message); } finally { setPerceelControleBezig(false); } } async function laadPerceelBackfillStatus() { try { const data = await fetch(`${API}/percelen/backfill/status`).then(r => r.json()); setPerceelBackfill(data); } catch(e) {} } async function startPerceelBackfill() { if (!form.gemeente_code) { setFout('Selecteer eerst een gemeente'); return; } setFout(null); setPerceelBackfillBezig(true); try { const params = new URLSearchParams({ gemeente_code: form.gemeente_code }); const ids = parseIdLijst(pandIds); if (ids.length > 0) params.append('pand_ids', JSON.stringify(ids)); else if (maxPanden && parseInt(maxPanden, 10) > 0) params.append('max_panden', parseInt(maxPanden, 10)); params.append('alleen_in_gebruik', alleenInGebruik ? 'true' : 'false'); const r = await fetch(`${API}/percelen/backfill?${params}`, { method: 'POST' }); if (!r.ok) { const e = await r.json(); throw new Error(e.detail || 'Perceelnummers vullen mislukt'); } await laadPerceelBackfillStatus(); } catch(e) { setFout(e.message); } finally { setPerceelBackfillBezig(false); } } async function startPipeline() { if (!form.gemeente_code) { setFout('Selecteer een gemeente'); return; } if (form.detectie_types.length === 0) { setFout('Selecteer minimaal één detectietype'); return; } setFout(null); setBezig(true); // Alles in een JSON-body: bij honderden perceel-/pand-ID's wordt een query-string // te lang (URL-limiet proxy/uvicorn) → "Failed to fetch". const payload = { gemeente_code: form.gemeente_code, ai_provider: form.ai_provider, ai_model: form.ai_model, modus: pipelineModus, detectie_types: form.detectie_types, alleen_in_gebruik: alleenInGebruik, trace: traceAan, }; const ids = parseIdLijst(pandIds); const perceelIdLijst = parseIdLijst(perceelIds); if (ids.length > 0 && perceelIdLijst.length === 0) { payload.pand_ids = ids; } else if (perceelIdLijst.length === 0 && maxPanden && parseInt(maxPanden, 10) > 0) { payload.max_panden = parseInt(maxPanden, 10); } if (luchtfotoBronId) payload.kaartmateriaal_id = luchtfotoBronId; if (traceAan && parseInt(traceMaxPanden, 10) > 0) { payload.trace_max_panden = parseInt(traceMaxPanden, 10); } const traceIds = tracePandIds.split(/[\n,;]+/).map(s => s.trim()).filter(Boolean); if (traceAan && traceIds.length > 0) { payload.trace_pand_ids = traceIds; } const promptIds = {}; Object.entries(promptKeuze).forEach(([type, id]) => { if (id && form.detectie_types.includes(type)) promptIds[type] = id; }); if (Object.keys(promptIds).length > 0) payload.prompt_ids = promptIds; if (pipelineModus === 'perceel_scan') { payload.selectie = perceelIdLijst.length > 0 ? { type: 'perceel_ids', perceel_ids: perceelIdLijst } : { type: ids.length > 0 ? 'pand_ids' : 'gemeente', pand_ids: ids }; payload.perceel_scan = { min_hoogte_m: parseFloat(perceelScan.min_hoogte_m) || 2.0, min_oppervlakte_m2: parseFloat(perceelScan.min_oppervlakte_m2) || 6, pand_buffer_m: parseFloat(perceelScan.pand_buffer_m) || 0.8, gebruik_ahn: perceelScan.gebruik_ahn !== false, }; } if (pipelineModus === 'gebied_scan') { payload.selectie = ids.length > 0 ? { type: 'pand_ids', pand_ids: ids } : { type: 'gemeente' }; const gebiedPayload = { tegel_grootte_m: parseFloat(gebiedScan.tegel_grootte_m) || 160, overlap_m: parseFloat(gebiedScan.overlap_m) || 20, foto_px: parseInt(gebiedScan.foto_px, 10) || 1024, min_cv_score: parseFloat(gebiedScan.min_cv_score) || 0.45, ai_confirmatie: !!gebiedScan.ai_confirmatie, }; const maxTegels = parseInt(gebiedScan.max_tegels, 10); if (maxTegels > 0) gebiedPayload.max_tegels = maxTegels; payload.gebied_scan = gebiedPayload; } try { const r = await fetch(`${API}/pipeline`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!r.ok) { const e = await r.json(); throw new Error(e.detail || 'Pipeline starten mislukt'); } const data = await r.json(); setLopendeTaakId(data.taak_id); setLopendeTaakStatus('bezig'); setVoortgang({verwerkt: 0, totaal: 0}); await laadTaken(); } catch(e) { setFout(e.message); } setBezig(false); } const pct = voortgang.totaal ? Math.round(100 * voortgang.verwerkt / voortgang.totaal) : 0; return (
Start en monitor AI-detectietaken.
/api/config/providers| Taak | Gemeente | Modus | Provider | Model | Types | Status | Voortgang | Kandidaten | Objecten | Verdacht | Zon | Gestart | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| #{t.id} | {t.gemeente_code} | {t.ai_provider} | {t.ai_model} | {(t.detectie_types||[]).join(', ')} |
{t.status==='klaar' && |
{v.totaal != null ? (
<>
{v.verwerkt ?? 0}/{v.totaal}
>
) : '—'}
|
{t.modus === 'gebied_scan' && typeof v.kandidaten === 'number' ? fmtNum(v.kandidaten) : '—'} | {objectModus ? fmtNum(objectStats.objecten || 0) : '—'} | {objectModus ? fmtNum(objectStats.contourafwijking || 0) : '—'} | {objectModus ? fmtNum(objectStats.zonnepanelen || 0) : '—'} | {fmtDateTime(t.gestart_op)} |
{t.status==='bezig' && (
|