add durable jobs and live job monitor
This commit is contained in:
+35
-4
@@ -15,7 +15,7 @@
|
||||
async function request(path, options = {}) { const response=await fetch(endpoint(path),{...options,credentials:'include'}); if(response.status===401){showLogin('Your session has expired. Please sign in again.');throw new Error('unauthorized');} return response; }
|
||||
async function jsonRequest(path, options = {}) { const res=await request(path,options); const body=await res.json().catch(()=>({})); if(!res.ok)throw new Error(body.error||body.message||'Request failed'); return body; }
|
||||
function showLogin(message=''){currentUser=null;$('dashboardShell').hidden=true;$('loginScreen').hidden=false;$('loginMessage').textContent=message;$('loginMessage').className=`form-message${message?' error':''}`;}
|
||||
function showDashboard(user){currentUser=user||{};const name=currentUser.name||currentUser.full_name||currentUser.email||'Workspace member';const role=currentUser.role||currentUser.roles?.[0]||'Member';$('userIdentity').textContent=`${name} · ${role}`;$('userAvatar').textContent=name.split(/\s+/).map(x=>x[0]).join('').slice(0,2).toUpperCase();$('loginScreen').hidden=true;$('dashboardShell').hidden=false;}
|
||||
function showDashboard(user){currentUser=user||{};const name=currentUser.name||currentUser.full_name||currentUser.email||'Workspace member';const role=currentUser.role||currentUser.roles?.[0]||'Member';$('userIdentity').textContent=`${name} · ${role}`;$('userAvatar').textContent=name.split(/\s+/).map(x=>x[0]).join('').slice(0,2).toUpperCase();$('loginScreen').hidden=true;$('dashboardShell').hidden=false;updateJobPermissions();}
|
||||
function renderMetrics(summary){const total=Number(summary?.businesses??summary?.total??prospects.length),high=prospects.filter(p=>scoreFor(p)>=80).length,review=prospects.filter(p=>statusOf(p)==='review').length,fresh=prospects.length?Math.round(prospects.filter(p=>freshness(p).cls==='good').length/prospects.length*100):0;$('metricTotal').textContent=total;$('heroCount').textContent=`${total} prospects`;$('metricReview').textContent=summary?.needs_review??review;$('metricHigh').textContent=summary?.high_fit??high;$('metricFresh').textContent=`${summary?.freshness_under_7d??fresh}%`;}
|
||||
function filterValues(){return {q:$('searchInput').value.trim(),score:$('scoreFilter').value,status:$('statusFilter').value,website_class:$('websiteClassFilter').value,pipeline_stage:$('pipelineFilter').value};}
|
||||
function filtered(){const {q,score:sf,status:st,website_class:wc,pipeline_stage:ps}=filterValues();return prospects.filter(p=>{const s=scoreFor(p),text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(),stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new';return(!q||text.includes(q.toLowerCase()))&&(sf==='all'||(sf==='high'&&s>=80)||(sf==='medium'&&s>=60&&s<80)||(sf==='low'&&s<60))&&(st==='all'||statusOf(p)===st)&&(wc==='all'||(p.website_class||'missing')===wc)&&(ps==='all'||stage===ps);});}
|
||||
@@ -32,13 +32,44 @@
|
||||
async function saveStage(form){const stage=new FormData(form).get('stage');if(!stage){message('pipelineMessage','Choose a pipeline stage.',true);return;}try{await jsonRequest(`/api/v1/businesses/${selectedId}/pipeline`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({stage})});message('pipelineMessage','Pipeline stage updated.');await loadDetail(selectedId);await loadData();}catch(e){if(e.message!=='unauthorized')message('pipelineMessage',e.message,true);}}
|
||||
async function verify(){try{await jsonRequest(`/api/v1/businesses/${selectedId}/verify`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({verified:true})});message('verifyMessage','Prospect marked verified.');await loadDetail(selectedId);await loadData();}catch(e){if(e.message!=='unauthorized')message('verifyMessage',e.message,true);}}
|
||||
async function addProspect(event){event.preventDefault();const data=Object.fromEntries(new FormData(event.currentTarget).entries());const msg=$('formMessage');try{const body=await jsonRequest('/api/v1/businesses',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});prospects.unshift(body);msg.textContent='Added to review queue.';event.currentTarget.reset();renderMetrics(null);renderRows();}catch(e){if(e.message!=='unauthorized'){msg.textContent=e.message;msg.className='form-message error';}}}
|
||||
let jobs = [], selectedJobId = null, jobPollTimer = null;
|
||||
const jobStatuses = ['queued','running','succeeded','failed','cancelled'];
|
||||
const jobRole = () => String(currentUser?.role || currentUser?.roles?.[0] || '').toLowerCase();
|
||||
const canManageJobs = () => ['admin','owner','operator','manager'].includes(jobRole()) || Boolean(currentUser?.permissions?.includes?.('jobs:manage'));
|
||||
const jobStatus = job => String(job?.status || job?.state || 'queued').toLowerCase();
|
||||
const jobLabel = status => status.charAt(0).toUpperCase() + status.slice(1);
|
||||
async function jobsRequest(path, options = {}) { return jsonRequest(path, options); }
|
||||
function jobMessage(text, error = false) { const el = $('jobsMessage'); el.textContent = text || ''; el.className = `jobs-message${error ? ' error' : ''}`; }
|
||||
function resetJobCounts() { jobStatuses.forEach(status => { const el = $(`jobCount${jobLabel(status)}`); if (el) el.textContent = '—'; }); }
|
||||
function renderJobCounts(payload) {
|
||||
const counts = payload?.counts || payload?.status_counts || payload?.summary || {};
|
||||
const derived = jobs.reduce((out, job) => { const status = jobStatus(job); if (jobStatuses.includes(status)) out[status] += 1; return out; }, {queued:0,running:0,succeeded:0,failed:0,cancelled:0});
|
||||
jobStatuses.forEach(status => { const value = counts[status] ?? counts[`${status}_count`] ?? derived[status]; $(`jobCount${jobLabel(status)}`).textContent = Number.isFinite(Number(value)) ? Number(value) : 0; });
|
||||
}
|
||||
function renderJobsList() {
|
||||
const list = $('jobsList');
|
||||
if (!jobs.length) { list.innerHTML = '<div class="job-empty">No jobs returned by the workspace.</div>'; return; }
|
||||
list.innerHTML = jobs.map(job => { const status = jobStatus(job), progress = Number(job.progress ?? job.percent ?? 0); return `<button class="job-row ${Number(job.id) === Number(selectedJobId) ? 'selected' : ''}" type="button" data-job-id="${esc(job.id)}"><span class="job-row-main"><strong>${esc(job.name || job.type || `Job ${job.id}`)}</strong><small>${esc(job.created_at || job.submitted_at || 'Time unavailable')}</small></span><span class="job-row-state ${esc(status)}">${esc(jobLabel(status))}</span><span class="job-row-progress">${status === 'running' ? `${Math.max(0, Math.min(100, progress))}%` : ''}</span></button>`; }).join('');
|
||||
}
|
||||
const structuredError = job => job.error || job.structured_error || job.failure || (job.error_code ? {code:job.error_code,message:job.error_message || 'The job reported a structured error.'} : null);
|
||||
const eventText = event => event.message || event.description || event.name || event.type || 'Job event';
|
||||
function renderJobDetail(job, events = job.events || job.event_timeline || []) {
|
||||
const status = jobStatus(job), error = structuredError(job), progress = Math.max(0, Math.min(100, Number(job.progress ?? job.percent ?? 0))), canCancel = canManageJobs() && ['queued','running'].includes(status), canRetry = canManageJobs() && status === 'failed';
|
||||
$('jobDetailPanel').innerHTML = `<div class="job-detail-head"><div><p class="eyebrow">JOB DETAIL</p><h3>${esc(job.name || job.type || `Job ${job.id}`)}</h3><p class="muted">ID ${esc(job.id)}</p></div><span class="job-row-state ${esc(status)}">${esc(jobLabel(status))}</span></div><div class="job-progress" aria-label="Job progress"><div class="job-progress-meta"><strong>${status === 'running' ? `${progress}% complete` : jobLabel(status)}</strong><span>${esc(job.progress_message || job.message || '')}</span></div><div class="progress-track"><span style="width:${progress}%"></span></div></div>${error ? `<div class="structured-error" role="alert"><strong>${esc(error.code || error.type || 'Job error')}</strong><p>${esc(error.message || error.detail || error.description || JSON.stringify(error))}</p>${error.details ? `<pre>${esc(JSON.stringify(error.details, null, 2))}</pre>` : ''}</div>` : ''}<div class="job-detail-actions">${canCancel ? '<button class="button ghost compact" id="cancelJobBtn" type="button">Cancel job</button>' : ''}${canRetry ? '<button class="button primary compact" id="retryJobBtn" type="button">Retry job</button>' : ''}${!canCancel && !canRetry && !canManageJobs() ? '<span class="muted">Your role cannot change jobs.</span>' : ''}</div><div class="event-timeline"><h4>Event timeline</h4>${Array.isArray(events) && events.length ? `<ol>${events.map(event => `<li><span class="timeline-dot"></span><div><strong>${esc(eventText(event))}</strong><small>${esc(event.created_at || event.timestamp || event.at || '')}</small>${event.progress != null ? `<span class="timeline-progress">${esc(event.progress)}%</span>` : ''}</div></li>`).join('')}</ol>` : '<p class="muted">No events returned yet.</p>'}</div>`;
|
||||
}
|
||||
async function loadJobDetail(id) { selectedJobId = id; renderJobsList(); $('jobDetailPanel').innerHTML = '<div class="detail-loading" aria-live="polite">Loading job detail…</div>'; try { const job = await jobsRequest(`/api/v1/jobs/${encodeURIComponent(id)}`); let events = job.events || job.event_timeline; if (!events) { const eventPayload = await jobsRequest(`/api/v1/jobs/${encodeURIComponent(id)}/events`); events = eventPayload.events || eventPayload.items || eventPayload; } renderJobDetail(job, events); } catch (error) { if (error.message !== 'unauthorized') $('jobDetailPanel').innerHTML = `<div class="detail-error" role="alert"><h3>Unable to load job</h3><p>${esc(error.message)}</p><button class="button ghost" id="retryJobDetailBtn" type="button">Try again</button></div>`; } }
|
||||
async function loadJobs({silent = false} = {}) { if (!silent) { jobMessage('Loading jobs…'); resetJobCounts(); } try { const payload = await jobsRequest('/api/v1/jobs'); jobs = Array.isArray(payload) ? payload : (payload.jobs || payload.items || []); renderJobCounts(payload); renderJobsList(); $('jobsUpdatedAt').textContent = `Updated ${new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`; jobMessage(''); const active = jobs.some(job => ['queued','running'].includes(jobStatus(job))); if (active && !jobPollTimer) jobPollTimer = setInterval(() => loadJobs({silent:true}), 5000); if (!active && jobPollTimer) { clearInterval(jobPollTimer); jobPollTimer = null; } if (selectedJobId) { const selected = jobs.find(job => String(job.id) === String(selectedJobId)); if (selected) await loadJobDetail(selectedJobId); } } catch (error) { jobs = []; resetJobCounts(); renderJobsList(); if (error.message !== 'unauthorized') jobMessage(error.message || 'Unable to load jobs.', true); } }
|
||||
async function startDemoJob() { if (!canManageJobs()) { jobMessage('Your role is not permitted to start jobs.', true); return; } const button = $('startDemoJobBtn'); button.disabled = true; jobMessage('Starting demo job…'); try { const job = await jobsRequest('/api/v1/jobs', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({type:'noop', payload:{}, idempotency_key:`demo-${Date.now()}-${Math.random().toString(36).slice(2)}`})}); await loadJobs({silent:true}); if (job?.id) await loadJobDetail(job.id); jobMessage('Demo job started.'); } catch (error) { if (error.message !== 'unauthorized') jobMessage(error.message || 'Unable to start demo job.', true); } finally { button.disabled = !canManageJobs(); } }
|
||||
async function jobAction(action) { const job = jobs.find(item => String(item.id) === String(selectedJobId)); if (!job || !canManageJobs()) return; const endpointPath = action === 'cancel' ? `/api/v1/jobs/${encodeURIComponent(job.id)}/cancel` : `/api/v1/jobs/${encodeURIComponent(job.id)}/retry`; const label = action === 'cancel' ? 'cancel' : 'retry'; try { await jobsRequest(endpointPath, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); jobMessage(`Job ${label} requested.`); await loadJobs({silent:true}); await loadJobDetail(job.id); } catch (error) { if (error.message !== 'unauthorized') jobMessage(error.message || `Unable to ${label} job.`, true); } }
|
||||
function updateJobPermissions() { if ($('startDemoJobBtn')) $('startDemoJobBtn').disabled = !canManageJobs(); }
|
||||
|
||||
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>`;}
|
||||
async function login(event){event.preventDefault();const form=event.currentTarget,messageEl=$('loginMessage'),data=Object.fromEntries(new FormData(form).entries());messageEl.textContent='Signing in…';messageEl.className='form-message';try{const res=await fetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)});const body=await res.json().catch(()=>({}));if(!res.ok)throw new Error(body.error||'Invalid email or password.');await bootstrap();}catch(e){if(e.message!=='unauthorized'){messageEl.textContent=e.message;messageEl.className='form-message error';}}}
|
||||
async function logout(){try{await fetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'});}finally{showLogin('You have been signed out.');$('loginForm').reset();}}
|
||||
async function bootstrap(){try{const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)throw new Error('Could not verify session.');const user=await res.json();showDashboard(user.user||user);await loadData();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}}
|
||||
async function bootstrap(){try{const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)throw new Error('Could not verify session.');const user=await res.json();showDashboard(user.user||user);await loadData();await loadJobs();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}}
|
||||
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==='retryDetailBtn'&&selectedId)loadDetail(selectedId);});
|
||||
$('loginForm').addEventListener('submit',login);$('logoutBtn').addEventListener('click',logout);$('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);$('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()));
|
||||
document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);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);$('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());$('startDemoJobBtn').addEventListener('click',startDemoJob);$('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()));
|
||||
bootstrap();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user