Files
hyprcrd/public/plugins/oumta-dashboard/oumta-dashboard.js
T
thandClaude Opus 4.7 72a7c7c7cc v2.0.1 — collab-attachments size cap + oumta overview tiles
- relay: raise /api/collab-attachments + /api/attachments/upload POST
  size limit from 256KB to 15MB, matching /api/attachments. Previous
  limit silently dropped any collab image >~100KB as 413 (envelope
  overhead pushes even modest JPEGs past the cap), leaving puller
  clients with broken <img src="uploads/..."> references.
- oumta-dashboard: centre overview tiles, bump value font to 56px,
  introduce .oumta-fail alarm state that paints the full tile red with
  white text. Toggled from JS for cluster state=down, workloads down
  >0, and open incidents >0. Fail colour uses --t-danger so it stays
  in sync with the "unavailable" workload labels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 18:38:17 +02:00

542 lines
27 KiB
JavaScript

// ── Oumta Dashboard plugin ──
//
// Fullscreen view that consumes the Oumta Go backend (`oumta-dashboard`).
// Currently ALL data is mocked so we can iterate on layout without waiting
// for the backend. See TODO.md for every call that must later become a real
// fetch against the backend.
const h = window.hyprcrd;
const $ = id => document.getElementById(id);
// ── Settings / persisted state ──
//
// We use localStorage for now. Later we might persist via a hyprcrd setting
// or a plugin-level table — see TODO.md.
const LS_KEY = 'oumta-dashboard-settings';
const state = {
settings: {
baseUrl: '', // e.g. https://dashboard.oumta.internal
authToken: '', // Keycloak bearer; empty = no auth yet
pollIntervalSec: 15,
},
namespaces: [], // [{ id, label, namespace }]
activeNamespace: null,
activeView: 'overview',
liveMode: 'polling', // 'live' | 'polling' | 'error' | 'off'
pollHandle: null,
sseHandle: null,
data: { overview: null, workloads: [], timeline: [], scopeWorkload: null },
};
function loadSettings() {
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) Object.assign(state.settings, JSON.parse(raw));
} catch {}
}
function saveSettings() {
localStorage.setItem(LS_KEY, JSON.stringify(state.settings));
}
// ── Central API surface ─────────────────────────────────────────
//
// Every backend call routes through `api()`. Right now each case returns
// mock data. When the backend endpoints are ready, swap the `return MOCK…`
// lines for real `fetch(…)` calls. The UI code above this layer should
// not need to change.
//
// Field naming legend for the mocks below:
// [REAL] = already defined in Go code (internal/httpapi/router.go).
// Must match byte-for-byte when flipping to real fetch.
// [PROPOSED] = our guess based on docs/README.md prose. Backend author
// is free to rename — when they do, adjust the renderer
// (there's only one place per field) or add a small adapter
// inside api() that normalises the response.
const MOCK_NAMESPACES = [
{ id: 'oumta-dev', label: 'oumta-dev', namespace: 'oumta-dev' },
{ id: 'oumta-beta', label: 'oumta-beta', namespace: 'oumta-beta' },
];
// Overview mock — shape matches the existing Go struct `clusterOverviewResponse`
// byte-for-byte (see cmd/api/main.go → internal/httpapi/router.go).
// [REAL] clusterState, workloadsUp, workloadsDown, updatedAtUtc
// Anything else the overview page shows (incidents count, attention list) is
// DERIVED client-side from the workloads response — see renderOverview().
function mockOverview(ns) {
const wl = mockWorkloads(ns);
const up = wl.filter(w => w.state === 'healthy').length;
const down = wl.filter(w => w.state === 'unavailable').length;
const degraded = wl.filter(w => w.state === 'degraded').length;
const clusterState = down > 0 ? 'down' : (degraded > 0 ? 'degraded' : 'healthy');
return {
clusterState,
workloadsUp: up,
workloadsDown: down,
updatedAtUtc: new Date().toISOString(),
};
}
// Workloads mock — [PROPOSED] shape. Fields chosen from docs/README.md §2:
// Name, Namespace, Zustand, Replikas (ready/desired), CPU, Speicher,
// Image, Letzter Grund für Zustandswechsel.
// `workloadId` is our synthetic stable handle (kind/namespace/name) so the
// timeline can be linked back. If the backend emits a different handle
// format we'll adjust the link-builders.
function mockWorkloads(ns) {
const id = name => `deployment/${ns}/${name}`;
return [
{ workloadId: id('api'), name: 'api', namespace: ns, state: 'degraded', replicasReady: 2, replicasDesired: 3, cpu: '420m', memory: '512Mi', image: 'oumta/api:1.4.2', lastReason: 'CrashLoopBackOff', lastTransitionAt: minutesAgo(12) },
{ workloadId: id('api-canary'), name: 'api-canary', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '140m', memory: '310Mi', image: 'oumta/api:1.4.3-rc2', lastReason: '—', lastTransitionAt: minutesAgo(180) },
{ workloadId: id('web'), name: 'web', namespace: ns, state: 'healthy', replicasReady: 2, replicasDesired: 2, cpu: '95m', memory: '256Mi', image: 'oumta/web:2.1.0', lastReason: '—', lastTransitionAt: minutesAgo(720) },
{ workloadId: id('worker'), name: 'worker', namespace: ns, state: 'healthy', replicasReady: 4, replicasDesired: 4, cpu: '820m', memory: '1Gi', image: 'oumta/worker:1.4.2', lastReason: '—', lastTransitionAt: minutesAgo(121) },
{ workloadId: id('ingest'), name: 'ingest', namespace: ns, state: 'unavailable', replicasReady: 0, replicasDesired: 1, cpu: '—', memory: '—', image: 'oumta/ingest:3.0.0', lastReason: 'ImagePullBackOff', lastTransitionAt: minutesAgo(3) },
{ workloadId: id('scheduler'), name: 'scheduler', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '55m', memory: '120Mi', image: 'oumta/scheduler:0.9.1', lastReason: '—', lastTransitionAt: minutesAgo(2880)},
{ workloadId: id('postgres'), name: 'postgres', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '180m', memory: '768Mi', image: 'postgres:16.2', lastReason: '—', lastTransitionAt: minutesAgo(4320)},
{ workloadId: id('redis'), name: 'redis', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '25m', memory: '80Mi', image: 'redis:7.2-alpine', lastReason: '—', lastTransitionAt: minutesAgo(4320)},
{ workloadId: id('nats'), name: 'nats', namespace: ns, state: 'degraded', replicasReady: 2, replicasDesired: 3, cpu: '60m', memory: '200Mi', image: 'nats:2.10.14', lastReason: 'Readiness probe failed', lastTransitionAt: minutesAgo(45) },
{ workloadId: id('reports'), name: 'reports', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '75m', memory: '180Mi', image: 'oumta/reports:1.2.0', lastReason: '—', lastTransitionAt: minutesAgo(600) },
{ workloadId: id('notifier'), name: 'notifier', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '30m', memory: '96Mi', image: 'oumta/notifier:0.5.3', lastReason: '—', lastTransitionAt: minutesAgo(1500)},
{ workloadId: id('migrations'), name: 'migrations', namespace: ns, state: 'healthy', replicasReady: 0, replicasDesired: 0, cpu: '—', memory: '—', image: 'oumta/migrations:1.4.2', lastReason: 'Completed', lastTransitionAt: minutesAgo(300) },
];
}
// Timeline mock — [PROPOSED] shape.
// Fields inferred from docs §3 (Detail page) & §4 (filters): timestamp,
// severity, workloadId, message, reason. `kind` is a derived hint for
// rendering (deployment vs. incident vs. scale etc.) and can be dropped
// if the backend doesn't provide it.
function mockTimeline(ns, workloadId = null) {
const id = name => `deployment/${ns}/${name}`;
const all = [
{ timestamp: minutesAgo(3), severity: 'error', workloadId: id('ingest'), message: 'Pod ingest-6d8bf4 entered ImagePullBackOff', reason: 'ImagePullBackOff', kind: 'incident' },
{ timestamp: minutesAgo(8), severity: 'warning', workloadId: id('nats'), message: 'Replica 3/3 readiness probe failed', reason: 'ProbeFailed', kind: 'incident' },
{ timestamp: minutesAgo(12), severity: 'warning', workloadId: id('api'), message: 'Pod api-7c9ff6 restarted (3rd time in 10m)', reason: 'CrashLoopBackOff', kind: 'incident' },
{ timestamp: minutesAgo(26), severity: 'info', workloadId: id('api'), message: 'HorizontalPodAutoscaler targeted 3 replicas', reason: 'HPA', kind: 'scale' },
{ timestamp: minutesAgo(48), severity: 'info', workloadId: id('web'), message: 'Rollout completed to revision 17', reason: 'DeploymentRolled', kind: 'deployment' },
{ timestamp: minutesAgo(58), severity: 'info', workloadId: id('web'), message: 'Rollout started: oumta/web:2.1.0', reason: 'DeploymentStarted', kind: 'deployment' },
{ timestamp: minutesAgo(121), severity: 'info', workloadId: id('worker'), message: 'Scaled up from 2 to 4 replicas', reason: 'HPA', kind: 'scale' },
{ timestamp: minutesAgo(180), severity: 'info', workloadId: id('api-canary'), message: 'Canary became ready', reason: 'CanaryReady', kind: 'deployment' },
{ timestamp: minutesAgo(240), severity: 'info', workloadId: id('api'), message: 'Rollout started: oumta/api:1.4.2', reason: 'DeploymentStarted', kind: 'deployment' },
{ timestamp: minutesAgo(300), severity: 'info', workloadId: id('migrations'), message: 'Job completed successfully in 1m42s', reason: 'JobSucceeded', kind: 'deployment' },
{ timestamp: minutesAgo(720), severity: 'info', workloadId: id('reports'), message: 'Rolled out revision 9', reason: 'DeploymentRolled', kind: 'deployment' },
{ timestamp: minutesAgo(1500), severity: 'warning', workloadId: id('ingest'), message: 'OOMKilled: container exceeded 256Mi limit', reason: 'OOMKilled', kind: 'incident' },
{ timestamp: minutesAgo(2880), severity: 'info', workloadId: id('scheduler'), message: 'Initial rollout: oumta/scheduler:0.9.1', reason: 'DeploymentStarted', kind: 'deployment' },
];
return workloadId ? all.filter(e => e.workloadId === workloadId) : all;
}
function minutesAgo(m) { return new Date(Date.now() - m * 60_000).toISOString(); }
async function api(path, opts = {}) {
// TODO(api): when backend is ready, build a real fetch here.
// const url = new URL(path, state.settings.baseUrl);
// const headers = state.settings.authToken
// ? { Authorization: 'Bearer ' + state.settings.authToken } : {};
// const res = await fetch(url, { ...opts, headers });
// if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
// return res.json();
// Mock dispatch — keep in sync with backend `docs/README.md`.
const ns = state.activeNamespace?.namespace || '';
if (path === '/api/namespaces') return { items: MOCK_NAMESPACES }; // TODO(api)
if (path === '/api/cluster/overview') return mockOverview(ns); // TODO(api): GET /api/cluster/overview?namespace=
if (path === '/api/workloads') return { items: mockWorkloads(ns) }; // TODO(api): GET /api/workloads?namespace=&state=&since=
if (path.startsWith('/api/workloads/') && path.endsWith('/timeline')) {
// /api/workloads/{namespace}/{name}/timeline
const parts = path.replace('/api/workloads/', '').replace('/timeline', '').split('/');
const workloadId = `deployment/${parts[0]}/${parts[1]}`;
return { items: mockTimeline(ns, workloadId) }; // TODO(api)
}
if (path === '/api/timeline') return { items: mockTimeline(ns) }; // TODO(api): GET /api/timeline?namespace=
throw new Error('unknown api path: ' + path);
}
// ── Fullscreen overlay show/hide ─────────────────────────────────
const overlay = $('oumta-overlay');
function isOpen() {
return overlay?.classList.contains('open');
}
function show() {
if (!overlay) return;
overlay.classList.add('open');
if (!state.namespaces.length) bootstrap();
else {
refresh();
startLive();
}
}
function hide() {
if (!overlay) return;
overlay.classList.remove('open');
stopLive();
}
function toggle() {
isOpen() ? hide() : show();
}
window._oumtaToggle = toggle;
// ── Bootstrap: load namespaces, then first refresh ─────────────
async function bootstrap() {
try {
const res = await api('/api/namespaces');
state.namespaces = res.items || [];
state.activeNamespace = state.namespaces[0] || null;
renderNamespaceTabs();
await refresh();
startLive();
} catch (e) {
setLive('error', e.message);
}
}
function renderNamespaceTabs() {
const host = $('oumta-ns-tabs');
if (!host) return;
host.innerHTML = state.namespaces.map(n => `
<button class="oumta-ns-tab ${state.activeNamespace?.id === n.id ? 'active' : ''}" data-ns="${n.id}">
${n.label}
</button>
`).join('');
host.querySelectorAll('.oumta-ns-tab').forEach(btn => {
btn.addEventListener('click', () => {
const ns = state.namespaces.find(x => x.id === btn.dataset.ns);
if (ns && ns.id !== state.activeNamespace?.id) {
state.activeNamespace = ns;
renderNamespaceTabs();
refresh();
}
});
});
}
// ── View switching ────────────────────────────────────────────
document.addEventListener('click', e => {
const tab = e.target.closest('.oumta-view-tab');
if (!tab || !overlay.contains(tab)) return;
switchView(tab.dataset.view);
});
function switchView(view) {
state.activeView = view;
document.querySelectorAll('.oumta-view-tab').forEach(b =>
b.classList.toggle('active', b.dataset.view === view));
document.querySelectorAll('.oumta-view').forEach(s =>
s.classList.toggle('hidden', s.dataset.view !== view));
// Reset workload-scoped timeline when leaving timeline view
if (view !== 'timeline') state.data.scopeWorkload = null;
renderTimeline();
}
// ── Refresh all data for the active namespace ──────────────────
async function refresh() {
if (!state.activeNamespace) return;
try {
const [ov, wl, tl] = await Promise.all([
api('/api/cluster/overview'),
api('/api/workloads'),
api('/api/timeline'),
]);
state.data.overview = ov;
state.data.workloads = wl.items || [];
state.data.timeline = tl.items || [];
renderAll();
} catch (e) {
setLive('error', e.message);
}
}
// ── Renderers ─────────────────────────────────────────────────
function renderAll() {
renderOverview();
renderWorkloads();
renderTimeline();
const genAt = state.data.overview?.updatedAtUtc;
const el = $('oumta-generated-at');
if (el && genAt) el.textContent = 'last import: ' + formatTime(genAt);
}
function renderOverview() {
const o = state.data.overview;
const wl = state.data.workloads || [];
if (!o) return;
// Values that come directly from the Go overview endpoint [REAL].
const clusterCard = $('oumta-cluster-card');
const clusterState = o.clusterState || 'unknown';
clusterCard.dataset.state = clusterState;
clusterCard.classList.toggle('oumta-fail', clusterState === 'down');
$('oumta-cluster-state').textContent = clusterState;
$('oumta-cluster-updated').textContent = o.updatedAtUtc ? 'as of ' + formatTime(o.updatedAtUtc) : 'no data';
$('oumta-workloads-up').textContent = o.workloadsUp ?? '—';
$('oumta-workloads-down').textContent = o.workloadsDown ?? '—';
$('oumta-last-import').textContent = o.updatedAtUtc ? formatTime(o.updatedAtUtc) : '—';
// Any workload down → the "workloads down" tile becomes an alarm tile.
const downCard = $('oumta-workloads-down').closest('.oumta-card');
if (downCard) downCard.classList.toggle('oumta-fail', (o.workloadsDown ?? 0) > 0);
// Values derived client-side from the workloads list — the overview
// endpoint doesn't carry these today. If the backend later adds
// `incidents` / `attention` fields, prefer those over the derivation.
const attention = wl.filter(w => w.state !== 'healthy')
.sort((a, b) => rankState(a.state) - rankState(b.state));
$('oumta-incidents').textContent = attention.length || '—';
const incCard = $('oumta-incidents').closest('.oumta-card');
if (incCard) incCard.classList.toggle('oumta-fail', attention.length > 0);
const list = $('oumta-attention-list');
if (!attention.length) {
list.innerHTML = '<div class="oumta-empty">Nothing currently flagged.</div>';
return;
}
list.innerHTML = attention.map(a => `
<div class="oumta-attention-row" data-wlid="${escapeAttr(a.workloadId)}">
<span class="oumta-attention-state" data-state="${a.state}">${a.state}</span>
<span class="oumta-attention-name">${escapeHtml(a.name)}</span>
<span class="oumta-attention-ns">${escapeHtml(a.namespace)}</span>
<span class="oumta-attention-reason">${escapeHtml(a.lastReason || '')}</span>
</div>
`).join('');
list.querySelectorAll('.oumta-attention-row').forEach(row => {
row.addEventListener('click', () => openWorkload(row.dataset.wlid));
});
}
// Severity order for the attention list: worst first.
function rankState(s) {
return { unavailable: 0, degraded: 1, healthy: 2 }[s] ?? 9;
}
function renderWorkloads() {
const search = ($('oumta-wl-search').value || '').toLowerCase();
const filterState = $('oumta-wl-status').value;
const body = $('oumta-wl-body');
const rows = (state.data.workloads || []).filter(w => {
if (search && !w.name.toLowerCase().includes(search)) return false;
if (filterState && w.state !== filterState) return false;
return true;
});
if (!rows.length) {
body.innerHTML = '<tr><td colspan="8" class="oumta-empty">No workloads match.</td></tr>';
return;
}
body.innerHTML = rows.map(w => `
<tr data-wlid="${escapeAttr(w.workloadId)}">
<td>${escapeHtml(w.name)}</td>
<td>${escapeHtml(w.namespace)}</td>
<td class="wl-state-${w.state}">${w.state}</td>
<td>${w.replicasReady}/${w.replicasDesired}</td>
<td>${escapeHtml(w.cpu)}</td>
<td>${escapeHtml(w.memory)}</td>
<td>${escapeHtml(w.image)}</td>
<td>${escapeHtml(w.lastReason)}</td>
</tr>
`).join('');
body.querySelectorAll('tr').forEach(tr => {
tr.addEventListener('click', () => openWorkload(tr.dataset.wlid));
});
}
function renderTimeline() {
const scope = state.data.scopeWorkload;
const scopeEl = $('oumta-timeline-scope');
const backEl = $('oumta-timeline-back');
if (scope) {
scopeEl.textContent = 'events for ' + scope;
backEl.classList.remove('hidden');
} else {
scopeEl.textContent = 'all events';
backEl.classList.add('hidden');
}
const events = scope
? (state.data.timeline.filter(e => e.workloadId === scope))
: state.data.timeline;
const body = $('oumta-timeline-body');
if (!events.length) {
body.innerHTML = '<div class="oumta-empty">No events.</div>';
return;
}
body.innerHTML = events.map(e => `
<div class="oumta-timeline-entry">
<div class="oumta-timeline-time">${formatTime(e.timestamp)}</div>
<div class="oumta-timeline-severity" data-sev="${e.severity}">${e.severity}</div>
<div>
<div class="oumta-timeline-message">${escapeHtml(e.message)}</div>
<div class="oumta-timeline-meta">${escapeHtml(e.workloadId)} · ${escapeHtml(e.reason || '')}</div>
</div>
</div>
`).join('');
}
// ── Workload → detail-as-timeline navigation ──────────────────
async function openWorkload(workloadId) {
state.data.scopeWorkload = workloadId;
const [nsSlug, name] = workloadId.replace('deployment/', '').split('/');
// Pull workload-scoped timeline — this REPLACES the generic one for rendering.
try {
const res = await api(`/api/workloads/${nsSlug}/${name}/timeline`);
state.data.timeline = res.items || [];
} catch (e) { setLive('error', e.message); }
switchView('timeline');
}
$('oumta-timeline-back')?.addEventListener('click', () => {
state.data.scopeWorkload = null;
// Restore the global timeline
api('/api/timeline').then(r => {
state.data.timeline = r.items || [];
renderTimeline();
}).catch(e => setLive('error', e.message));
});
// ── Realtime (SSE) with polling fallback ──────────────────────
//
// Plan: open an EventSource at <baseUrl>/api/events?namespace=… — backend
// not implemented yet, so we go straight to polling. When SSE is available
// we can try to connect and fall back on `error`.
function startLive() {
stopLive();
// TODO(api): attempt SSE first:
// state.sseHandle = new EventSource(url);
// state.sseHandle.onmessage = ev => applyLiveEvent(JSON.parse(ev.data));
// state.sseHandle.onerror = () => { stopLive(); startPolling(); setLive('polling'); };
// state.sseHandle.onopen = () => setLive('live');
startPolling();
}
function stopLive() {
if (state.pollHandle) { clearInterval(state.pollHandle); state.pollHandle = null; }
if (state.sseHandle) { state.sseHandle.close(); state.sseHandle = null; }
setLive('off');
}
function startPolling() {
const ms = (state.settings.pollIntervalSec || 15) * 1000;
state.pollHandle = setInterval(refresh, ms);
setLive('polling');
}
function setLive(mode, detail = '') {
state.liveMode = mode;
const el = $('oumta-live-indicator');
const lbl = $('oumta-live-label');
if (el) el.dataset.state = mode;
if (lbl) {
lbl.textContent = ({
live: 'live',
polling: 'polling',
error: 'error' + (detail ? ' — ' + detail : ''),
off: 'off',
})[mode] || mode;
}
}
// ── Header buttons ───────────────────────────────────────────
$('oumta-close')?.addEventListener('click', hide);
$('oumta-refresh')?.addEventListener('click', refresh);
$('oumta-wl-search')?.addEventListener('input', renderWorkloads);
$('oumta-wl-status')?.addEventListener('change', renderWorkloads);
$('oumta-wl-timerange')?.addEventListener('change', () => {
// TODO(api): forward to /api/workloads?since=<ISO> once the backend accepts it.
renderWorkloads();
});
// ESC to close
window.addEventListener('keydown', e => {
if (e.key === 'Escape' && isOpen()) {
e.preventDefault(); e.stopPropagation();
hide();
}
}, { capture: true });
// ── Inject header button into hyprcrd top bar ────────────────
function injectHeaderButton() {
const tasksBtn = document.getElementById('tasks-btn');
if (!tasksBtn || document.getElementById('oumta-btn')) return;
const btn = document.createElement('button');
btn.id = 'oumta-btn';
btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
btn.innerHTML = '<i class="ph ph-gauge text-sm"></i><span class="font-pixel text-[10px]">Oumta</span>';
btn.addEventListener('click', toggle);
tasksBtn.parentNode.insertBefore(btn, tasksBtn);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectHeaderButton);
} else {
injectHeaderButton();
}
// ── Settings tab in hyprcrd settings ─────────────────────────
function renderSettingsPanel(panelEl) {
panelEl.innerHTML = `
<div class="oumta-settings-field">
<label for="oumta-set-baseurl">API Base URL</label>
<input type="text" id="oumta-set-baseurl" placeholder="http://localhost:8080">
<div class="oumta-settings-help">Where the Oumta Go backend is reachable from this browser. No trailing slash.</div>
</div>
<div class="oumta-settings-field">
<label for="oumta-set-token">Auth token (optional)</label>
<input type="text" id="oumta-set-token" placeholder="Bearer token from Keycloak">
<div class="oumta-settings-help">Will be sent as <code>Authorization: Bearer &lt;token&gt;</code>. Leave empty if the API is unauthenticated.</div>
</div>
<div class="oumta-settings-field">
<label for="oumta-set-poll">Poll interval (seconds)</label>
<input type="number" id="oumta-set-poll" min="5" max="300">
<div class="oumta-settings-help">Used as a fallback when SSE is unavailable.</div>
</div>
<button id="oumta-set-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors">
<i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span>
</button>
<span id="oumta-set-status" class="font-pixel text-[10px] text-t-dim ml-3"></span>
`;
$('oumta-set-baseurl').value = state.settings.baseUrl || '';
$('oumta-set-token').value = state.settings.authToken || '';
$('oumta-set-poll').value = state.settings.pollIntervalSec || 15;
$('oumta-set-save').addEventListener('click', () => {
state.settings.baseUrl = $('oumta-set-baseurl').value.trim();
state.settings.authToken = $('oumta-set-token').value.trim();
state.settings.pollIntervalSec = Math.max(5, parseInt($('oumta-set-poll').value) || 15);
saveSettings();
const s = $('oumta-set-status');
s.textContent = 'saved';
setTimeout(() => { s.textContent = ''; }, 1500);
// If the overlay is open, restart polling with new interval.
if (isOpen()) { stopLive(); startLive(); }
});
}
// ── Helpers ─────────────────────────────────────────────────
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function escapeAttr(s) { return escapeHtml(s); }
function formatTime(iso) {
try {
const d = new Date(iso);
return d.toLocaleString();
} catch { return iso; }
}
// ── Boot ────────────────────────────────────────────────────
loadSettings();
h?.settings?.registerTab?.({
id: 'oumta-dashboard',
label: 'oumta',
icon: 'ph-gauge',
onShow: renderSettingsPanel,
});
h?.registerPlugin?.({ name: 'oumta-dashboard', version: '0.1.0' });