add schedule and AI analysis to discovery workspace
CI / compose (push) Failing after 5m41s

This commit is contained in:
Marco0300
2026-09-04 21:46:02 +02:00
parent 73ababe90c
commit 7d3c1ab0c9
3 changed files with 51 additions and 7 deletions
+48 -4
View File
@@ -323,16 +323,59 @@
const health = sourceRows.length ? '<section class="source-health"><div class="subheading"><h4>Source health</h4><span class="small-label">' + sourceRows.length + ' sources</span></div><div class="source-health-grid">' + sourceRows.map(source => '<article><strong>' + esc(source.name || source.source_name || ('Source ' + (source.source_id || ''))) + '</strong><span class="source-health-status ' + esc(source.status || source.health || 'unknown') + '">' + esc(source.status || source.health || 'Unknown') + '</span><small>' + esc(source.result_count ?? source.results ?? 0) + ' results · ' + esc(source.error || 'No errors') + '</small></article>').join('') + '</div></section>' : '<section class="source-health"><div class="subheading"><h4>Source health</h4></div><p class="muted">Per-source health will appear when the API returns source telemetry.</p></section>'; const health = sourceRows.length ? '<section class="source-health"><div class="subheading"><h4>Source health</h4><span class="small-label">' + sourceRows.length + ' sources</span></div><div class="source-health-grid">' + sourceRows.map(source => '<article><strong>' + esc(source.name || source.source_name || ('Source ' + (source.source_id || ''))) + '</strong><span class="source-health-status ' + esc(source.status || source.health || 'unknown') + '">' + esc(source.status || source.health || 'Unknown') + '</span><small>' + esc(source.result_count ?? source.results ?? 0) + ' results · ' + esc(source.error || 'No errors') + '</small></article>').join('') + '</div></section>' : '<section class="source-health"><div class="subheading"><h4>Source health</h4></div><p class="muted">Per-source health will appear when the API returns source telemetry.</p></section>';
const log = events.length ? events.map(event => '<li><span class="timeline-dot"></span><div><strong>' + esc(event.message || event.type || 'Run event') + '</strong><small>' + esc(event.created_at || event.timestamp || '') + '</small></div></li>').join('') : '<li class="muted">No persisted events returned yet.</li>'; const log = events.length ? events.map(event => '<li><span class="timeline-dot"></span><div><strong>' + esc(event.message || event.type || 'Run event') + '</strong><small>' + esc(event.created_at || event.timestamp || '') + '</small></div></li>').join('') : '<li class="muted">No persisted events returned yet.</li>';
const stats = '<div class="discovery-stat-grid"><div><small>Progress</small><strong>' + progress + '%</strong></div><div><small>Records</small><strong>' + esc(run.result_count ?? candidates.length) + '</strong></div><div><small>Daily limit</small><strong>' + esc(run.daily_limit ?? '—') + '</strong></div><div><small>Errors</small><strong>' + (Array.isArray(errors) ? errors.length : 0) + '</strong></div></div>'; const stats = '<div class="discovery-stat-grid"><div><small>Progress</small><strong>' + progress + '%</strong></div><div><small>Records</small><strong>' + esc(run.result_count ?? candidates.length) + '</strong></div><div><small>Daily limit</small><strong>' + esc(run.daily_limit ?? '—') + '</strong></div><div><small>Errors</small><strong>' + (Array.isArray(errors) ? errors.length : 0) + '</strong></div></div>';
const cards = candidates.length ? candidates.slice(0, 50).map(item => '<article class="discovery-candidate"><strong>' + esc(item.name || item.business_name || item.title || 'Unnamed candidate') + '</strong><span>' + esc(item.website || item.url || item.domain || 'No URL returned') + '</span><small>' + esc(item.source_url || item.source || 'Source provenance unavailable') + ' · ' + esc(item.confidence || item.score || 'Confidence unknown') + '</small></article>').join('') : '<p class="muted">No candidates returned by this run.</p>'; const cards = candidates.length ? candidates.slice(0, 50).map(item => {
const score = item.score ?? item.confidence ?? '—';
const provenance = item.source_url || item.source || 'Source provenance unavailable';
const sourceName = item.source_name || item.source || 'Unknown source';
const needsReview = item.review_status === 'pending' || item.human_review_required;
return '<article class="discovery-candidate">' +
'<strong>' + esc(item.name || item.business_name || item.title || 'Unnamed candidate') + '</strong>' +
'<span>' + esc(item.website || item.url || item.domain || 'No URL returned') + '</span>' +
'<small>Source: ' + esc(sourceName) + ' · Score: ' + esc(String(score)) + '</small>' +
'<small>Provenance: ' + esc(provenance) + '</small>' +
(needsReview ? '<small class="review-flag">⚠ Human review required</small>' : '') +
'<button class="button ghost compact" type="button" data-ai-assess="' + esc(String(item.id || item.business_id || '')) + '">AI analysis</button>' +
'</article>';
}).join('') : '<p class="muted">No candidates returned by this run.</p>';
if (['running','queued','paused'].includes(status)) { el.innerHTML = controls + stats + '<p>' + esc(job?.progress_message || 'Processing approved sources within bounded limits.') + '</p><div class="progress-track"><span style="width:' + progress + '%"></span></div><div class="discovery-two-col"><section class="live-log"><h4>Live log</h4><ol>' + log + '</ol></section>' + health + '</div>'; return; } if (['running','queued','paused'].includes(status)) { el.innerHTML = controls + stats + '<p>' + esc(job?.progress_message || 'Processing approved sources within bounded limits.') + '</p><div class="progress-track"><span style="width:' + progress + '%"></span></div><div class="discovery-two-col"><section class="live-log"><h4>Live log</h4><ol>' + log + '</ol></section>' + health + '</div>'; return; }
const partial = status === 'partial' || run.partial === true || result.partial === true; const partial = status === 'partial' || run.partial === true || result.partial === true;
el.innerHTML = controls + (partial ? '<div class="discovery-partial" role="status"><strong>Partial results</strong><p>Review provenance before using any candidate.</p></div>' : '') + (status === 'failed' ? '<div class="detail-error" role="alert"><strong>Discovery failed</strong><p>' + esc(job?.error?.message || job?.message || run.error || 'The job returned no safe result.') + '</p></div>' : '') + stats + health + '<div class="discovery-two-col"><section class="live-log"><h4>Run log</h4><ol>' + log + '</ol></section><section><h4>Results</h4><div class="discovery-candidate-list">' + cards + '</div></section></div>'; el.innerHTML = controls + (partial ? '<div class="discovery-partial" role="status"><strong>Partial results</strong><p>Review provenance before using any candidate.</p></div>' : '') + (status === 'failed' ? '<div class="detail-error" role="alert"><strong>Discovery failed</strong><p>' + esc(job?.error?.message || job?.message || run.error || 'The job returned no safe result.') + '</p></div>' : '') + stats + health + '<div class="discovery-two-col"><section class="live-log"><h4>Run log</h4><ol>' + log + '</ol></section><section><h4>Results</h4><div class="discovery-candidate-list">' + cards + '</div></section></div>';
} }
async function discoveryAction(action,run){if(!run?.id)return;const path=action==='cancel'?`/api/v1/discovery-runs/${encodeURIComponent(run.id)}/cancel`:action==='retry'&&run.job_id?`/api/v1/jobs/${encodeURIComponent(run.job_id)}/retry`:null;if(!path){message('directDiscoveryMessage','This action is not available for the selected run.',true);return;}message('directDiscoveryMessage',action==='cancel'?'Cancelling discovery run…':'Retrying failed discovery job…');try{await jsonRequest(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});message('directDiscoveryMessage',action==='cancel'?'Discovery run cancelled.':'Discovery job retry accepted.');await loadDiscoveryRuns();}catch(error){if(error.message!=='unauthorized')message('directDiscoveryMessage',error.message||`Unable to ${action} discovery run.`,true);}} async function assessCandidate(bid, item) {
if (!bid) { message('directDiscoveryMessage', 'No candidate selected for AI analysis.', true); return; }
message('directDiscoveryMessage', 'Requesting evidence-grounded AI opportunity assessment…');
try {
const result = await jsonRequest(`/api/v1/businesses/${encodeURIComponent(bid)}/ai/opportunity-assessment`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({})
});
const a = result.assessment || {};
const lines = [
'<div class="ai-assessment">',
'<h4>AI opportunity assessment</h4>',
'<div class="ai-score-grid"><div><small>Opportunity</small><strong>' + esc(String(a.opportunity_score ?? '—')) + '</strong></div>',
'<div><small>Confidence</small><strong>' + esc(String(a.confidence_score ?? '—')) + '</strong></div>',
'<div><small>Recommendation</small><strong>' + esc(String(a.recommendation ?? '—')) + '</strong></div>',
'<div><small>Priority</small><strong>' + esc(String(a.priority ?? '—')) + '</strong></div></div>',
'<p><strong>Reasons:</strong> ' + esc((a.reasons || []).join(', ') || 'None recorded') + '</p>',
'<p><strong>Missing evidence:</strong> ' + esc((a.missing_evidence || []).join(', ') || 'None recorded') + '</p>',
'<p><strong>Website:</strong> ' + esc((a.website_assessment || {}).status || 'unknown') + '</p>',
'<p><strong>Domain:</strong> ' + esc((a.domain_assessment || {}).status || 'unknown') + '</p>',
'<p><strong>Contactability:</strong> ' + esc((a.contactability || {}).contact_type || 'unknown') + '</p>',
'<p><strong>Human review required:</strong> ' + (a.human_review_required ? 'Yes' : 'No') + '</p>',
'<small>Network send: ' + String(result.network_send) + ' · Automatic outreach: ' + String(result.automatic_outreach) + '</small>',
'</div>',
].join('');
$('directDiscoveryResultState').innerHTML = lines + '<p>' + esc(item.name || 'Candidate') + ' · assessment complete. Review the evidence before any action.</p>';
} catch (error) {
if (error.message !== 'unauthorized') message('directDiscoveryMessage', error.message || 'Unable to complete AI analysis.', true);
}
}
async function discoveryAction(action,run){if(!run?.id)return;const path=action==='cancel'?`/api/v1/discovery-runs/${encodeURIComponent(run.id)}/cancel`:action==='retry'&&run.job_id?`/api/v1/jobs/${encodeURIComponent(run.job_id)}/retry`:null;if(!path){message('directDiscoveryMessage','This action is not available for the selected run.',true);return;}message('directDiscoveryMessage',action==='cancel'?'Cancelling discovery run…':'Retrying failed discovery job…');try{await jsonRequest(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});message('directDiscoveryMessage',action==='cancel'?'Discovery run cancelled.':'Discovery job retry accepted.');await loadDiscoveryRuns();}catch(error){if(error.message!=='unauthorized')message('directDiscoveryMessage',error.message||`Unable to ${action} discovery run.`,true);}}
async function loadDiscoveryRun(run) { selectedDiscoveryRunId=run.id; renderDiscoveryResult(run); try { const job=await jsonRequest(`/api/v1/jobs/${encodeURIComponent(run.job_id)}`); renderDiscoveryResult(run,job); if(['queued','running','paused'].includes(discoveryStatus(job))){clearTimeout(discoveryPollTimer);discoveryPollTimer=setTimeout(loadDiscoveryRuns,3000);} } catch(error){if(error.message!=='unauthorized')renderDiscoveryResult(run,{status:'error',message:error.message});} } async function loadDiscoveryRun(run) { selectedDiscoveryRunId=run.id; renderDiscoveryResult(run); try { const job=await jsonRequest(`/api/v1/jobs/${encodeURIComponent(run.job_id)}`); renderDiscoveryResult(run,job); if(['queued','running','paused'].includes(discoveryStatus(job))){clearTimeout(discoveryPollTimer);discoveryPollTimer=setTimeout(loadDiscoveryRuns,3000);} } catch(error){if(error.message!=='unauthorized')renderDiscoveryResult(run,{status:'error',message:error.message});} }
function renderDiscoveryRuns() { const el=$('directDiscoveryRunsState'); if(!discoveryRuns.length){el.innerHTML='<div class="discovery-empty">No discovery runs yet. Start with a market brief and approved sources.</div>';return;} el.innerHTML=discoveryRuns.map(run=>`<button class="discovery-run-row ${String(run.id)===String(selectedDiscoveryRunId)?'selected':''}" type="button" data-discovery-run-id="${esc(run.id)}"><span><strong>Run ${esc(run.id)}</strong><small>${esc(run.criteria?.keywords||run.criteria?.keyword||'Discovery brief')} · ${esc(run.criteria?.city||run.criteria?.location||'Global')}</small></span><span class="discovery-run-status ${esc(discoveryStatus(run))}">${esc(discoveryLabel(discoveryStatus(run)))}</span><small>${esc(run.result_count??discoveryCandidates(run).length)} records</small></button>`).join(''); } function renderDiscoveryRuns() { const el=$('directDiscoveryRunsState'); if(!discoveryRuns.length){el.innerHTML='<div class="discovery-empty">No discovery runs yet. Start with a market brief and approved sources.</div>';return;} el.innerHTML=discoveryRuns.map(run=>`<button class="discovery-run-row ${String(run.id)===String(selectedDiscoveryRunId)?'selected':''}" type="button" data-discovery-run-id="${esc(run.id)}"><span><strong>Run ${esc(run.id)}</strong><small>${esc(run.criteria?.keywords||run.criteria?.keyword||'Discovery brief')} · ${esc(run.criteria?.city||run.criteria?.location||'Global')}</small></span><span class="discovery-run-status ${esc(discoveryStatus(run))}">${esc(discoveryLabel(discoveryStatus(run)))}</span><small>${esc(run.result_count??discoveryCandidates(run).length)} records</small></button>`).join(''); }
async function loadDiscoveryRuns() { const state=$('directDiscoveryRunsState'); if(state)state.innerHTML='<div class="detail-loading" aria-live="polite">Loading discovery runs…</div>'; try { const payload=await jsonRequest('/api/v1/discovery-runs?page_size=50'); discoveryRuns=payloadItems(payload,['items','runs']); const jobsById=new Map(await Promise.all(discoveryRuns.map(async run=>{try{return [String(run.job_id),await jsonRequest(`/api/v1/jobs/${encodeURIComponent(run.job_id)}`)]}catch{return [String(run.job_id),{}]}}))); discoveryRuns=discoveryRuns.map(run=>({...run,status:jobsById.get(String(run.job_id))?.status||run.status||'queued'})); renderDiscoveryRuns(); const selected=discoveryRuns.find(run=>String(run.id)===String(selectedDiscoveryRunId))||discoveryRuns[0]; if(selected)loadDiscoveryRun(selected); } catch(error) { if(error.message!=='unauthorized'&&state)state.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load discovery runs</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryDiscoveryRunsBtn" type="button">Try again</button></div>`; } } async function loadDiscoveryRuns() { const state=$('directDiscoveryRunsState'); if(state)state.innerHTML='<div class="detail-loading" aria-live="polite">Loading discovery runs…</div>'; try { const payload=await jsonRequest('/api/v1/discovery-runs?page_size=50'); discoveryRuns=payloadItems(payload,['items','runs']); const jobsById=new Map(await Promise.all(discoveryRuns.map(async run=>{try{return [String(run.job_id),await jsonRequest(`/api/v1/jobs/${encodeURIComponent(run.job_id)}`)]}catch{return [String(run.job_id),{}]}}))); discoveryRuns=discoveryRuns.map(run=>({...run,status:jobsById.get(String(run.job_id))?.status||run.status||'queued'})); renderDiscoveryRuns(); const selected=discoveryRuns.find(run=>String(run.id)===String(selectedDiscoveryRunId))||discoveryRuns[0]; if(selected)loadDiscoveryRun(selected); } catch(error) { if(error.message!=='unauthorized'&&state)state.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load discovery runs</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryDiscoveryRunsBtn" type="button">Try again</button></div>`; } }
async function submitDirectDiscovery(event){event.preventDefault();const form=event.currentTarget,button=$('directDiscoveryRunBtn'),fields=Object.fromEntries(new FormData(form).entries()),include=(fields.include_websites||'').split(/\r?\n|,/).map(v=>v.trim()).filter(Boolean),exclude=(fields.exclude_websites||'').split(/\r?\n|,/).map(v=>v.trim()).filter(Boolean),dryRun=$('directDiscoveryDryRun')?.checked;if(!fields.category.trim()||!fields.keywords.trim()){message('directDiscoveryMessage','Enter a category and keywords before starting discovery.',true);return;}if(!selectedSourceIds().length){message('directDiscoveryMessage','Select at least one enabled source before starting a discovery run.',true);return;}const criteria={category:fields.category.trim(),keywords:fields.keywords.trim(),city:fields.city.trim(),province:fields.province.trim(),country:fields.country.trim(),language:fields.language.trim()};const payload={criteria,source_ids:selectedSourceIds(),daily_limit:Number(fields.daily_limit),max_pages:Number(fields.max_pages),max_results:Number(fields.max_results),dry_run:Boolean(dryRun),website_analysis:{priorities:fields.priorities.trim(),include_websites:include,exclude_websites:exclude},idempotency_key:`discovery-${Date.now()}-${Math.random().toString(36).slice(2)}`};if(dryRun){message('directDiscoveryMessage',`Dry run validated: ${payload.source_ids.length} enabled sources, ${payload.max_results} max results. No job created.`);return;}button.disabled=true;message('directDiscoveryMessage','Starting bounded discovery job…');try{const job=await jsonRequest('/api/v1/discovery',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});message('directDiscoveryMessage','Discovery accepted. Tracking job status and partial results below.');await loadDiscoveryRuns();const run=discoveryRuns.find(item=>String(item.job_id)===String(job.id));if(run)loadDiscoveryRun(run);}catch(error){if(error.message!=='unauthorized')message('directDiscoveryMessage',error.message||'Unable to start discovery run.',true);}finally{button.disabled=false;}} async function submitDirectDiscovery(event){event.preventDefault();const form=event.currentTarget,button=$('directDiscoveryRunBtn'),fields=Object.fromEntries(new FormData(form).entries()),include=(fields.include_websites||'').split(/\r?\n|,/).map(v=>v.trim()).filter(Boolean),exclude=(fields.exclude_websites||'').split(/\r?\n|,/).map(v=>v.trim()).filter(Boolean),dryRun=$('directDiscoveryDryRun')?.checked;if(!fields.category.trim()||!fields.keywords.trim()){message('directDiscoveryMessage','Enter a category and keywords before starting discovery.',true);return;}if(!selectedSourceIds().length){message('directDiscoveryMessage','Select at least one enabled source before starting a discovery run.',true);return;}const criteria={category:fields.category.trim(),keywords:fields.keywords.trim(),city:fields.city.trim(),province:fields.province.trim(),country:fields.country.trim(),language:fields.language.trim()};const payload={criteria,source_ids:selectedSourceIds(),daily_limit:Number(fields.daily_limit),max_pages:Number(fields.max_pages),max_results:Number(fields.max_results),dry_run:Boolean(dryRun),website_analysis:{priorities:fields.priorities.trim(),include_websites:include,exclude_websites:exclude},idempotency_key:`discovery-${Date.now()}-${Math.random().toString(36).slice(2)}`};payload.schedule=fields.schedule||'run_now';if(dryRun){message('directDiscoveryMessage',`Dry run validated: ${payload.source_ids.length} enabled sources, ${payload.max_results} max results, schedule: ${payload.schedule}. No job created.`);return;}button.disabled=true;message('directDiscoveryMessage','Starting bounded discovery job…');try{const job=await jsonRequest('/api/v1/discovery',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});message('directDiscoveryMessage','Discovery accepted. Tracking job status and partial results below.');await loadDiscoveryRuns();const run=discoveryRuns.find(item=>String(item.job_id)===String(job.id));if(run)loadDiscoveryRun(run);}catch(error){if(error.message!=='unauthorized')message('directDiscoveryMessage',error.message||'Unable to start discovery run.',true);}finally{button.disabled=false;}}
function parseCsv(text){const lines=text.trim().split(/\r?\n/).filter(Boolean),cells=line=>line.match(/("[^"]*(?:""[^"]*)*"|[^,]+)(?=,|$)/g)?.map(x=>x.replace(/^"|"$/g,'').replaceAll('""','"'))||[];if(!lines.length)return[];const headers=cells(lines[0]);return lines.slice(1,11).map(l=>Object.fromEntries(cells(l).map((v,i)=>[headers[i]||`column_${i+1}`,v])));} function parseCsv(text){const lines=text.trim().split(/\r?\n/).filter(Boolean),cells=line=>line.match(/("[^"]*(?:""[^"]*)*"|[^,]+)(?=,|$)/g)?.map(x=>x.replace(/^"|"$/g,'').replaceAll('""','"'))||[];if(!lines.length)return[];const headers=cells(lines[0]);return lines.slice(1,11).map(l=>Object.fromEntries(cells(l).map((v,i)=>[headers[i]||`column_${i+1}`,v])));}
function renderCsv(rows){if(!rows.length){$('csvPreview').innerHTML='<span>⊞</span><p>No data rows found</p>';return;}const h=Object.keys(rows[0]);$('csvPreview').className='csv-table';$('csvPreview').innerHTML=`<table><thead><tr>${h.map(x=>`<th>${esc(x)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${h.map(x=>`<td>${esc(r[x])}</td>`).join('')}</tr>`).join('')}</tbody></table><small class="muted">Showing up to 10 rows · Preview only; nothing added yet.</small>`;} function renderCsv(rows){if(!rows.length){$('csvPreview').innerHTML='<span>⊞</span><p>No data rows found</p>';return;}const h=Object.keys(rows[0]);$('csvPreview').className='csv-table';$('csvPreview').innerHTML=`<table><thead><tr>${h.map(x=>`<th>${esc(x)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${h.map(x=>`<td>${esc(r[x])}</td>`).join('')}</tr>`).join('')}</tbody></table><small class="muted">Showing up to 10 rows · Preview only; nothing added yet.</small>`;}
@@ -341,7 +384,8 @@
async function bootstrap(){let res;try{res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});}catch(e){showLogin('Unable to connect to the workspace. Try again.');return;}if(res.status===401){showLogin();return;}if(!res.ok){showLogin('Unable to verify the workspace session. Try again.');return;}let user;try{user=await res.json();}catch(e){showLogin('Unable to read the workspace session. Try again.');return;}showDashboard(user.user||user);const panelLoads=[loadData(),loadJobs(),loadSources(),loadDiscoveryRuns(),loadScorePanels(),loadSavedFilters(),loadReviewQueue(),loadCrmPipeline(),loadReports(),loadSuppressions(),loadProviderPolicy(),loadAiProviderSettings()];const results=await Promise.allSettled(panelLoads);const rejected=results.filter(result=>result.status==='rejected');if(rejected.length){console.error('Workspace dashboard panels failed',rejected);$('apiStatus').textContent='● Some panels need refresh';$('apiStatus').classList.remove('live');setExplorerState('Some workspace panels could not be loaded. Use Refresh to retry.',true);}} async function bootstrap(){let res;try{res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});}catch(e){showLogin('Unable to connect to the workspace. Try again.');return;}if(res.status===401){showLogin();return;}if(!res.ok){showLogin('Unable to verify the workspace session. Try again.');return;}let user;try{user=await res.json();}catch(e){showLogin('Unable to read the workspace session. Try again.');return;}showDashboard(user.user||user);const panelLoads=[loadData(),loadJobs(),loadSources(),loadDiscoveryRuns(),loadScorePanels(),loadSavedFilters(),loadReviewQueue(),loadCrmPipeline(),loadReports(),loadSuppressions(),loadProviderPolicy(),loadAiProviderSettings()];const results=await Promise.allSettled(panelLoads);const rejected=results.filter(result=>result.status==='rejected');if(rejected.length){console.error('Workspace dashboard panels failed',rejected);$('apiStatus').textContent='● Some panels need refresh';$('apiStatus').classList.remove('live');setExplorerState('Some workspace panels could not be loaded. Use Refresh to retry.',true);}}
document.addEventListener('submit',e=>{if(e.target.id==='contactForm')saveContact(e.target);if(e.target.id==='noteForm')saveNote(e.target);if(e.target.id==='pipelineForm')saveStage(e.target);}); document.addEventListener('submit',e=>{if(e.target.id==='contactForm')saveContact(e.target);if(e.target.id==='noteForm')saveNote(e.target);if(e.target.id==='pipelineForm')saveStage(e.target);});
document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='recalculateScoreBtn')recalculateScore();if(e.target.id==='generateAiSuggestionBtn')generateAiSuggestion();if(e.target.id==='createOutreachDraftBtn')createOutreachDraft();const outreachButton=e.target.closest?.('[data-outreach-action]');if(outreachButton)approveOutreachDraft(outreachButton.dataset.outreachId);const aiButton=e.target.closest?.('[data-ai-action]');if(aiButton)aiAction(aiButton.dataset.aiAction,aiButton.dataset.aiId);const scoreEdit=e.target.closest?.('[data-score-edit]');if(scoreEdit)updateScoreRule(scoreEdit.dataset.scoreEdit,scoreEdit.dataset.scorePoints);if(e.target.id==='extractContactsBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='refreshContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='retryContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId);if(e.target.id==='scanWebsiteBtn'&&selectedId)loadWebsiteScan(selectedId,{scan:true});if(e.target.id==='refreshWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='retryWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='runDomainCheckBtn')runDomainCheck();if(e.target.id==='retryDomainBtn'&&selectedId)loadDomainIntelligence(selectedId);const availability=e.target.closest?.('[data-domain-availability]');if(availability)checkDomainAvailability(availability.dataset.domain,availability);if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);if(e.target.id==='retryDedupBtn'&&selectedId)loadMatchSuggestions(selectedId);if(e.target.id==='retryHistoryBtn'&&selectedId)loadMergeHistory(selectedId);if(e.target.id==='cancelMergeBtn'||e.target.id==='cancelMergeBtnSecondary')closeMergeDialog();if(e.target.id==='confirmMergeBtn')confirmMerge();const mergeButton=e.target.closest?.('[data-merge-target]');if(mergeButton)openMergeDialog(mergeButton.dataset.mergeTarget,mergeButton.dataset.mergeTargetName);const reverseButton=e.target.closest?.('[data-reverse-merge]');if(reverseButton)reverseMerge(reverseButton.dataset.reverseMerge);if(e.target.id==='retryJobDetailBtn'&&selectedJobId)loadJobDetail(selectedJobId);if(e.target.id==='cancelJobBtn')jobAction('cancel');if(e.target.id==='retryJobBtn')jobAction('retry');const row=e.target.closest?.('[data-job-id]');if(row)loadJobDetail(row.dataset.jobId);}); document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='recalculateScoreBtn')recalculateScore();if(e.target.id==='generateAiSuggestionBtn')generateAiSuggestion();if(e.target.id==='createOutreachDraftBtn')createOutreachDraft();const outreachButton=e.target.closest?.('[data-outreach-action]');if(outreachButton)approveOutreachDraft(outreachButton.dataset.outreachId);const aiButton=e.target.closest?.('[data-ai-action]');if(aiButton)aiAction(aiButton.dataset.aiAction,aiButton.dataset.aiId);const scoreEdit=e.target.closest?.('[data-score-edit]');if(scoreEdit)updateScoreRule(scoreEdit.dataset.scoreEdit,scoreEdit.dataset.scorePoints);if(e.target.id==='extractContactsBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='refreshContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='retryContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId);if(e.target.id==='scanWebsiteBtn'&&selectedId)loadWebsiteScan(selectedId,{scan:true});if(e.target.id==='refreshWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='retryWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='runDomainCheckBtn')runDomainCheck();if(e.target.id==='retryDomainBtn'&&selectedId)loadDomainIntelligence(selectedId);const availability=e.target.closest?.('[data-domain-availability]');if(availability)checkDomainAvailability(availability.dataset.domain,availability);if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);if(e.target.id==='retryDedupBtn'&&selectedId)loadMatchSuggestions(selectedId);if(e.target.id==='retryHistoryBtn'&&selectedId)loadMergeHistory(selectedId);if(e.target.id==='cancelMergeBtn'||e.target.id==='cancelMergeBtnSecondary')closeMergeDialog();if(e.target.id==='confirmMergeBtn')confirmMerge();const mergeButton=e.target.closest?.('[data-merge-target]');if(mergeButton)openMergeDialog(mergeButton.dataset.mergeTarget,mergeButton.dataset.mergeTargetName);const reverseButton=e.target.closest?.('[data-reverse-merge]');if(reverseButton)reverseMerge(reverseButton.dataset.reverseMerge);if(e.target.id==='retryJobDetailBtn'&&selectedJobId)loadJobDetail(selectedJobId);if(e.target.id==='cancelJobBtn')jobAction('cancel');if(e.target.id==='retryJobBtn')jobAction('retry');const row=e.target.closest?.('[data-job-id]');if(row)loadJobDetail(row.dataset.jobId);});
$('loginForm').addEventListener('submit',login);$('logoutBtn').addEventListener('click',logout);$('directDiscoveryForm').addEventListener('submit',submitDirectDiscovery);$('directDiscoveryRefreshBtn').addEventListener('click',loadDiscoveryRuns);$('directDiscoveryRunsState').addEventListener('click',e=>{const row=e.target.closest?.('[data-discovery-run-id]');if(row){const run=discoveryRuns.find(item=>String(item.id)===String(row.dataset.discoveryRunId));if(run)loadDiscoveryRun(run);}if(e.target.id==='retryDiscoveryRunsBtn')loadDiscoveryRuns();});$('searchInput').addEventListener('input',()=>{page=1;renderRows();});['scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id).addEventListener('change',()=>{page=1;loadData();}));$('pageSize').addEventListener('change',e=>{pageSize=Number(e.target.value);page=1;loadData();});$('nextPageBtn').addEventListener('click',()=>{if(hasNextPage){page+=1;loadData();}});$('refreshBtn').addEventListener('click',loadData);$('jobsRefreshBtn').addEventListener('click',()=>loadJobs());$('sourcesRefreshBtn').addEventListener('click',loadSources);$('sourceForm').addEventListener('submit',saveSource);$('sourceType').addEventListener('change',e=>{$('sourceCsvField').hidden=e.target.value!=='csv';});$('sourceTestBtn').addEventListener('click',()=>sourceAction(selectedSourceId,'test'));$('sourceEnableBtn').addEventListener('click',()=>sourceAction(selectedSourceId,'toggle'));$('sourcesList').addEventListener('click',e=>{const button=e.target.closest?.('[data-source-action]');if(button)sourceAction(button.dataset.sourceId,button.dataset.sourceAction);});$('addForm').addEventListener('submit',addProspect);$('csvInput').addEventListener('change',e=>{const file=e.target.files[0];if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}});$('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open'));document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView())); $('loginForm').addEventListener('submit',login);$('logoutBtn').addEventListener('click',logout);$('directDiscoveryForm').addEventListener('submit',submitDirectDiscovery);$('directDiscoveryRefreshBtn').addEventListener('click',loadDiscoveryRuns);$('directDiscoveryRunsState').addEventListener('click',e=>{const row=e.target.closest?.('[data-discovery-run-id]');if(row){const run=discoveryRuns.find(item=>String(item.id)===String(row.dataset.discoveryRunId));if(run)loadDiscoveryRun(run);}if(e.target.id==='retryDiscoveryRunsBtn')loadDiscoveryRuns();});
$('directDiscoveryResultState').addEventListener('click',e=>{const aiButton=e.target.closest?.('[data-ai-assess]');if(aiButton){const bid=aiButton.dataset.aiAssess;const run=discoveryRuns.find(item=>String(item.id)===String(selectedDiscoveryRunId));const candidates=discoveryCandidates(run);const item=candidates.find(c=>String(c.id||c.business_id)===String(bid));assessCandidate(bid,item||{});}const button=e.target.closest?.('[data-run-action]');if(button){const run=discoveryRuns.find(item=>String(item.id)===String(selectedDiscoveryRunId));discoveryAction(button.dataset.runAction,run);}});$('searchInput').addEventListener('input',()=>{page=1;renderRows();});['scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id).addEventListener('change',()=>{page=1;loadData();}));$('pageSize').addEventListener('change',e=>{pageSize=Number(e.target.value);page=1;loadData();});$('nextPageBtn').addEventListener('click',()=>{if(hasNextPage){page+=1;loadData();}});$('refreshBtn').addEventListener('click',loadData);$('jobsRefreshBtn').addEventListener('click',()=>loadJobs());$('sourcesRefreshBtn').addEventListener('click',loadSources);$('sourceForm').addEventListener('submit',saveSource);$('sourceType').addEventListener('change',e=>{$('sourceCsvField').hidden=e.target.value!=='csv';});$('sourceTestBtn').addEventListener('click',()=>sourceAction(selectedSourceId,'test'));$('sourceEnableBtn').addEventListener('click',()=>sourceAction(selectedSourceId,'toggle'));$('sourcesList').addEventListener('click',e=>{const button=e.target.closest?.('[data-source-action]');if(button)sourceAction(button.dataset.sourceId,button.dataset.sourceAction);});$('addForm').addEventListener('submit',addProspect);$('csvInput').addEventListener('change',e=>{const file=e.target.files[0];if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}});$('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open'));document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView()));
$('savedFilterForm').addEventListener('submit',saveCurrentFilters);$('savedFilterSelect').addEventListener('change',e=>{ $('deleteSavedFilterBtn').disabled=!e.target.value;if(e.target.value)loadSavedFilter(e.target.value);});$('deleteSavedFilterBtn').addEventListener('click',deleteSavedFilter);$('reviewQueueState').addEventListener('change',e=>{const input=e.target.closest?.('[data-review-id]');if(input){if(input.checked)selectedReviewIds.add(String(input.dataset.reviewId));else selectedReviewIds.delete(String(input.dataset.reviewId));updateBulkState();}});$('selectAllReview').addEventListener('change',e=>{reviewQueue.forEach(p=>e.target.checked?selectedReviewIds.add(String(p.id)):selectedReviewIds.delete(String(p.id)));renderReviewQueue({items:reviewQueue,count:$('reviewQueueCount').textContent});});$('bulkVerifyBtn').addEventListener('click',()=>bulkReview('verify'));$('bulkRejectBtn').addEventListener('click',()=>bulkReview('reject'));$('reviewQueueState').addEventListener('click',e=>{if(e.target.id==='retryReviewQueueBtn')loadReviewQueue();});document.querySelectorAll('[data-dashboard-filter]').forEach(card=>card.addEventListener('click',()=>{const kind=card.dataset.dashboardFilter;if(kind==='review'||kind==='suppressed')applyFilters({status:kind});else if(kind==='high')applyFilters({score:'high'});else if(kind==='fresh')applyFilters({});else applyFilters({status:'all',score:'all'});})); $('savedFilterForm').addEventListener('submit',saveCurrentFilters);$('savedFilterSelect').addEventListener('change',e=>{ $('deleteSavedFilterBtn').disabled=!e.target.value;if(e.target.value)loadSavedFilter(e.target.value);});$('deleteSavedFilterBtn').addEventListener('click',deleteSavedFilter);$('reviewQueueState').addEventListener('change',e=>{const input=e.target.closest?.('[data-review-id]');if(input){if(input.checked)selectedReviewIds.add(String(input.dataset.reviewId));else selectedReviewIds.delete(String(input.dataset.reviewId));updateBulkState();}});$('selectAllReview').addEventListener('change',e=>{reviewQueue.forEach(p=>e.target.checked?selectedReviewIds.add(String(p.id)):selectedReviewIds.delete(String(p.id)));renderReviewQueue({items:reviewQueue,count:$('reviewQueueCount').textContent});});$('bulkVerifyBtn').addEventListener('click',()=>bulkReview('verify'));$('bulkRejectBtn').addEventListener('click',()=>bulkReview('reject'));$('reviewQueueState').addEventListener('click',e=>{if(e.target.id==='retryReviewQueueBtn')loadReviewQueue();});document.querySelectorAll('[data-dashboard-filter]').forEach(card=>card.addEventListener('click',()=>{const kind=card.dataset.dashboardFilter;if(kind==='review'||kind==='suppressed')applyFilters({status:kind});else if(kind==='high')applyFilters({score:'high'});else if(kind==='fresh')applyFilters({});else applyFilters({status:'all',score:'all'});}));
['sourceFilter','geographyFilter','categoryFilter','contactStatusFilter','scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id)?.addEventListener('change',()=>{page=1;loadData();})); ['sourceFilter','geographyFilter','categoryFilter','contactStatusFilter','scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id)?.addEventListener('change',()=>{page=1;loadData();}));
$('interactionForm').addEventListener('submit',saveInteraction);$('suppressionForm').addEventListener('submit',addSuppression);$('aiProviderForm').addEventListener('submit',saveAiProviderSettings);$('aiProvider').addEventListener('change',updateAiProviderForm);$('aiProviderRefreshBtn').addEventListener('click',loadAiProviderSettings);$('testAiProviderBtn').addEventListener('click',testAiProviderConnection);$('crmRefreshBtn').addEventListener('click',()=>{loadCrmPipeline();loadInteractions(selectedId);});$('reportsRefreshBtn').addEventListener('click',loadReports);$('suppressionRefreshBtn').addEventListener('click',loadSuppressions);$('pipelineViewToggle').addEventListener('click',()=>{crmListMode=!crmListMode;$('pipelineViewToggle').textContent=crmListMode?'▦ Board view':'☷ List view';$('pipelineViewToggle').setAttribute('aria-pressed',String(crmListMode));renderPipeline();});$('pipelineBoard').addEventListener('click',e=>{const selectButton=e.target.closest?.('[data-crm-select]');if(selectButton){selectedId=Number(selectButton.dataset.crmSelect);selectProspect(selectedId);loadInteractions(selectedId);$('crmActivity').scrollIntoView({behavior:'smooth',block:'start'});}const stageButton=e.target.closest?.('[data-crm-save-stage]');if(stageButton)saveCrmStage(stageButton.dataset.crmSaveStage);if(e.target.id==='retryCrmBtn')loadCrmPipeline();if(e.target.id==='retryInteractionsBtn'&&selectedId)loadInteractions(selectedId);});$('suppressionState').addEventListener('change',e=>{const input=e.target.closest?.('[data-suppression-id]');if(input){if(input.checked)selectedSuppressionIds.add(String(input.dataset.suppressionId));else selectedSuppressionIds.delete(String(input.dataset.suppressionId));updateSuppressionSelection();}});$('suppressionState').addEventListener('click',e=>{const button=e.target.closest?.('[data-remove-suppression]');if(button)removeSuppression(button.dataset.removeSuppression);if(e.target.id==='retrySuppressionsBtn')loadSuppressions();});$('selectAllSuppressions').addEventListener('change',e=>{suppressions.forEach(item=>e.target.checked?selectedSuppressionIds.add(String(item.id)):selectedSuppressionIds.delete(String(item.id)));renderSuppressions();});$('bulkReviewSuppressionsBtn').addEventListener('click',bulkReviewSuppressions);$('discoveryWorkspace').addEventListener('click',e=>{const action=e.target.closest?.('[data-run-action]');if(action){const run=discoveryRuns.find(item=>String(item.id)===String(selectedDiscoveryRunId));discoveryAction(action.dataset.runAction,run);}}); $('interactionForm').addEventListener('submit',saveInteraction);$('suppressionForm').addEventListener('submit',addSuppression);$('aiProviderForm').addEventListener('submit',saveAiProviderSettings);$('aiProvider').addEventListener('change',updateAiProviderForm);$('aiProviderRefreshBtn').addEventListener('click',loadAiProviderSettings);$('testAiProviderBtn').addEventListener('click',testAiProviderConnection);$('crmRefreshBtn').addEventListener('click',()=>{loadCrmPipeline();loadInteractions(selectedId);});$('reportsRefreshBtn').addEventListener('click',loadReports);$('suppressionRefreshBtn').addEventListener('click',loadSuppressions);$('pipelineViewToggle').addEventListener('click',()=>{crmListMode=!crmListMode;$('pipelineViewToggle').textContent=crmListMode?'▦ Board view':'☷ List view';$('pipelineViewToggle').setAttribute('aria-pressed',String(crmListMode));renderPipeline();});$('pipelineBoard').addEventListener('click',e=>{const selectButton=e.target.closest?.('[data-crm-select]');if(selectButton){selectedId=Number(selectButton.dataset.crmSelect);selectProspect(selectedId);loadInteractions(selectedId);$('crmActivity').scrollIntoView({behavior:'smooth',block:'start'});}const stageButton=e.target.closest?.('[data-crm-save-stage]');if(stageButton)saveCrmStage(stageButton.dataset.crmSaveStage);if(e.target.id==='retryCrmBtn')loadCrmPipeline();if(e.target.id==='retryInteractionsBtn'&&selectedId)loadInteractions(selectedId);});$('suppressionState').addEventListener('change',e=>{const input=e.target.closest?.('[data-suppression-id]');if(input){if(input.checked)selectedSuppressionIds.add(String(input.dataset.suppressionId));else selectedSuppressionIds.delete(String(input.dataset.suppressionId));updateSuppressionSelection();}});$('suppressionState').addEventListener('click',e=>{const button=e.target.closest?.('[data-remove-suppression]');if(button)removeSuppression(button.dataset.removeSuppression);if(e.target.id==='retrySuppressionsBtn')loadSuppressions();});$('selectAllSuppressions').addEventListener('change',e=>{suppressions.forEach(item=>e.target.checked?selectedSuppressionIds.add(String(item.id)):selectedSuppressionIds.delete(String(item.id)));renderSuppressions();});$('bulkReviewSuppressionsBtn').addEventListener('click',bulkReviewSuppressions);$('discoveryWorkspace').addEventListener('click',e=>{const action=e.target.closest?.('[data-run-action]');if(action){const run=discoveryRuns.find(item=>String(item.id)===String(selectedDiscoveryRunId));discoveryAction(action.dataset.runAction,run);}});
+2 -2
View File
@@ -14,9 +14,9 @@
], ],
"integrity": { "integrity": {
"config.js": "sha256-7310c02ad6a24ab7d1b49536bfea169ef28f7eb55ccbfe77cb8b0ee1918319ca", "config.js": "sha256-7310c02ad6a24ab7d1b49536bfea169ef28f7eb55ccbfe77cb8b0ee1918319ca",
"app.js": "sha256-7772e21dd629a56665264a714cf720b8a9cd20b6599e7d7aa5440211a6310651", "app.js": "sha256-4956869c375eda23362c2345ee951a322693295b3eeba8a9481c754fb4fa2103",
"styles.css": "sha256-c1b192a15de735819fbb7ec3cddc9574808ae366a8255b4f690a7a40bc21caba", "styles.css": "sha256-c1b192a15de735819fbb7ec3cddc9574808ae366a8255b4f690a7a40bc21caba",
"index.html": "sha256-34b95523d465521a78845acc26fb6b9b8de84d3540c8b7acab224e2af28417a2", "index.html": "sha256-3a6a3f7e0d4780c1fadfbd77671e6a2472d99c5e891d08c86759d8475bf38986",
"health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81", "health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81",
"error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf", "error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf",
"healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22" "healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22"
+1 -1
View File
@@ -58,7 +58,7 @@
<div class="discovery-hero panel"><div><p class="eyebrow">SOURCE INTELLIGENCE</p><h2 id="discoveryWorkspaceTitle">Discovery runs</h2><p class="muted">Build a criteria-first run across approved sources, monitor it live, and review every result with provenance before it enters your pipeline.</p></div><span class="discovery-badge">Bounded · review first</span></div> <div class="discovery-hero panel"><div><p class="eyebrow">SOURCE INTELLIGENCE</p><h2 id="discoveryWorkspaceTitle">Discovery runs</h2><p class="muted">Build a criteria-first run across approved sources, monitor it live, and review every result with provenance before it enters your pipeline.</p></div><span class="discovery-badge">Bounded · review first</span></div>
<div class="discovery-workspace-grid"> <div class="discovery-workspace-grid">
<article class="panel direct-discovery-form-panel"><div class="panel-heading"><div><p class="eyebrow">RUN BUILDER</p><h3>Define your market</h3></div><span class="small-label">Max 20 pages · 50 candidates</span></div> <article class="panel direct-discovery-form-panel"><div class="panel-heading"><div><p class="eyebrow">RUN BUILDER</p><h3>Define your market</h3></div><span class="small-label">Max 20 pages · 50 candidates</span></div>
<form id="directDiscoveryForm"><div class="form-grid"><label>Category<input id="directDiscoveryCategory" name="category" required maxlength="120" placeholder="e.g. Renewable energy"></label><label>Keywords<input id="directDiscoveryKeywords" name="keywords" required maxlength="240" placeholder="e.g. solar installers"></label><label>City<input id="directDiscoveryCity" name="city" maxlength="80" placeholder="Cape Town"></label><label>Province / state<input id="directDiscoveryProvince" name="province" maxlength="80" placeholder="Western Cape"></label><label>Country<input id="directDiscoveryCountry" name="country" maxlength="80" placeholder="South Africa"></label><label>Language<input id="directDiscoveryLanguage" name="language" maxlength="24" placeholder="en"></label></div><label>Enabled sources <span class="optional">Select one or more approved sources</span><select id="directDiscoverySources" name="source_ids" multiple size="4" aria-describedby="directDiscoverySourceHelp"><option value="" disabled>Loading enabled sources…</option></select></label><p id="directDiscoverySourceHelp" class="small-label">No enabled sources are available. Configure, test, and enable a source first.</p><div class="discovery-limit-grid"><label>Max results<select id="directDiscoveryMaxResults" name="max_results"><option value="10">10 results</option><option value="25" selected>25 results</option><option value="50">50 results</option></select></label><label>Max pages<select id="directDiscoveryMaxPages" name="max_pages"><option value="5">5 pages</option><option value="10" selected>10 pages</option><option value="20">20 pages</option></select></label><label>Daily limit<input id="directDiscoveryDailyLimit" name="daily_limit" type="number" min="1" max="1000" value="100"></label></div><details class="advanced-discovery" open><summary>Website analysis</summary><label>Analysis priorities<input id="directDiscoveryPriorities" name="priorities" maxlength="240" placeholder="service fit, contacts, local presence"></label><label>Include websites <span class="optional">optional · one domain or URL per line</span><textarea id="directDiscoveryIncludeWebsites" name="include_websites" rows="2" placeholder="https://example.com"></textarea></label><label>Exclude websites <span class="optional">optional · one domain or URL per line</span><textarea id="directDiscoveryExcludeWebsites" name="exclude_websites" rows="2" placeholder="https://excluded.example"></textarea></label></details><label class="checkbox-label"><input id="directDiscoveryDryRun" name="dry_run" type="checkbox" checked> Dry run — validate criteria and source readiness without creating a job</label><div class="form-footer"><p id="directDiscoveryMessage" class="form-message" role="status" aria-live="polite">Choose enabled sources to start. Nothing runs until you submit.</p><button class="button primary" id="directDiscoveryRunBtn" type="submit">Start discovery run</button></div></form> <form id="directDiscoveryForm"><div class="form-grid"><label>Category<input id="directDiscoveryCategory" name="category" required maxlength="120" placeholder="e.g. Renewable energy"></label><label>Keywords<input id="directDiscoveryKeywords" name="keywords" required maxlength="240" placeholder="e.g. solar installers"></label><label>City<input id="directDiscoveryCity" name="city" maxlength="80" placeholder="Cape Town"></label><label>Province / state<input id="directDiscoveryProvince" name="province" maxlength="80" placeholder="Western Cape"></label><label>Country<input id="directDiscoveryCountry" name="country" maxlength="80" placeholder="South Africa"></label><label>Language<input id="directDiscoveryLanguage" name="language" maxlength="24" placeholder="en"></label></div><label>Enabled sources <span class="optional">Select one or more approved sources</span><select id="directDiscoverySources" name="source_ids" multiple size="4" aria-describedby="directDiscoverySourceHelp"><option value="" disabled>Loading enabled sources…</option></select></label><p id="directDiscoverySourceHelp" class="small-label">No enabled sources are available. Configure, test, and enable a source first.</p><div class="discovery-limit-grid"><label>Max results<select id="directDiscoveryMaxResults" name="max_results"><option value="10">10 results</option><option value="25" selected>25 results</option><option value="50">50 results</option></select></label><label>Max pages<select id="directDiscoveryMaxPages" name="max_pages"><option value="5">5 pages</option><option value="10" selected>10 pages</option><option value="20">20 pages</option></select></label><label>Daily limit<input id="directDiscoveryDailyLimit" name="daily_limit" type="number" min="1" max="1000" value="100"></label></div><details class="advanced-discovery" open><summary>Website analysis</summary><label>Analysis priorities<input id="directDiscoveryPriorities" name="priorities" maxlength="240" placeholder="service fit, contacts, local presence"></label><label>Include websites <span class="optional">optional · one domain or URL per line</span><textarea id="directDiscoveryIncludeWebsites" name="include_websites" rows="2" placeholder="https://example.com"></textarea></label><label>Exclude websites <span class="optional">optional · one domain or URL per line</span><textarea id="directDiscoveryExcludeWebsites" name="exclude_websites" rows="2" placeholder="https://excluded.example"></textarea></label></details><label>Schedule<select id="directDiscoverySchedule" name="schedule"><option value="run_now" selected>Run now</option><option value="daily">Daily</option><option value="weekdays">Weekdays</option><option value="weekly">Weekly</option></select></label><label class="checkbox-label"><input id="directDiscoveryDryRun" name="dry_run" type="checkbox"> Dry run — validate criteria and source readiness without creating a job</label> Dry run — validate criteria and source readiness without creating a job</label><div class="form-footer"><p id="directDiscoveryMessage" class="form-message" role="status" aria-live="polite">Choose enabled sources to start. Nothing runs until you submit.</p><button class="button primary" id="directDiscoveryRunBtn" type="submit">Start discovery run</button></div></form>
</article> </article>
<aside class="panel discovery-runs-panel"><div class="panel-heading"><div><p class="eyebrow">RUN HISTORY</p><h3>Discovery runs</h3></div><button class="button ghost compact" id="directDiscoveryRefreshBtn" type="button">↻ Refresh</button></div><div id="directDiscoveryRunsState" class="discovery-runs-state" aria-live="polite"><div class="detail-loading">Sign in to load discovery runs.</div></div></aside> <aside class="panel discovery-runs-panel"><div class="panel-heading"><div><p class="eyebrow">RUN HISTORY</p><h3>Discovery runs</h3></div><button class="button ghost compact" id="directDiscoveryRefreshBtn" type="button">↻ Refresh</button></div><div id="directDiscoveryRunsState" class="discovery-runs-state" aria-live="polite"><div class="detail-loading">Sign in to load discovery runs.</div></div></aside>
</div> </div>