feat: implement task queue statistics and add UI templates for job management and visualization.

This commit is contained in:
2026-08-15 00:00:41 -07:00
parent 5510e3ac82
commit 286278c1ec
12 changed files with 1144 additions and 644 deletions
+374
View File
@@ -0,0 +1,374 @@
package ui
const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hireme | Job Command Center</title>
<meta name="description" content="Automated job discovery, filtering, and application tracking powered by LLM analysis.">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/[email protected]"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#eef2ff',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
}
}
}
}
}
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body { font-family: 'Inter', sans-serif; }
.glass-panel {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.custom-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
.custom-scrollbar::-webkit-scrollbar-track { background: rgba(15, 23, 42, 0.6); }
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(99, 102, 241, 0.3); border-radius: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(99, 102, 241, 0.6); }
/* Bottom bar clearance so content isn't hidden */
main { padding-bottom: 5rem; }
/* Sortable table headers */
th[data-sort] { cursor: pointer; user-select: none; }
th[data-sort]:hover { color: #a5b4fc; }
th[data-sort].sort-asc::after { content: ' ↑'; color: #818cf8; }
th[data-sort].sort-desc::after { content: ' ↓'; color: #818cf8; }
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex flex-col antialiased selection:bg-indigo-500 selection:text-white">
<!-- Top Navigation Header -->
<header class="sticky top-0 z-30 glass-panel border-b border-slate-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 rounded-xl bg-gradient-to-tr from-indigo-600 to-violet-500 flex items-center justify-center shadow-lg shadow-indigo-500/30">
<span class="text-base font-black text-white">H</span>
</div>
<div>
<h1 class="text-base font-bold bg-gradient-to-r from-white via-indigo-200 to-indigo-400 bg-clip-text text-transparent">Hireme</h1>
<p class="text-[10px] text-slate-400 font-medium leading-none">Job Command Center</p>
</div>
</div>
<!-- Quick Stats -->
<div class="hidden md:flex items-center space-x-6 text-xs">
<div class="flex items-center space-x-1.5">
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
<span class="text-slate-400">Qualified:</span>
<span class="font-bold text-emerald-400">{{ .QualifiedJobsCount }}</span>
</div>
<div class="flex items-center space-x-1.5">
<span class="text-slate-400">Ingested:</span>
<span class="font-bold text-white">{{ .TotalIngestedCount }}</span>
</div>
<div class="flex items-center space-x-1.5">
<span class="text-slate-400">Applied:</span>
<span class="font-bold text-violet-400">{{ .ActiveApplications }}</span>
</div>
<div class="flex items-center space-x-1.5 px-2 py-1 rounded-md bg-slate-900/60 border border-slate-800">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
<span class="text-slate-400">LLM:</span>
<span class="font-semibold text-indigo-300 uppercase">{{ .LLMProvider }}</span>
</div>
</div>
</div>
</header>
<!-- Tab Bar -->
<div class="sticky top-14 z-20 bg-slate-950/90 backdrop-blur-md border-b border-slate-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex space-x-0">
<button id="tab-btn-jobs" onclick="switchTab('jobs')"
class="tab-btn px-5 py-3 text-sm font-semibold border-b-2 transition-colors text-indigo-400 border-indigo-500">
🎯 Jobs
</button>
<button id="tab-btn-apply" onclick="switchTab('apply')"
class="tab-btn px-5 py-3 text-sm font-semibold border-b-2 transition-colors text-slate-400 border-transparent hover:text-slate-200">
📋 Apply
</button>
<button id="tab-btn-targets" onclick="switchTab('targets')"
class="tab-btn px-5 py-3 text-sm font-semibold border-b-2 transition-colors text-slate-400 border-transparent hover:text-slate-200">
⚙️ Targets
</button>
</div>
</div>
</div>
<!-- Main Workspace -->
<main class="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Tab: Jobs -->
<div id="tab-content-jobs">
{{ template "jobsTab" . }}
</div>
<!-- Tab: Apply (Table with pipeline indicator) -->
<div id="tab-content-apply" class="hidden">
{{ template "applyTab" . }}
</div>
<!-- Tab: Targets -->
<div id="tab-content-targets" class="hidden space-y-5">
{{ template "targetsTab" . }}
</div>
</main>
<!-- Right Slide-Over Detail Panel -->
<div id="detail-modal" class="fixed inset-0 z-50 hidden" aria-modal="true">
<div class="absolute inset-0 bg-slate-950/70 backdrop-blur-sm" onclick="closeDetailModal()"></div>
<div id="detail-modal-body"
class="absolute top-0 right-0 w-full max-w-2xl bg-slate-900 border-l border-slate-800 h-full p-6 overflow-y-auto custom-scrollbar shadow-2xl transition-transform">
<!-- Dynamic HTMX content loaded here -->
</div>
</div>
<!-- Fixed Bottom Status & Action Bar -->
<div id="bottom-bar"
class="fixed bottom-0 left-0 right-0 z-40 bg-slate-900/95 backdrop-blur-md border-t border-slate-700/60 shadow-2xl shadow-black/50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between gap-4">
<!-- Left: Worker + Queue Status (auto-refreshed) -->
<div id="status-bar-left" class="flex items-center gap-4 text-xs">
<div id="worker-status-badge" class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-slate-800/80 border border-slate-700">
<span id="worker-dot" class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
<span class="text-slate-300">Workers:</span>
<span id="worker-label" class="font-bold text-emerald-400 uppercase">Running</span>
</div>
<div class="flex items-center gap-3 text-slate-400">
<span>Pending: <strong id="q-pending" class="text-amber-400">–</strong></span>
<span>Running: <strong id="q-running" class="text-sky-400">–</strong></span>
<span>Failed: <strong id="q-failed" class="text-rose-400">–</strong></span>
</div>
</div>
<!-- Right: Action Buttons -->
<div class="flex items-center gap-2">
<button id="btn-discover"
onclick="triggerAction('/api/tasks/discover', this, '🔍 Discover', 'Discovering…')"
class="action-btn px-3 py-1.5 text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-500 rounded-lg transition-all active:scale-95 shadow-md shadow-indigo-500/20">
🔍 Discover
</button>
<button id="btn-reprocess"
onclick="triggerAction('/api/tasks/reprocess', this, '🔄 Reprocess', 'Reprocessing…')"
class="action-btn px-3 py-1.5 text-xs font-semibold text-white bg-amber-600 hover:bg-amber-500 rounded-lg transition-all active:scale-95 shadow-md shadow-amber-500/20">
🔄 Reprocess
</button>
<button id="btn-clean"
onclick="triggerAction('/api/tasks/clean', this, '🗑️ Clean Queue', 'Cleaning…')"
class="action-btn px-3 py-1.5 text-xs font-semibold text-white bg-rose-700 hover:bg-rose-600 rounded-lg transition-all active:scale-95 shadow-md shadow-rose-500/20">
🗑️ Clean
</button>
<button id="btn-toggle"
onclick="toggleWorkers(this)"
class="action-btn px-3 py-1.5 text-xs font-semibold text-white bg-slate-700 hover:bg-slate-600 rounded-lg transition-all active:scale-95 border border-slate-600">
⏸ Pause Workers
</button>
</div>
</div>
</div>
<script>
// ── Tab Routing ──────────────────────────────────────────────────────
const TABS = ['jobs', 'apply', 'targets'];
function switchTab(tab) {
TABS.forEach(t => {
document.getElementById('tab-content-' + t).classList.add('hidden');
const btn = document.getElementById('tab-btn-' + t);
btn.classList.remove('text-indigo-400', 'border-indigo-500');
btn.classList.add('text-slate-400', 'border-transparent');
});
document.getElementById('tab-content-' + tab).classList.remove('hidden');
const activeBtn = document.getElementById('tab-btn-' + tab);
activeBtn.classList.remove('text-slate-400', 'border-transparent');
activeBtn.classList.add('text-indigo-400', 'border-indigo-500');
history.replaceState(null, '', '#' + tab);
}
// Restore tab from URL hash on load
(function() {
const hash = location.hash.replace('#', '');
if (TABS.includes(hash)) switchTab(hash);
})();
// ── Detail Modal ─────────────────────────────────────────────────────
function openDetailModal() {
document.getElementById('detail-modal').classList.remove('hidden');
}
function closeDetailModal() {
document.getElementById('detail-modal').classList.add('hidden');
}
// ── Bottom Bar: Status Polling ────────────────────────────────────────
function refreshStatus() {
fetch('/api/status')
.then(r => r.json())
.then(data => {
const isRunning = data.worker_status === 'running';
document.getElementById('worker-dot').className =
'w-2 h-2 rounded-full ' + (isRunning ? 'bg-emerald-400 animate-pulse' : 'bg-slate-500');
document.getElementById('worker-label').textContent = isRunning ? 'Running' : 'Paused';
document.getElementById('worker-label').className =
'font-bold uppercase ' + (isRunning ? 'text-emerald-400' : 'text-slate-400');
document.getElementById('btn-toggle').textContent =
isRunning ? '⏸ Pause Workers' : '▶ Start Workers';
if (data.queue) {
document.getElementById('q-pending').textContent = data.queue.pending;
document.getElementById('q-running').textContent = data.queue.running;
document.getElementById('q-failed').textContent = data.queue.failed;
}
})
.catch(() => {});
}
refreshStatus();
setInterval(refreshStatus, 3000);
// ── Action Button Helper ──────────────────────────────────────────────
function triggerAction(url, btn, originalLabel, loadingLabel) {
btn.disabled = true;
btn.textContent = loadingLabel;
fetch(url, { method: 'POST' })
.then(() => { refreshStatus(); })
.catch(() => {})
.finally(() => {
setTimeout(() => {
btn.disabled = false;
btn.textContent = originalLabel;
}, 1200);
});
}
function toggleWorkers(btn) {
btn.disabled = true;
fetch('/api/tasks/toggle', { method: 'POST' })
.then(() => { refreshStatus(); })
.catch(() => {})
.finally(() => { btn.disabled = false; });
}
// ── Generic Table Sort/Filter Helpers ─────────────────────────────────
function initSortableTable(tableId) {
const table = document.getElementById(tableId);
if (!table) return;
table.querySelectorAll('th[data-sort]').forEach(th => {
th.removeEventListener('click', th._sortHandler);
th._sortHandler = () => sortTable(table, th);
th.addEventListener('click', th._sortHandler);
});
}
function sortTable(table, th) {
const col = Array.from(th.parentElement.children).indexOf(th);
const asc = !th.classList.contains('sort-asc');
table.querySelectorAll('th[data-sort]').forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
th.classList.add(asc ? 'sort-asc' : 'sort-desc');
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr[data-row]'));
rows.sort((a, b) => {
const aVal = a.cells[col]?.dataset.sortVal || a.cells[col]?.textContent.trim() || '';
const bVal = b.cells[col]?.dataset.sortVal || b.cells[col]?.textContent.trim() || '';
const aNum = parseFloat(aVal), bNum = parseFloat(bVal);
if (!isNaN(aNum) && !isNaN(bNum)) return asc ? aNum - bNum : bNum - aNum;
return asc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
});
rows.forEach(r => tbody.appendChild(r));
}
// ── Job Table Filter ──────────────────────────────────────────────────
function filterJobTable() {
const table = document.getElementById('jobs-table');
if (!table) return;
const text = (document.getElementById('job-filter-text')?.value || '').toLowerCase();
const qual = document.getElementById('job-filter-qual')?.value || 'default';
table.querySelectorAll('tr[data-row]').forEach(row => {
const rowText = row.textContent.toLowerCase();
const rowQual = row.dataset.qual || ''; // qualified | not-qualified | pending
const rowApp = row.dataset.app || 'none'; // none | opted-out | pipeline
const textMatch = !text || rowText.includes(text);
let qualMatch;
switch (qual) {
case 'default': qualMatch = rowApp !== 'opted-out' && (rowQual === 'qualified' || rowQual === 'pending'); break;
case 'not-qualified': qualMatch = rowApp !== 'opted-out' && rowQual === 'not-qualified'; break;
case 'saved': qualMatch = rowApp === 'pipeline'; break;
case 'all': qualMatch = rowApp !== 'opted-out'; break;
case 'opted-out': qualMatch = rowApp === 'opted-out'; break;
default: qualMatch = true;
}
row.style.display = (textMatch && qualMatch) ? '' : 'none';
});
}
function initJobTable() {
initSortableTable('jobs-table');
const filterInput = document.getElementById('job-filter-text');
const filterSelect = document.getElementById('job-filter-qual');
if (filterInput) filterInput.addEventListener('input', filterJobTable);
if (filterSelect) filterSelect.addEventListener('change', filterJobTable);
// Re-apply current filter immediately after table is rebuilt
filterJobTable();
}
// ── Apply Table Filter ────────────────────────────────────────────────
function filterApplyTable() {
const table = document.getElementById('apply-table');
if (!table) return;
const text = (document.getElementById('apply-filter-text')?.value || '').toLowerCase();
const status = document.getElementById('apply-filter-status')?.value || 'active';
table.querySelectorAll('tr[data-row]').forEach(row => {
const rowText = row.textContent.toLowerCase();
const rowStatus = row.dataset.appStatus || '';
const textMatch = !text || rowText.includes(text);
const ACTIVE = ['SAVED','APPLIED','INTERVIEW','OFFER'];
let statusMatch;
if (status === 'active') statusMatch = ACTIVE.includes(rowStatus);
else if (status === 'all') statusMatch = true;
else statusMatch = rowStatus === status;
row.style.display = (textMatch && statusMatch) ? '' : 'none';
});
}
function initApplyTable() {
initSortableTable('apply-table');
const filterInput = document.getElementById('apply-filter-text');
const filterSelect = document.getElementById('apply-filter-status');
if (filterInput) filterInput.addEventListener('input', filterApplyTable);
if (filterSelect) filterSelect.addEventListener('change', filterApplyTable);
filterApplyTable();
}
// ── Init & HTMX Hooks ─────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initJobTable();
initApplyTable();
// Auto-refresh job list every 12s to pick up newly processed jobs
setInterval(() => {
htmx.trigger(document.getElementById('jobs-container'), 'refresh');
}, 12000);
});
// Re-init tables after any HTMX swap so filters & sorting stay active
document.addEventListener('htmx:afterSwap', (e) => {
if (!e.target) return;
if (e.target.id === 'jobs-container' || e.target.closest?.('#jobs-container'))
initJobTable();
if (e.target.id === 'apply-container' || e.target.closest?.('#apply-container'))
initApplyTable();
});
</script>
</body>
</html>{{ end }}`