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 = '
' + + '' + + '' + + ''; + 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 += '' + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ''; + }); + 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 += '' + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + '' + + ''; + }); + } + html += '
SeveritySessionModelTop targetCalls since landed$ since landedOpen for
${escapeHtml(r.severity)}${title}${model}${topWhat}${calls}${cost}${relTime}${blockBtn}
${escapeHtml(r.severity)}${title}${model}${topWhat}${calls}${cost}${relTime}
'; + 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}
+
+
+
+
+
+ +

Stalls by model

+
+
Flagged looping sessions per model
+
+
+ + + +
+
+
+
+
+
Loading…
+
+
+
+
+
+
+
+
+
+
+ +

Blocked models

+
+
+
+
+
Loading…
+
+
+
+
+
@@ -354,7 +396,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
- +