feat: add job maintenance dashboard and automated stale job expiration system
This commit is contained in:
@@ -2,6 +2,7 @@ package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -44,6 +45,8 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/tasks/reprocess", s.handleReprocessJobs)
|
||||
mux.HandleFunc("POST /api/tasks/toggle", s.handleToggleTasks)
|
||||
mux.HandleFunc("GET /api/status", s.handleGetStatus)
|
||||
mux.HandleFunc("POST /api/maintenance/stale-expire", s.handleMaintenanceStaleExpire)
|
||||
mux.HandleFunc("GET /api/maintenance/stats", s.handleMaintenanceStats)
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -83,10 +86,16 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
mStats, err := s.db.GetJobMaintenanceStats()
|
||||
if err != nil {
|
||||
logger.Log.Warnw("Failed to get maintenance stats", "component", "UI", "error", err)
|
||||
}
|
||||
|
||||
data := PageData{
|
||||
Targets: targets,
|
||||
Jobs: jobs,
|
||||
Applications: apps,
|
||||
MaintenanceStats: mStats,
|
||||
ActiveTab: "triage",
|
||||
LLMProvider: s.cfg.LLMProvider,
|
||||
ToReviewCount: toReviewCount,
|
||||
@@ -434,3 +443,39 @@ func (s *Server) handleGetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleMaintenanceStaleExpire(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
days, _ := strconv.Atoi(r.FormValue("days"))
|
||||
if days <= 0 {
|
||||
days = 14
|
||||
}
|
||||
protect := r.FormValue("protect") != "false"
|
||||
|
||||
count, err := s.db.EnqueueExpireTasks(days, protect)
|
||||
if err != nil {
|
||||
logger.Log.Errorw("Failed to enqueue expire tasks", "component", "UI", "error", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "success",
|
||||
"count": count,
|
||||
"message": fmt.Sprintf("Enqueued %d expire task(s) for postings not seen in %d+ days.", count, days),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMaintenanceStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := s.db.GetJobMaintenanceStats()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(stats)
|
||||
}
|
||||
|
||||
@@ -82,3 +82,53 @@ func TestHandleDeleteTarget(t *testing.T) {
|
||||
func osWriteFile(path string, data []byte) error {
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
func TestMaintenanceHandlers(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "test_ui_maintenance.db")
|
||||
|
||||
database, err := db.InitDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init db: %v", err)
|
||||
}
|
||||
defer func() { _ = database.Close() }()
|
||||
|
||||
// Insert job
|
||||
job, _, err := database.InsertRawJob("canon-m-1", nil, "Software Engineer", "Google", "Seattle", "https://example.com/job/1", "Go backend")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert raw job: %v", err)
|
||||
}
|
||||
|
||||
// Make stale (25 days old)
|
||||
_, _ = database.Exec(`UPDATE raw_jobs SET last_seen_at = datetime('now', '-25 days') WHERE id = ?`, job.ID)
|
||||
|
||||
cfg := &config.Config{}
|
||||
server := NewServer(cfg, database, nil)
|
||||
mux := http.NewServeMux()
|
||||
server.RegisterRoutes(mux)
|
||||
|
||||
// 1. Test POST /api/maintenance/stale-expire
|
||||
req := httptest.NewRequest("POST", "/api/maintenance/stale-expire", strings.NewReader("days=14&protect=true"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"status":"success"`) || !strings.Contains(w.Body.String(), `"count":1`) {
|
||||
t.Errorf("unexpected body: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 2. Test GET /api/maintenance/stats
|
||||
reqStats := httptest.NewRequest("GET", "/api/maintenance/stats", nil)
|
||||
wStats := httptest.NewRecorder()
|
||||
mux.ServeHTTP(wStats, reqStats)
|
||||
|
||||
if wStats.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", wStats.Code, wStats.Body.String())
|
||||
}
|
||||
if !strings.Contains(wStats.Body.String(), `"total_jobs":1`) {
|
||||
t.Errorf("expected stats to show total_jobs:1, got %s", wStats.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ const applyTabHTML = `{{ define "applyTab" }}
|
||||
<option value="INTERVIEW">💬 Interview</option>
|
||||
<option value="OFFER">🎉 Offer</option>
|
||||
<option value="REJECTED">❌ Rejected</option>
|
||||
<option value="EXPIRED">⚠️ Expired / Closed</option>
|
||||
<option value="OPTED_OUT">🚫 Opted Out</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
<button hx-get="/applications" hx-target="#apply-container" hx-swap="innerHTML"
|
||||
@@ -69,6 +71,15 @@ const applyTableHTML = `{{ define "applyTable" }}
|
||||
|
||||
<!-- Pipeline Stage (arrow indicator) -->
|
||||
<td class="px-4 py-3">
|
||||
{{ if eq .App.Status "EXPIRED" }}
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-amber-500/15 border border-amber-500/30 text-amber-300 text-[10px] font-bold">
|
||||
⚠️ Expired / Closed
|
||||
</span>
|
||||
{{ else if eq .App.Status "OPTED_OUT" }}
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-slate-800 border border-slate-700 text-slate-400 text-[10px] font-medium">
|
||||
🚫 Opted Out
|
||||
</span>
|
||||
{{ else }}
|
||||
<div class="flex items-center gap-0.5 text-[10px] font-semibold">
|
||||
<span class="px-2 py-0.5 rounded-l-full {{ if eq .App.Status "SAVED" }}bg-slate-500 text-white{{ else }}bg-slate-800/60 text-slate-600{{ end }}">Saved</span>
|
||||
<span class="text-slate-700">▶</span>
|
||||
@@ -84,6 +95,7 @@ const applyTableHTML = `{{ define "applyTable" }}
|
||||
<span class="px-2 py-0.5 rounded-r-full bg-slate-800/60 text-slate-600">Offer</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</td>
|
||||
|
||||
<!-- Score -->
|
||||
@@ -122,6 +134,8 @@ const applyTableHTML = `{{ define "applyTable" }}
|
||||
<option value="INTERVIEW" {{ if eq .App.Status "INTERVIEW" }}selected{{ end }}>💬 Interview</option>
|
||||
<option value="OFFER" {{ if eq .App.Status "OFFER" }}selected{{ end }}>🎉 Offer</option>
|
||||
<option value="REJECTED" {{ if eq .App.Status "REJECTED" }}selected{{ end }}>❌ Rejected</option>
|
||||
<option value="EXPIRED" {{ if eq .App.Status "EXPIRED" }}selected{{ end }}>⚠️ Expired</option>
|
||||
<option value="OPTED_OUT" {{ if eq .App.Status "OPTED_OUT" }}selected{{ end }}>🚫 Opted Out</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -18,6 +18,7 @@ const jobsTabHTML = `{{ define "jobsTab" }}
|
||||
<option value="not-qualified">❌ Not Qualified</option>
|
||||
<option value="saved">💾 Saved / In Pipeline</option>
|
||||
<option value="all">🌐 All Jobs</option>
|
||||
<option value="expired">⚠️ Expired / Closed</option>
|
||||
<option value="opted-out">🚫 Opted-out</option>
|
||||
</select>
|
||||
<button hx-get="/jobs" hx-target="#jobs-container" hx-swap="innerHTML"
|
||||
@@ -45,7 +46,7 @@ const jobListHTML = `{{ define "jobs" }}
|
||||
<th class="px-4 py-3" data-sort="status">Analysis</th>
|
||||
<th class="px-4 py-3" data-sort="score">Score & Qualification</th>
|
||||
<th class="px-4 py-3">Reason / Tags</th>
|
||||
<th class="px-4 py-3" data-sort="date">Crawled</th>
|
||||
<th class="px-4 py-3" data-sort="date">Last Updated</th>
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -54,7 +55,7 @@ const jobListHTML = `{{ define "jobs" }}
|
||||
{{- $qual := "pending" -}}
|
||||
{{- if .Analysis }}{{ if .Analysis.IsQualified }}{{ $qual = "qualified" }}{{ else }}{{ $qual = "not-qualified" }}{{ end }}{{ end -}}
|
||||
{{- $appClass := "none" -}}
|
||||
{{- if .App }}{{ if eq .App.Status "OPTED_OUT" }}{{ $appClass = "opted-out" }}{{ else }}{{ $appClass = "pipeline" }}{{ end }}{{ end -}}
|
||||
{{- if .App }}{{ if eq .App.Status "OPTED_OUT" }}{{ $appClass = "opted-out" }}{{ else if eq .App.Status "EXPIRED" }}{{ $appClass = "expired" }}{{ else }}{{ $appClass = "pipeline" }}{{ end }}{{ end -}}
|
||||
<tr class="hover:bg-slate-800/40 transition-colors group" data-row data-qual="{{ $qual }}" data-app="{{ $appClass }}">
|
||||
|
||||
<!-- Title & Company -->
|
||||
@@ -117,9 +118,13 @@ const jobListHTML = `{{ define "jobs" }}
|
||||
|
||||
<!-- Reason / Matched Tags -->
|
||||
<td class="px-4 py-3 max-w-[180px]">
|
||||
{{ if .App }}{{ if eq .App.Status "OPTED_OUT" }}
|
||||
<span class="text-[10px] text-slate-500 italic">Opted out</span>
|
||||
{{ end }}{{ end }}
|
||||
{{ if .App }}
|
||||
{{ if eq .App.Status "OPTED_OUT" }}
|
||||
<span class="text-[10px] text-slate-500 italic block">Opted out</span>
|
||||
{{ else if eq .App.Status "EXPIRED" }}
|
||||
<span class="text-[10px] text-amber-400/80 italic block">⚠️ No longer active</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ if .Analysis }}
|
||||
{{ if .Analysis.IsQualified }}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@@ -139,8 +144,8 @@ const jobListHTML = `{{ define "jobs" }}
|
||||
</td>
|
||||
|
||||
<!-- Date -->
|
||||
<td class="px-4 py-3 text-slate-400 text-[11px]" data-sort-val="{{ .CrawledAt.Unix }}">
|
||||
{{ formatDate .CrawledAt }}
|
||||
<td class="px-4 py-3 text-slate-400 text-[11px]" data-sort-val="{{ .LastSeenAt.Unix }}">
|
||||
{{ formatDate .LastSeenAt }}
|
||||
</td>
|
||||
|
||||
<!-- Actions: Save + Opt-out OR status badge -->
|
||||
@@ -148,14 +153,32 @@ const jobListHTML = `{{ define "jobs" }}
|
||||
{{ if .App }}
|
||||
{{ if eq .App.Status "OPTED_OUT" }}
|
||||
<!-- Opted out: show undo button -->
|
||||
<button hx-post="/applications"
|
||||
hx-vals='{"raw_job_id": {{ .ID }}, "status": "SAVED"}'
|
||||
hx-target="#jobs-container"
|
||||
hx-swap="innerHTML"
|
||||
title="Undo opt-out"
|
||||
class="px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-400 border border-slate-700 text-[10px] transition-colors">
|
||||
↩ Undo
|
||||
</button>
|
||||
<div class="flex items-center justify-end gap-1.5">
|
||||
<span class="text-[10px] text-slate-500">Opted-out</span>
|
||||
<button hx-post="/applications"
|
||||
hx-vals='{"raw_job_id": {{ .ID }}, "status": "SAVED"}'
|
||||
hx-target="#jobs-container"
|
||||
hx-swap="innerHTML"
|
||||
title="Undo opt-out"
|
||||
class="px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 text-[10px] transition-colors">
|
||||
↩
|
||||
</button>
|
||||
</div>
|
||||
{{ else if eq .App.Status "EXPIRED" }}
|
||||
<!-- Expired -->
|
||||
<div class="flex items-center justify-end gap-1.5">
|
||||
<span class="px-2 py-0.5 rounded bg-slate-800 text-slate-400 text-[10px] font-bold border border-slate-700">
|
||||
⚠️ EXPIRED
|
||||
</span>
|
||||
<button hx-post="/applications"
|
||||
hx-vals='{"raw_job_id": {{ .ID }}, "status": "SAVED"}'
|
||||
hx-target="#jobs-container"
|
||||
hx-swap="innerHTML"
|
||||
title="Re-open / Save"
|
||||
class="px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 text-[10px] transition-colors">
|
||||
↩
|
||||
</button>
|
||||
</div>
|
||||
{{ else }}
|
||||
<!-- In pipeline -->
|
||||
<span class="px-2 py-1 rounded bg-violet-500/20 text-violet-300 text-[10px] font-bold border border-violet-500/30">
|
||||
@@ -203,7 +226,7 @@ const jobDetailHTML = `{{ define "jobDetail" }}
|
||||
<div class="flex-1 min-w-0 pr-4">
|
||||
<span class="text-xs font-bold text-indigo-400 uppercase tracking-wide">{{ .Company }}</span>
|
||||
<h2 class="text-xl font-extrabold text-white mt-0.5 leading-tight">{{ .Title }}</h2>
|
||||
<p class="text-xs text-slate-400 mt-1">📍 {{ .Location }} • Ingested {{ formatDate .CrawledAt }}</p>
|
||||
<p class="text-xs text-slate-400 mt-1">📍 {{ .Location }} • Last Updated {{ formatDate .LastSeenAt }}</p>
|
||||
</div>
|
||||
<button onclick="closeDetailModal()" class="flex-shrink-0 text-slate-400 hover:text-white text-lg p-1 rounded hover:bg-slate-800 transition-colors">✕</button>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package ui
|
||||
|
||||
const preferencesTabHTML = `{{ define "preferencesTab" }}
|
||||
<div class="space-y-6 max-w-4xl">
|
||||
<div>
|
||||
<h2 class="text-base font-bold text-white flex items-center gap-2">
|
||||
<span>⚙️</span> System Setup & Maintenance
|
||||
</h2>
|
||||
<p class="text-xs text-slate-400 mt-0.5">Monitor database health and purge stale job postings not seen on target career sites</p>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance Result Alert Banner -->
|
||||
<div id="maintenance-alert" class="hidden p-4 rounded-xl border transition-all"></div>
|
||||
|
||||
<!-- Statistics Overview Grid -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
|
||||
<span class="text-[10px] uppercase font-bold text-slate-400 block tracking-wider">Total Ingested</span>
|
||||
<span class="text-xl font-extrabold text-white mt-1 block">{{ .MaintenanceStats.total_jobs }}</span>
|
||||
</div>
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
|
||||
<span class="text-[10px] uppercase font-bold text-emerald-400 block tracking-wider">Active (≤7d)</span>
|
||||
<span class="text-xl font-extrabold text-emerald-400 mt-1 block">{{ .MaintenanceStats.active_7d }}</span>
|
||||
</div>
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
|
||||
<span class="text-[10px] uppercase font-bold text-amber-400 block tracking-wider">Stale (>7d)</span>
|
||||
<span class="text-xl font-extrabold text-amber-400 mt-1 block">{{ .MaintenanceStats.stale_7d }}</span>
|
||||
</div>
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
|
||||
<span class="text-[10px] uppercase font-bold text-rose-400 block tracking-wider">Stale (>30d)</span>
|
||||
<span class="text-xl font-extrabold text-rose-400 mt-1 block">{{ .MaintenanceStats.stale_30d }}</span>
|
||||
</div>
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
|
||||
<span class="text-[10px] uppercase font-bold text-slate-400 block tracking-wider">Expired / Closed</span>
|
||||
<span class="text-xl font-extrabold text-slate-300 mt-1 block">{{ .MaintenanceStats.expired_count }}</span>
|
||||
</div>
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/70 border border-slate-800">
|
||||
<span class="text-[10px] uppercase font-bold text-violet-400 block tracking-wider">In Pipeline</span>
|
||||
<span class="text-xl font-extrabold text-violet-300 mt-1 block">{{ .MaintenanceStats.in_pipeline }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance Tool: Purge Stale Postings (Worker Queue Driven) -->
|
||||
<div class="p-6 rounded-xl bg-slate-900/60 border border-slate-800 space-y-5">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-9 h-9 rounded-xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-lg">⚠️</span>
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-white">Purge Stale Postings (Mark Expired)</h3>
|
||||
<p class="text-xs text-slate-400 mt-0.5">Enqueues worker queue tasks to mark job postings as <strong class="text-amber-300 font-semibold">Expired</strong> if they have not been discovered on target career boards for X days.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-slate-800/80 grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
|
||||
<div>
|
||||
<label for="expire-days" class="block text-xs font-semibold text-slate-300 mb-1.5">Not updated in:</label>
|
||||
<select id="expire-days" class="w-full px-3 py-2 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500">
|
||||
<option value="7">7+ days</option>
|
||||
<option value="14" selected>14+ days</option>
|
||||
<option value="30">30+ days</option>
|
||||
<option value="60">60+ days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<input type="checkbox" id="expire-protect-pipeline" checked class="rounded bg-slate-800 border-slate-700 text-indigo-600 focus:ring-0">
|
||||
<label for="expire-protect-pipeline" class="text-xs text-slate-300">Protect active applications</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="button"
|
||||
onclick="runMaintenanceAction('/api/maintenance/stale-expire', 'expire-days', 'expire-protect-pipeline', this, '⚠️ Queue Expire Tasks')"
|
||||
class="w-full py-2 bg-amber-600 hover:bg-amber-500 text-xs font-semibold text-white rounded-lg shadow-md transition-all active:scale-95">
|
||||
⚠️ Queue Expire Tasks
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Explanation Card -->
|
||||
<div class="p-5 rounded-xl bg-slate-900/40 border border-slate-800/80 text-xs text-slate-400 space-y-2">
|
||||
<h4 class="font-bold text-slate-200 uppercase tracking-wider text-[11px]">💡 How Job Verification Works</h4>
|
||||
<p>
|
||||
Every time <strong class="text-indigo-300">🔍 Discover</strong> runs, Hireme searches live listings directly on career sites (Google, Apple, Meta).
|
||||
When an existing job appears in search results, its <strong class="text-emerald-400">Last Updated</strong> timestamp is refreshed.
|
||||
If a role closes or is removed from search results, its Last Updated timestamp remains unchanged, allowing you to safely purge expired postings using the tool above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}`
|
||||
@@ -72,6 +72,7 @@ type PageData struct {
|
||||
Targets []db.SearchTarget
|
||||
Jobs []db.JobWithAnalysis
|
||||
Applications []db.JobWithAnalysis
|
||||
MaintenanceStats map[string]int
|
||||
ActiveTab string
|
||||
LLMProvider string
|
||||
ToReviewCount int
|
||||
@@ -92,6 +93,7 @@ func init() {
|
||||
jobDetailHTML,
|
||||
targetsTabHTML,
|
||||
targetListHTML,
|
||||
preferencesTabHTML,
|
||||
applyTabHTML,
|
||||
applyTableHTML,
|
||||
kanbanBoardHTML,
|
||||
@@ -131,3 +133,8 @@ func RenderKanbanBoard(w io.Writer, apps []db.JobWithAnalysis) error {
|
||||
func RenderApplyTable(w io.Writer, apps []db.JobWithAnalysis) error {
|
||||
return rootTemplate.ExecuteTemplate(w, "applyTable", apps)
|
||||
}
|
||||
|
||||
func RenderPreferencesTab(w io.Writer, data PageData) error {
|
||||
return rootTemplate.ExecuteTemplate(w, "preferencesTab", data)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user