feat: implement task queue statistics and add UI templates for job management and visualization.
This commit is contained in:
@@ -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 }}`
|
||||
Reference in New Issue
Block a user