<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Admin Ops — sfpermits.ai</title>
<script src="/static/htmx.min.js"></script>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface-2: #252834;
--border: #333749;
--text: #e4e6eb;
--text-muted: #8b8fa3;
--accent: #4f8ff7;
--success: #34d399;
--warning: #fbbf24;
--error: #f87171;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
.container { max-width: 1100px; margin: 0 auto; padding: 0 24px; }
/* header styles provided by fragments/nav.html */
main { padding: 40px 0 80px; }
h1 { font-size: 1.5rem; margin-bottom: 8px; }
.subtitle { color: var(--text-muted); margin-bottom: 20px; font-size: 0.9rem; }
/* Tab bar */
.tab-bar {
display: flex; gap: 6px; margin-bottom: 24px; flex-wrap: wrap;
}
.tab-btn {
padding: 7px 16px; border-radius: 6px; font-size: 0.8rem;
border: 1px solid var(--border); background: var(--surface-2);
color: var(--text-muted); cursor: pointer; font-family: inherit;
text-decoration: none; transition: all 0.15s;
}
.tab-btn:hover { border-color: var(--text-muted); color: var(--text); }
.tab-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.tab-btn.loading {
color: var(--accent) !important;
background: var(--surface-2) !important;
border-color: var(--accent) !important;
animation: tab-pulse 0.5s ease-in-out infinite alternate;
}
@keyframes tab-pulse {
from { border-color: rgba(79,143,247,0.3); }
to { border-color: rgba(79,143,247,1); }
}
/* Content area */
#tab-content {
min-height: 200px;
}
.tab-loading {
text-align: center; padding: 60px 0;
color: var(--text-muted); font-size: 0.9rem;
}
.tab-loading-spinner {
display: inline-block; width: 20px; height: 20px;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-right: 8px; vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
<link rel="stylesheet" href="/static/mobile.css">
</head>
<body>
{% include 'fragments/nav.html' %}
<main>
<div class="container">
<h1><span style="font-size:0.65rem;background:var(--surface-2);color:var(--text-muted);padding:3px 8px;border-radius:8px;border:1px solid var(--border);vertical-align:middle;margin-right:8px;">⚙ Admin</span>Operations</h1>
<p class="subtitle">Pipeline health, data quality, and admin tools.</p>
<div class="tab-bar" id="tab-bar">
<button class="tab-btn" data-tab="pipeline"
hx-get="/admin/ops/fragment/pipeline"
hx-target="#tab-content"
hx-swap="innerHTML"
hx-indicator="find .tab-loading">Pipeline Health</button>
<button class="tab-btn" data-tab="quality"
hx-get="/admin/ops/fragment/quality"
hx-target="#tab-content"
hx-swap="innerHTML">Data Quality</button>
<button class="tab-btn" data-tab="activity"
hx-get="/admin/ops/fragment/activity"
hx-target="#tab-content"
hx-swap="innerHTML">User Activity</button>
<button class="tab-btn" data-tab="feedback"
hx-get="/admin/ops/fragment/feedback"
hx-target="#tab-content"
hx-swap="innerHTML">Feedback</button>
<button class="tab-btn" data-tab="sources"
hx-get="/admin/ops/fragment/sources"
hx-target="#tab-content"
hx-swap="innerHTML">LUCK Sources</button>
<button class="tab-btn" data-tab="regulatory"
hx-get="/admin/ops/fragment/regulatory"
hx-target="#tab-content"
hx-swap="innerHTML">Regulatory Watch</button>
</div>
<div id="tab-content">
<div class="tab-loading">
<span class="tab-loading-spinner"></span>
Loading...
</div>
</div>
</div>
</main>
{% include 'fragments/feedback_widget.html' %}
<script>
(function() {
// 30s client-side timeout for all HTMX requests on this page
htmx.config.timeout = 30000;
var buttons = document.querySelectorAll('.tab-btn');
var activeTab = null;
var contentLoaded = false; // track whether ANY content has loaded
// Hash aliases — allow friendly names in URL hash
var hashAliases = {
'luck': 'sources',
'dq': 'quality',
'watch': 'regulatory'
};
// Helper: extract the trigger element from an HTMX event.
// HTMX 2.0 may put the element at evt.detail.elt OR
// evt.detail.requestConfig.elt depending on the event type.
function getTrigger(evt) {
var d = evt.detail || {};
return d.elt || (d.requestConfig && d.requestConfig.elt) || null;
}
function showError(msg) {
contentLoaded = true;
document.getElementById('tab-content').innerHTML =
'<div class="tab-loading" style="color:var(--error);">' +
(msg || 'Failed to load tab.') +
' <a href="javascript:location.reload()" style="color:var(--accent);">Reload page</a>' +
'</div>';
buttons.forEach(function(b) { b.classList.remove('loading'); });
}
function activateTab(tabName) {
if (activeTab === tabName) return;
activeTab = tabName;
buttons.forEach(function(btn) {
var isActive = btn.dataset.tab === tabName;
btn.classList.toggle('active', isActive);
if (isActive) btn.classList.remove('loading');
});
history.replaceState(null, '', '#' + tabName);
}
// Tab click handling — HTMX handles the content loading,
// we just handle the active state and loading animation
buttons.forEach(function(btn) {
btn.addEventListener('click', function() {
if (this.classList.contains('active')) return;
buttons.forEach(function(b) { b.classList.remove('active', 'loading'); });
this.classList.add('loading');
contentLoaded = false;
});
});
// After HTMX swaps content, mark the tab as active
document.body.addEventListener('htmx:afterSwap', function(evt) {
contentLoaded = true;
var trigger = getTrigger(evt);
if (trigger && trigger.dataset && trigger.dataset.tab) {
activateTab(trigger.dataset.tab);
}
});
// Handle HTTP errors (4xx, 5xx)
document.body.addEventListener('htmx:responseError', function(evt) {
showError('Tab returned an error (HTTP ' + ((evt.detail.xhr && evt.detail.xhr.status) || '?') + ').');
});
// Handle network errors (connection reset, CORS failures)
document.body.addEventListener('htmx:sendError', function(evt) {
showError('Request failed — server may be busy or unreachable.');
});
// Handle client-side timeout (30s)
document.body.addEventListener('htmx:timeout', function(evt) {
showError('Request timed out (30s). The server may be overloaded.');
});
// ── Fallback safety net ──────────────────────────────────────
// If nothing has loaded after 35s (HTMX timeout is 30s + 5s grace),
// force-show an error. Catches edge cases where HTMX events don't
// fire or the event detail structure doesn't match our handlers.
setTimeout(function() {
if (!contentLoaded) {
showError('Page failed to load. The server did not respond in time.');
}
}, 35000);
// Load initial tab from hash or default to quality.
// Uses htmx.ajax() directly instead of simulating a button click,
// because the inline script runs BEFORE HTMX's DOMContentLoaded
// handler processes hx-get attributes on the buttons. A simulated
// .click() would fire before HTMX is listening, causing a no-op.
var hash = location.hash.replace('#', '') || 'quality';
hash = hashAliases[hash] || hash;
var initialBtn = document.querySelector('.tab-btn[data-tab="' + hash + '"]');
if (!initialBtn) initialBtn = document.querySelector('.tab-btn[data-tab="quality"]');
if (initialBtn) {
initialBtn.classList.add('loading');
// htmx.ajax bypasses element processing — works immediately
htmx.ajax('GET', '/admin/ops/fragment/' + hash, {target: '#tab-content', swap: 'innerHTML'});
}
})();
</script>
</body>
</html>