← back to Rentv 2026

public/admin/pr-intelligence/runs.html

81 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>CRE PR Intelligence — Runs & Jobs</title>
<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
</head>
<body>
<script src="/admin/pr-intelligence/pr-shared.js"></script>
<script>
(async function () {
  const c = PR.frame('runs', 'Runs & Jobs', 'Resumable research runs (with checkpoints) and the database-backed job queue. The worker process executes: npm run pr:worker.');
  const meta = await PR.api('/meta');
  c.innerHTML = `
    <div class="filters">
      <select id="j-type">${meta.job_types.map((t) => `<option>${t}</option>`).join('')}</select>
      <input id="j-payload" placeholder='payload JSON e.g. {"state":"CA"}' style="min-width:260px">
      <button class="btn primary" id="j-add">Queue job</button>
      <button class="btn" id="refresh">↻ Refresh</button>
      <label style="font-size:12px;color:var(--muted)"><input type="checkbox" id="auto" checked> auto-refresh 5s</label>
    </div>
    <div class="cols2">
      <div class="panel"><h3>Research runs</h3><div id="runs"><span class="spin"></span></div></div>
      <div class="panel"><h3>Job queue</h3><div id="jobs"><span class="spin"></span></div></div>
    </div>`;

  async function load() {
    const rj = await PR.api('/runs');
    document.getElementById('runs').innerHTML = rj.rows.length ? rj.rows.map((r) => `
      <div class="evi" style="border-color:${r.status === 'failed' ? 'var(--bad)' : r.status === 'running' ? 'var(--accent)' : 'var(--line)'}">
        <b>#${r.id}</b> ${PR.badge(r.state, 'blue')} ${r.metro ? PR.badge(r.metro) : ''} ${r.category ? PR.badge(r.category) : ''}
        ${PR.statusBadge(r.status)} <span style="color:var(--muted);font-size:11.5px">adapter ${PR.esc(r.adapter)}</span>
        <div class="x">pages ${r.pages_checked} · candidates ${r.candidates_found} · created ${r.records_created} · updated ${r.records_updated} · dups ${r.duplicates_detected} · retries ${r.retry_count}
          ${(r.errors || []).length ? ' · <span style="color:var(--bad)">' + r.errors.length + ' error(s)</span>' : ''}</div>
        ${(r.errors || []).length ? `<div class="x" style="color:var(--bad)">${PR.esc(String((r.errors[r.errors.length - 1] || {}).error || '').slice(0, 160))}</div>` : ''}
        <div style="font-size:11px;color:var(--muted)">checkpoint: <code>${PR.esc(JSON.stringify(r.checkpoint).slice(0, 90))}</code> · 🕓 ${PR.date(r.created_at)}</div>
        <div class="filters" style="margin-top:6px">
          ${['queued', 'running'].includes(r.status) ? `<button class="btn sm" data-pause="${r.id}">⏸ Pause</button>` : ''}
          ${r.status === 'paused' ? `<button class="btn sm primary" data-resume="${r.id}">▶ Resume</button>` : ''}
        </div></div>`).join('') : '<div class="empty">No runs yet — start one from Research</div>';
    document.querySelectorAll('[data-pause]').forEach((b) => b.addEventListener('click', () =>
      PR.guard(() => PR.api('/runs/' + b.dataset.pause + '/pause', { method: 'POST' }), 'Paused (jobs paused too)').then(load)));
    document.querySelectorAll('[data-resume]').forEach((b) => b.addEventListener('click', () =>
      PR.guard(() => PR.api('/runs/' + b.dataset.resume + '/resume', { method: 'POST' }), 'Resumed — resumes from its checkpoint').then(load)));

    const jj = await PR.api('/jobs?limit=60');
    document.getElementById('jobs').innerHTML = jj.rows.length ? `<table class="grid"><thead><tr>
      <th>#</th><th>Type</th><th>Status</th><th>Attempts</th><th>Payload</th><th>Last error</th><th></th></tr></thead><tbody>` +
      jj.rows.map((j) => `<tr>
        <td>${j.id}</td><td style="font-size:12px">${PR.esc(j.job_type)}</td>
        <td>${PR.statusBadge(j.status)}</td><td>${j.attempts}/${j.max_attempts}</td>
        <td><code style="font-size:11px">${PR.esc(JSON.stringify(j.payload).slice(0, 60))}</code></td>
        <td style="font-size:11px;color:var(--bad)">${PR.esc((j.last_error || '').slice(0, 60))}</td>
        <td>${['queued', 'running'].includes(j.status) ? `<button class="btn sm" data-jp="${j.id}">⏸</button>` : ''}
            ${['paused', 'failed'].includes(j.status) ? `<button class="btn sm" data-jr="${j.id}">▶ retry</button>` : ''}
            ${j.status !== 'completed' && j.status !== 'cancelled' ? `<button class="btn sm danger" data-jc="${j.id}">✕</button>` : ''}</td></tr>`).join('') + '</tbody></table>'
      : '<div class="empty">Queue empty</div>';
    document.querySelectorAll('[data-jp]').forEach((b) => b.addEventListener('click', () => setJ(b.dataset.jp, 'paused')));
    document.querySelectorAll('[data-jr]').forEach((b) => b.addEventListener('click', () => setJ(b.dataset.jr, 'queued')));
    document.querySelectorAll('[data-jc]').forEach((b) => b.addEventListener('click', () => setJ(b.dataset.jc, 'cancelled')));
  }
  async function setJ(id, status) { await PR.guard(() => PR.api('/jobs/' + id + '/status', { method: 'POST', body: { status } }), status); load(); }

  document.getElementById('j-add').addEventListener('click', async () => {
    let payload = {};
    const t = document.getElementById('j-payload').value.trim();
    if (t) { try { payload = JSON.parse(t); } catch { return PR.toast('Payload must be valid JSON', true); } }
    try {
      const r = await PR.api('/jobs', { method: 'POST', body: { job_type: document.getElementById('j-type').value, payload } });
      PR.toast(r.job.deduped ? 'Identical job already queued' : 'Job queued');
      load();
    } catch (e) { PR.toast(e.message, true); }
  });
  document.getElementById('refresh').addEventListener('click', load);
  setInterval(() => { if (document.getElementById('auto').checked) load(); }, 5000);
  load();
})();
</script>
</body>
</html>