feat: add job maintenance dashboard and automated stale job expiration system

This commit is contained in:
2026-08-22 01:51:43 -07:00
parent 367929e7d0
commit 7ec91f384f
12 changed files with 633 additions and 56 deletions
+71 -12
View File
@@ -102,7 +102,11 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
</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
🎯 Targets
</button>
<button id="tab-btn-preferences" onclick="switchTab('preferences')"
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">
⚙️ Preferences
</button>
</div>
</div>
@@ -126,6 +130,11 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
{{ template "targetsTab" . }}
</div>
<!-- Tab: Preferences -->
<div id="tab-content-preferences" class="hidden space-y-5">
{{ template "preferencesTab" . }}
</div>
</main>
<!-- Right Slide-Over Detail Panel -->
@@ -230,19 +239,25 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
<script>
// ── Tab Routing ──────────────────────────────────────────────────────
const TABS = ['jobs', 'apply', 'targets'];
const TABS = ['jobs', 'apply', 'targets', 'preferences'];
function switchTab(tab) {
TABS.forEach(t => {
document.getElementById('tab-content-' + t).classList.add('hidden');
const content = document.getElementById('tab-content-' + t);
if (content) content.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');
if (btn) {
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 activeContent = document.getElementById('tab-content-' + tab);
if (activeContent) activeContent.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');
if (activeBtn) {
activeBtn.classList.remove('text-slate-400', 'border-transparent');
activeBtn.classList.add('text-indigo-400', 'border-indigo-500');
}
history.replaceState(null, '', '#' + tab);
}
@@ -308,6 +323,49 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
});
}
// ── Maintenance Actions ──────────────────────────────────────────────
function runMaintenanceAction(url, daysInputId, protectCheckboxId, btn, originalText) {
const days = document.getElementById(daysInputId)?.value || '14';
const protect = document.getElementById(protectCheckboxId)?.checked ?? true;
const alertBox = document.getElementById('maintenance-alert');
btn.disabled = true;
btn.textContent = 'Processing…';
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ days: days, protect: protect ? 'true' : 'false' })
})
.then(res => res.json())
.then(data => {
if (alertBox) {
alertBox.className = 'p-4 rounded-xl border text-xs font-semibold ' +
(data.status === 'success' ? 'bg-emerald-950/60 border-emerald-500/40 text-emerald-300' : 'bg-rose-950/60 border-rose-500/40 text-rose-300');
alertBox.innerHTML = '<span>✓</span> ' + (data.message || 'Operation completed successfully.');
alertBox.classList.remove('hidden');
setTimeout(() => { alertBox.classList.add('hidden'); }, 8000);
}
// Refresh Jobs and Apply tables & stats
const jobsContainer = document.getElementById('jobs-container');
if (jobsContainer) htmx.trigger(jobsContainer, 'refresh');
const applyContainer = document.getElementById('apply-container');
if (applyContainer) htmx.trigger(applyContainer, 'refresh');
refreshStatus();
})
.catch(err => {
if (alertBox) {
alertBox.className = 'p-4 rounded-xl border text-xs font-semibold bg-rose-950/60 border-rose-500/40 text-rose-300';
alertBox.innerHTML = '<span>✕</span> Error: ' + err.message;
alertBox.classList.remove('hidden');
}
})
.finally(() => {
btn.disabled = false;
btn.textContent = originalText;
});
}
// ── Bottom Bar: Status Polling ────────────────────────────────────────
function refreshStatus() {
fetch('/api/status')
@@ -392,14 +450,15 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
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 rowApp = row.dataset.app || 'none'; // none | opted-out | expired | pipeline
const textMatch = !text || rowText.includes(text);
let qualMatch;
switch (qual) {
case 'default': qualMatch = rowApp !== 'opted-out' && rowApp !== 'pipeline' && (rowQual === 'qualified' || rowQual === 'pending'); break;
case 'not-qualified': qualMatch = rowApp !== 'opted-out' && rowQual === 'not-qualified'; break;
case 'default': qualMatch = rowApp !== 'opted-out' && rowApp !== 'expired' && rowApp !== 'pipeline' && (rowQual === 'qualified' || rowQual === 'pending'); break;
case 'not-qualified': qualMatch = rowApp !== 'opted-out' && rowApp !== 'expired' && rowQual === 'not-qualified'; break;
case 'saved': qualMatch = rowApp === 'pipeline'; break;
case 'all': qualMatch = rowApp !== 'opted-out'; break;
case 'all': qualMatch = true; break;
case 'expired': qualMatch = rowApp === 'expired'; break;
case 'opted-out': qualMatch = rowApp === 'opted-out'; break;
default: qualMatch = true;
}