- Connector: moved from per-entry pseudo-elements to a single linear-gradient line on the body. Fades in/out 28px at each end and no longer has gaps at entry boundaries. Dots and the date rect's solid background sit naturally on top; everything else stays transparent so the line threads through cleanly. - Layout: entry is now a 2-row grid. The optional date rect lives in row 1 (justify-self:start, left-aligned), while the dot-row and the events column both pin to row 2 so the time label always lines up with the first event of the cluster — even when a date rect sits above. Time+dot `align-self: start` keeps them at the top of the event section instead of vertically centred through the whole cluster. - Filters: state.timelineFilter is a Set now, so clicking multiple workload pills (or the overflow entries) stacks them as an any-of filter. "all" clears the set. The overflow dropdown stays open on pick so several can be toggled without reopening it each time. Scope label shows "filtered · N" with the active count. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
572 lines
23 KiB
JavaScript
572 lines
23 KiB
JavaScript
// ── Oumta Dashboard plugin ──
|
|
//
|
|
// Fullscreen view that consumes the Oumta Go backend (`oumta-dashboard`).
|
|
// Backend location + poll interval are hardwired below — change them here
|
|
// if the deployment topology shifts. No UI settings panel on purpose:
|
|
// this plugin only makes sense in one specific deployment, so a settings
|
|
// tab would just be noise and tempt accidental misconfiguration.
|
|
|
|
const h = window.hyprcrd;
|
|
const $ = id => document.getElementById(id);
|
|
|
|
const CONFIG = {
|
|
baseUrl: 'http://localhost:8085',
|
|
authToken: '', // Keycloak bearer; empty = no auth yet
|
|
pollIntervalSec: 15,
|
|
};
|
|
|
|
const state = {
|
|
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 },
|
|
timelineFilter: new Set(), // empty = all events; else any-of workloadIds (multi-select pills)
|
|
};
|
|
|
|
// ── Central API surface ─────────────────────────────────────────
|
|
//
|
|
// Every backend call routes through `api()`. Requests go against the
|
|
// hardwired baseUrl; the active namespace (if any) is attached as a
|
|
// `namespace` query parameter, and the bearer token (if any) is sent as
|
|
// Authorization. Response shapes are defined by the Go backend in
|
|
// `internal/httpapi/contracts.go`.
|
|
|
|
async function api(path, opts = {}) {
|
|
const url = new URL(path, CONFIG.baseUrl);
|
|
if (!url.searchParams.has('namespace') && state.activeNamespace) {
|
|
url.searchParams.set('namespace', state.activeNamespace.namespace);
|
|
}
|
|
const headers = CONFIG.authToken
|
|
? { Authorization: 'Bearer ' + CONFIG.authToken }
|
|
: {};
|
|
const res = await fetch(url, { ...opts, headers });
|
|
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
|
return res.json();
|
|
}
|
|
|
|
// ── 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));
|
|
});
|
|
}
|
|
|
|
// ── Timeline helpers ──────────────────────────────────────────
|
|
|
|
const TL_CLUSTER_WINDOW_MS = 60_000; // events within 60s collapse into one entry
|
|
const TL_SEV_RANK = { error: 5, warning: 4, recovered: 3, image: 2, info: 1 };
|
|
const TL_MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
|
|
|
|
/** Normalise an event's severity. Two post-hoc classifications matter:
|
|
* "-> healthy" transitions are recoveries, and ImageRolled reasons are
|
|
* real image pushes (vs. plain revision bumps which are just info). */
|
|
function eventSev(e) {
|
|
const isRecovery = typeof e.message === 'string' && e.message.endsWith('-> healthy');
|
|
const isImageRoll = e.reason === 'ImageRolled';
|
|
if (isRecovery) return 'recovered';
|
|
if (isImageRoll) return 'image';
|
|
return e.severity || 'info';
|
|
}
|
|
|
|
/** Workload short-name for the filter pill (strip namespace prefix). */
|
|
function workloadShort(id) {
|
|
if (!id) return '?';
|
|
const parts = id.split('/');
|
|
return parts[parts.length - 1] || id;
|
|
}
|
|
|
|
/** Greedy clustering: walk events (assumed newest-first) and start a new
|
|
* cluster whenever the gap exceeds the tolerance window. */
|
|
function clusterEvents(events) {
|
|
const clusters = [];
|
|
let current = null;
|
|
for (const e of events) {
|
|
const ts = Date.parse(e.timestamp);
|
|
if (!current || isNaN(ts) || Math.abs(current.anchorTs - ts) > TL_CLUSTER_WINDOW_MS) {
|
|
current = { anchorTs: ts, ts: e.timestamp, events: [e] };
|
|
clusters.push(current);
|
|
} else {
|
|
current.events.push(e);
|
|
}
|
|
}
|
|
return clusters;
|
|
}
|
|
|
|
function dominantSev(events) {
|
|
let best = null;
|
|
for (const e of events) {
|
|
const sev = eventSev(e);
|
|
if (!best || TL_SEV_RANK[sev] > TL_SEV_RANK[best]) best = sev;
|
|
}
|
|
return best || 'info';
|
|
}
|
|
|
|
function sameDay(a, b) {
|
|
const da = new Date(a), db = new Date(b);
|
|
return da.getFullYear() === db.getFullYear()
|
|
&& da.getMonth() === db.getMonth()
|
|
&& da.getDate() === db.getDate();
|
|
}
|
|
|
|
function formatTimeShort(iso) {
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return iso;
|
|
const now = new Date();
|
|
if (Math.abs(now - d) < 60_000) return 'just now';
|
|
const hh = String(d.getHours()).padStart(2, '0');
|
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
return `${hh}:${mm}`;
|
|
}
|
|
|
|
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 {
|
|
const n = state.timelineFilter.size;
|
|
scopeEl.textContent = n === 0 ? 'all events' : `filtered · ${n}`;
|
|
backEl.classList.add('hidden');
|
|
}
|
|
const filter = state.timelineFilter;
|
|
const events = state.data.timeline.filter(e => {
|
|
if (scope) return e.workloadId === scope;
|
|
if (filter.size === 0) return true;
|
|
return filter.has(e.workloadId);
|
|
});
|
|
renderTimelineFilters();
|
|
|
|
const body = $('oumta-timeline-body');
|
|
if (!events.length) {
|
|
body.innerHTML = '<div class="oumta-empty">No events.</div>';
|
|
return;
|
|
}
|
|
const clusters = clusterEvents(events);
|
|
|
|
let lastDate = null;
|
|
body.innerHTML = clusters.map(cluster => {
|
|
const sev = dominantSev(cluster.events);
|
|
const showDate = !lastDate || !sameDay(lastDate, cluster.ts);
|
|
lastDate = cluster.ts;
|
|
const d = new Date(cluster.ts);
|
|
const badge = showDate
|
|
? `<div class="oumta-tl-date" title="${escapeAttr(d.toDateString())}">
|
|
<span class="day">${d.getDate()}</span>
|
|
<span class="mon">${TL_MONTHS[d.getMonth()]}</span>
|
|
</div>`
|
|
: '';
|
|
const left = `
|
|
${badge}
|
|
<div class="oumta-tl-row">
|
|
<span class="oumta-tl-time">${escapeHtml(formatTimeShort(cluster.ts))}</span>
|
|
<span class="oumta-tl-dot" data-sev="${sev}"></span>
|
|
</div>`;
|
|
const right = `
|
|
<div class="oumta-tl-right">
|
|
${cluster.events.map(e => {
|
|
const esev = eventSev(e);
|
|
const wid = e.workloadId || '';
|
|
return `
|
|
<div class="oumta-tl-event">
|
|
<div class="oumta-tl-event-head">
|
|
<span class="oumta-tl-severity" data-sev="${esev}">${esev}</span>
|
|
<span class="oumta-tl-workload" data-wlid="${escapeAttr(wid)}" title="filter by this workload">${escapeHtml(workloadShort(wid))}</span>
|
|
<span class="oumta-tl-reason">${escapeHtml(e.reason || '')}</span>
|
|
</div>
|
|
<div class="oumta-tl-message">${escapeHtml(e.message || '')}</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>`;
|
|
return `<div class="oumta-timeline-entry">${left}${right}</div>`;
|
|
}).join('');
|
|
|
|
// Tapping a workload name inside an event toggles it in the filter set.
|
|
body.querySelectorAll('.oumta-tl-workload').forEach(el => {
|
|
el.addEventListener('click', () => toggleTimelineFilter(el.dataset.wlid));
|
|
});
|
|
}
|
|
|
|
/** Toggle a workload in the multi-select filter set. Passing an empty
|
|
* value clears the set (the "all" pill). */
|
|
function toggleTimelineFilter(workloadId) {
|
|
const set = state.timelineFilter;
|
|
if (!workloadId) { set.clear(); }
|
|
else if (set.has(workloadId)) { set.delete(workloadId); }
|
|
else { set.add(workloadId); }
|
|
renderTimeline();
|
|
}
|
|
|
|
/** Render the workload quick-pick bar. "all" + distinct workloads seen in
|
|
* the current timeline, capped at N inline pills — overflow collapses
|
|
* into a dropdown. Deduplicated by workloadId, ranked by most recent
|
|
* event so the pills you care about stay visible. */
|
|
function renderTimelineFilters() {
|
|
const host = $('oumta-timeline-filters');
|
|
if (!host) return;
|
|
// In workload-detail scope (opened via a card click) the filter bar is
|
|
// redundant — we're already showing one workload's events.
|
|
if (state.data.scopeWorkload) { host.innerHTML = ''; return; }
|
|
|
|
const seen = new Map(); // workloadId -> earliest-seen index (= recency)
|
|
state.data.timeline.forEach((e, i) => {
|
|
if (e.workloadId && !seen.has(e.workloadId)) seen.set(e.workloadId, i);
|
|
});
|
|
const workloads = [...seen.keys()];
|
|
if (!workloads.length) { host.innerHTML = ''; return; }
|
|
|
|
const INLINE_LIMIT = 6;
|
|
const inline = workloads.slice(0, INLINE_LIMIT);
|
|
const overflow = workloads.slice(INLINE_LIMIT);
|
|
const active = state.timelineFilter; // Set<workloadId>
|
|
|
|
const pill = (id, label, isActive) =>
|
|
`<button class="oumta-tl-pill${isActive ? ' active' : ''}" data-wlid="${escapeAttr(id)}">${escapeHtml(label)}</button>`;
|
|
|
|
// "all" is active whenever the filter set is empty; picking it clears
|
|
// the set. The individual pills toggle their workload in the set so
|
|
// multiple filters can stack (any-of match).
|
|
const overflowActiveCount = overflow.reduce((n, id) => n + (active.has(id) ? 1 : 0), 0);
|
|
host.innerHTML = [
|
|
pill('', 'all', active.size === 0),
|
|
...inline.map(id => pill(id, workloadShort(id), active.has(id))),
|
|
overflow.length ? `
|
|
<div class="oumta-tl-more">
|
|
<button class="oumta-tl-pill${overflowActiveCount ? ' active' : ''}" data-tl-more="1">+${overflow.length} more${overflowActiveCount ? ` (${overflowActiveCount})` : ''}<i class="ph ph-caret-down" style="font-size:10px;margin-left:4px;"></i></button>
|
|
<div class="oumta-tl-more-menu hidden">
|
|
${overflow.map(id => `<button class="${active.has(id) ? 'active' : ''}" data-wlid="${escapeAttr(id)}">${escapeHtml(workloadShort(id))}</button>`).join('')}
|
|
</div>
|
|
</div>` : '',
|
|
].filter(Boolean).join('');
|
|
|
|
host.querySelectorAll('.oumta-tl-pill[data-wlid]').forEach(btn => {
|
|
btn.addEventListener('click', () => toggleTimelineFilter(btn.dataset.wlid || ''));
|
|
});
|
|
const moreBtn = host.querySelector('[data-tl-more]');
|
|
const moreMenu = host.querySelector('.oumta-tl-more-menu');
|
|
if (moreBtn && moreMenu) {
|
|
moreBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
moreMenu.classList.toggle('hidden');
|
|
});
|
|
// Overflow picks don't close the menu — multi-select UX relies on
|
|
// being able to tick several without the menu collapsing every time.
|
|
moreMenu.querySelectorAll('button[data-wlid]').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
toggleTimelineFilter(btn.dataset.wlid || '');
|
|
});
|
|
});
|
|
document.addEventListener('click', (e) => {
|
|
if (!moreMenu.contains(e.target) && e.target !== moreBtn) moreMenu.classList.add('hidden');
|
|
}, { once: true });
|
|
}
|
|
}
|
|
|
|
// ── 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 = (CONFIG.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();
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────
|
|
|
|
function escapeHtml(s) {
|
|
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
}
|
|
function escapeAttr(s) { return escapeHtml(s); }
|
|
function formatTime(iso) {
|
|
try {
|
|
const d = new Date(iso);
|
|
return d.toLocaleString();
|
|
} catch { return iso; }
|
|
}
|
|
|
|
// ── Boot ────────────────────────────────────────────────────
|
|
|
|
h?.registerPlugin?.({ name: 'oumta-dashboard', version: '0.1.0' });
|