// ── 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 => ` `).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 = '
Authorization: Bearer <token>. Leave empty if the API is unauthenticated.