diff --git a/admin/frontend/index.html b/admin/frontend/index.html
index 0363fb7..a301119 100644
--- a/admin/frontend/index.html
+++ b/admin/frontend/index.html
@@ -1336,28 +1336,206 @@ function hashCode(str) {
LOOPS (open watchdog alerts)
═══════════════════════════════════════ */
+/* LIFT_A2_HELPERS:BEGIN */
+function formatMoney(usd) {
+ if (usd == null) return 'n/a';
+ return '$' + Number(usd).toFixed(2);
+}
+
+function formatRelativeTime(iso8601) {
+ if (!iso8601) return '';
+ const now = new Date();
+ const date = new Date(iso8601);
+ const diffMs = now - date;
+ const diffSeconds = Math.floor(diffMs / 1000);
+ if (diffSeconds < 10) return 'just now';
+ if (diffSeconds < 60) return Math.floor(diffSeconds) + 's ago';
+ const diffMinutes = Math.floor(diffSeconds / 60);
+ if (diffMinutes < 60) return diffMinutes + 'm ago';
+ const diffHours = Math.floor(diffMinutes / 60);
+ if (diffHours < 24) return diffHours + 'h ago';
+ const diffDays = Math.floor(diffHours / 24);
+ if (diffDays < 30) return diffDays + 'd ago';
+ return diffDays + 'd ago';
+}
+
+function truncate(text, maxLen) {
+ if (!text) return '';
+ if (text.length <= maxLen) return text;
+ return text.substring(0, maxLen) + '...';
+}
+/* LIFT_A2_HELPERS:END */
+
async function loadLoops() {
- const rows = await apiFetch(`${API}api/watchdog/loops`);
+ const data = await apiFetch(`${API}api/watchdog/loops`);
const body = document.getElementById('loops-body');
const countEl = document.getElementById('loops-count');
- if (!rows || !rows.length) {
+ const alerts = data && data.alerts ? data.alerts : [];
+ const resolved = data && data.resolved ? data.resolved : [];
+ countEl.textContent = `${alerts.length} open`;
+
+ if (alerts.length === 0 && resolved.length === 0) {
body.innerHTML = '
No open alerts
';
- countEl.textContent = '0 open';
return;
}
- countEl.textContent = `${rows.length} open`;
- body.innerHTML = rows.map(r => {
+ let html = ''
+ + '| Severity | Session | Model | Top target | '
+ + 'Calls since landed | $ since landed | Open for | | '
+ + '
';
+ alerts.forEach(r => {
const sev = r.severity === 'critical' ? 'bg-danger'
: r.severity === 'warning' ? 'bg-warning'
: 'bg-info';
- return `
- ${escapeHtml(r.severity)}
- ${escapeHtml(r.dedup_key)}
- ${escapeHtml(r.opened_at || '')}
-
`;
- }).join('');
+ const title = escapeHtml(truncate(r.title || r.dedup_key || '', 40));
+ const fullTitle = escapeHtml(r.title || r.dedup_key || '');
+ const model = r.model_id ? escapeHtml(r.model_id) : 'unknown';
+ const topWhat = r.top_what ? escapeHtml(r.top_what) : '—';
+ const calls = r.calls_since_landed != null ? escapeHtml(String(r.calls_since_landed)) : '?';
+ const cost = formatMoney(r.cost_since_landed_usd);
+ const relTime = formatRelativeTime(r.opened_at);
+ const dataModel = r.model_id || '';
+ const dataProvider = r.provider || '';
+ const dataReason = 'looping session ' + (r.title || r.dedup_key || '');
+ const blockBtn = ``;
+ html += ''
+ + `| ${escapeHtml(r.severity)} | `
+ + `${title} | `
+ + `${model} | `
+ + `${topWhat} | `
+ + `${calls} | `
+ + `${cost} | `
+ + `${relTime} | `
+ + `${blockBtn} | `
+ + '
';
+ });
+ if (resolved.length > 0) {
+ resolved.forEach(r => {
+ const sev = r.severity === 'critical' ? 'bg-danger'
+ : r.severity === 'warning' ? 'bg-warning'
+ : 'bg-info';
+ const title = escapeHtml(truncate(r.title || r.dedup_key || '', 40));
+ const fullTitle = escapeHtml(r.title || r.dedup_key || '');
+ const model = r.model_id ? escapeHtml(r.model_id) : 'unknown';
+ const topWhat = r.top_what ? escapeHtml(r.top_what) : '—';
+ const calls = r.calls_since_landed != null ? escapeHtml(String(r.calls_since_landed)) : '?';
+ const cost = formatMoney(r.cost_since_landed_usd);
+ const relTime = formatRelativeTime(r.opened_at);
+ html += ''
+ + `| ${escapeHtml(r.severity)} | `
+ + `${title} | `
+ + `${model} | `
+ + `${topWhat} | `
+ + `${calls} | `
+ + `${cost} | `
+ + `${relTime} | `
+ + ' | '
+ + '
';
+ });
+ }
+ html += '
';
+ body.innerHTML = html;
}
+/* BLOCK_CONFIRM:BEGIN */
+function blockModelConfirm(event) {
+ const btn = event.currentTarget;
+ const model = btn.dataset.model;
+ const prov = btn.dataset.provider;
+ const reason = btn.dataset.reason;
+ if (!model) return;
+ const row = btn.closest('div.d-flex') || btn.closest('tr');
+ if (!row) return;
+ const existing = row.nextElementSibling;
+ if (existing && existing.classList.contains('inline-confirm-row')) {
+ existing.remove();
+ return;
+ }
+ const confirmRow = document.createElement('div');
+ confirmRow.className = 'inline-confirm-row d-flex align-items-center gap-2 py-1';
+ confirmRow.style.cssText = 'border-bottom:1px solid rgba(255,255,255,.05);background:rgba(220,38,38,.08);padding:4px 8px';
+ confirmRow.innerHTML = ''
+ + ''
+ + '';
+
+ const cancelBtn = confirmRow.querySelector('button:last-child');
+ cancelBtn.onclick = () => confirmRow.remove();
+
+ const confirmBtn = confirmRow.querySelector('button:nth-of-type(1)');
+ const input = confirmRow.querySelector('input');
+ confirmBtn.onclick = async () => {
+ const reason = input.value;
+ confirmBtn.disabled = true;
+ const resp = await apiFetch(
+ `${API}api/models/${encodeURIComponent(model)}/${encodeURIComponent(prov)}/availability`,
+ { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ availability: 'blocked', reason: reason }) }
+ );
+ if (resp) {
+ toast('Model blocked', 'success');
+ confirmRow.remove();
+ // Refresh dependent views
+ if (typeof loadStallRollup === 'function') loadStallRollup(document.querySelector('#stall-window .active')?.dataset?.hours || 24);
+ if (typeof loadBlockedModels === 'function') loadBlockedModels();
+ if (typeof loadLoops === 'function') loadLoops();
+ if (typeof loadModels === 'function') loadModels();
+ } else {
+ confirmBtn.disabled = false;
+ confirmRow.querySelector('.inline-error')?.remove();
+ const err = document.createElement('div');
+ err.className = 'text-danger small inline-error';
+ err.textContent = 'Block failed';
+ confirmRow.appendChild(err);
+ }
+ };
+
+ row.after(confirmRow);
+}
+
+function unblockModelConfirm(modelId, provider) {
+ const btn = event.target;
+ const row = btn.closest('div.d-flex') || btn.closest('tr');
+ if (!row) return;
+ const existing = row.nextElementSibling;
+ if (existing && existing.classList.contains('inline-confirm-row')) {
+ existing.remove();
+ return;
+ }
+ const confirmRow = document.createElement('div');
+ confirmRow.className = 'inline-confirm-row d-flex align-items-center gap-2 py-1';
+ confirmRow.style.cssText = 'border-bottom:1px solid rgba(255,255,255,.05);background:rgba(220,38,38,.08);padding:4px 8px';
+ confirmRow.innerHTML = 'Unblock this model?'
+ + ''
+ + '';
+
+ const cancelBtn = confirmRow.querySelector('button:last-child');
+ cancelBtn.onclick = () => confirmRow.remove();
+
+ const confirmBtn = confirmRow.querySelector('button:nth-of-type(1)');
+ confirmBtn.onclick = async () => {
+ confirmBtn.disabled = true;
+ const resp = await apiFetch(
+ `${API}api/models/${encodeURIComponent(modelId)}/${encodeURIComponent(provider)}/availability`,
+ { method:'DELETE' }
+ );
+ if (resp) {
+ toast('Model unblocked', 'success');
+ confirmRow.remove();
+ if (typeof loadBlockedModels === 'function') loadBlockedModels();
+ if (typeof loadModels === 'function') loadModels();
+ if (typeof loadStallRollup === 'function') loadStallRollup(document.querySelector('#stall-window .active')?.dataset?.hours || 24);
+ } else {
+ confirmBtn.disabled = false;
+ confirmRow.querySelector('.inline-error')?.remove();
+ const err = document.createElement('div');
+ err.className = 'text-danger small inline-error';
+ err.textContent = 'Unblock failed';
+ confirmRow.appendChild(err);
+ }
+ };
+
+ row.after(confirmRow);
+}
+/* BLOCK_CONFIRM:END */
+
/* ═══════════════════════════════════════
INIT
═══════════════════════════════════════ */
diff --git a/admin/frontend/models.html b/admin/frontend/models.html
index 728f1d7..5fb8aeb 100644
--- a/admin/frontend/models.html
+++ b/admin/frontend/models.html
@@ -124,7 +124,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
#model-tbody .model-override{height:24px;padding:.05rem .35rem;font-size:.7rem;line-height:1.2}
/* Numeric columns align right and share the tabular figures the rest of the
portal uses, so magnitudes line up down the column. */
-#model-tbody td.num, thead th.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
+tbody td.num, thead th.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
.prof-n{color:var(--tblr-secondary);opacity:.6;font-size:.62rem;margin-left:1px}
.cost-free{color:#4ade80}
/* Capability chips reuse the decisions page's flag-chip language so the two
@@ -221,6 +221,48 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
+
+
@@ -354,7 +396,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
-
+