diff --git a/.gitignore b/.gitignore index d530d09..009ab59 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ electron/node_modules data/hyprcrd.db-shm data/hyprcrd.db-wal + +/oumta-dashboard/ +/oumta-dashboard-ui/ + diff --git a/config.php b/config.php index abe09b8..1c7befb 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ ` if a token is configured; the open question is **how the token arrives**. +- Options: + - **A.** User pastes a token into the settings tab (good for POC, not for prod). + - **B.** hyprcrd itself becomes a Keycloak client, acquires a token, exposes it via `window.hyprcrd.authToken` — plugin reads that. Requires a hyprcrd-core change. + - **C.** PHP proxy (see below) attaches the token server-side; the browser never sees it. +- **Recommendation**: go with **C** once we decide on the proxy. Until then **A** is fine for dev. + +### PHP proxy in hyprcrd + +- Today the plugin calls the Go backend **directly from the browser**. That means: + - The Go backend must return permissive CORS (it does: `Access-Control-Allow-Origin` is wide-open in dev). + - The user's browser must reach the backend (issue if the backend lives inside a private network). +- If we add a PHP proxy (file: `public/plugins/oumta-dashboard/backend.php`), we'd: + - Route `/api/oumta/*` → forward to `baseUrl + *`. + - Store `baseUrl` + `authToken` in hyprcrd's `settings` table instead of `localStorage`. + - Get CORS for free because the request originates from hyprcrd's own origin. +- **Trigger for adding the proxy**: when we get the Keycloak spec, or when users report "dashboard only works when I'm VPN'd". + +### Settings persistence + +- Currently `localStorage['oumta-dashboard-settings']` — fine for a single user in one browser. +- If we want per-tenant server-side settings, add a hyprcrd `settings` key like `oumta.base_url` and expose via a small PHP endpoint. + +--- + +## Phase 3 — realtime + +- `startLive()` currently just starts polling (every `settings.pollIntervalSec`). +- SSE plan (see commented-out block in `startLive()`): + - Backend exposes `GET /api/events?namespace=…` as an `EventSource`-compatible stream. + - On `onopen` → `setLive('live')`, apply incoming events live (invalidate `state.data.overview` etc. or surgically patch). + - On `onerror` → fall back to polling, `setLive('polling')` with a hint. +- SSE event shape proposal (to negotiate with backend): `{ type: 'workload.state', workloadId, state, reason, timestamp }`, etc. Parking this until the backend is ready to talk. + +--- + +## Phase 3+ — nice to haves (not in scope yet) + +From the original questions catalog we parked: +- Timezone handling: UTC vs. local browser time (probably just use local + tooltip with UTC). +- Plugin settings in hyprcrd's settings-tab vs. per-user vs. shared across tenants. +- Auto-create a hyprcrd card when an incident is opened (tie the two systems together). +- Filter persistence in URL hash so links are shareable. +- Light/dark accent variants — currently inherits theme variables, should just work. + +--- + +## Open backend questions (to sync with the Go team) + +1. **Namespace list endpoint** — UI needs an endpoint to populate the namespace tabs. The doc says "Namespace-Auswahl per Menü" and mentions env-var config on the backend. What's the endpoint? `GET /api/namespaces`? If not, how does the UI discover them? +2. **Response envelope** — the existing `overview` endpoint returns a flat object. Lists should probably be `{ items: [...] }` (consistent with our renderers) or `[]` (then we'll adapt). Pick one and stick with it across all list endpoints. +3. **Cluster state values** — our Overview card colour-codes on `healthy | degraded | down | unknown`. Backend currently returns `"unknown"` as a literal. Confirm the vocabulary. +4. **Workload state values** — we colour-code on `healthy | degraded | unavailable`. The doc uses these but the old .NET API also had `withRestarts` / `withWarnings` as summary counters. Only the three we use? Or more? +5. **Workload ID shape** — we use `deployment/{namespace}/{name}` as a synthetic id (so workloads can be linked from the timeline). Does the backend emit that exact form, or just `{namespace}/{name}`? We can adapt either way. +6. **Timeline pagination** — limit? cursor? since? The .NET version had all three; the Go doc mentions only filters. Enough for MVP? +7. **Realtime channel** — path + event format. We've got a plan above; needs the backend team's blessing. + +--- + +## Internal follow-ups + +- `renderWorkloads` is called on every keystroke in the filter — fine up to ~1k rows, might need debouncing above that. +- `refresh()` tears down the scoped-workload timeline whenever it fires. For Phase 1 that's acceptable; once real data lands, consider NOT overwriting `state.data.timeline` when `state.data.scopeWorkload` is active. +- `bootstrap()` only runs on first show. If the user changes the API base URL in settings while the overlay is open, we re-start live mode but don't re-fetch namespaces. Add a "reset & reload" action if that matters. +- Namespace tab bar shows `namespace.label`; we currently fake it. Once real, consider showing the full k8s namespace if `label` and `namespace` differ. diff --git a/public/plugins/oumta-dashboard/oumta-dashboard.css b/public/plugins/oumta-dashboard/oumta-dashboard.css new file mode 100644 index 0000000..54ecbcc --- /dev/null +++ b/public/plugins/oumta-dashboard/oumta-dashboard.css @@ -0,0 +1,234 @@ +/* ── Namespace tabs ── */ +.oumta-ns-tab { + font-family: var(--font-pixel, 'Silkscreen', cursive); + font-size: 10px; + padding: 8px 14px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--t-muted); + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + margin-right: 2px; +} +.oumta-ns-tab:hover { color: var(--t-bright); } +.oumta-ns-tab.active { color: var(--t-accent); border-bottom-color: var(--t-accent); } + +/* ── View tabs ── */ +.oumta-view-tab { + display: inline-flex; align-items: center; gap: 6px; + font-family: var(--font-pixel, 'Silkscreen', cursive); + font-size: 10px; + padding: 4px 8px; + background: transparent; + border: 1px solid transparent; + color: var(--t-muted); + cursor: pointer; + border-radius: var(--ui-radius-button); + transition: all 0.15s; +} +.oumta-view-tab:hover { color: var(--t-bright); } +.oumta-view-tab.active { color: var(--t-accent); border-color: var(--t-accent); } + +.oumta-view.hidden { display: none; } +.oumta-view { padding: 20px 24px; } + +/* ── Overview cards ── */ +.oumta-overview-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 24px; +} +.oumta-card { + background: var(--t-bg); + border: 1px solid var(--t-border); + border-radius: var(--ui-radius-card); + padding: 14px 16px; + display: flex; flex-direction: column; gap: 4px; +} +.oumta-card-wide { grid-column: span 4; } +.oumta-card-label { + font-family: var(--font-pixel, 'Silkscreen', cursive); + font-size: 9px; + color: var(--t-dim); + text-transform: uppercase; + letter-spacing: 0.5px; +} +.oumta-card-value { + font-family: var(--font-content, monospace); + font-size: 28px; + color: var(--t-bright); + font-weight: bold; +} +.oumta-card-value-sm { font-size: 14px; font-weight: normal; } +.oumta-card-sub { font-size: 10px; color: var(--t-dim); } +.oumta-danger { color: var(--t-danger); } + +.oumta-cluster-status[data-state="healthy"] .oumta-card-value { color: #4ade80; } +.oumta-cluster-status[data-state="degraded"] .oumta-card-value { color: #fb923c; } +.oumta-cluster-status[data-state="down"] .oumta-card-value { color: var(--t-danger); } +.oumta-cluster-status[data-state="unknown"] .oumta-card-value { color: var(--t-dim); } + +/* ── Attention list ── */ +.oumta-section { margin-top: 8px; } +.oumta-section-title { + font-family: var(--font-pixel, 'Silkscreen', cursive); + font-size: 10px; + color: var(--t-accent); + margin-bottom: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.oumta-attention-list { display: flex; flex-direction: column; gap: 6px; } +.oumta-attention-row { + display: flex; align-items: center; gap: 10px; + padding: 10px 14px; + background: var(--t-bg); + border: 1px solid var(--t-border); + border-radius: var(--ui-radius-card); + cursor: pointer; + transition: border-color 0.15s; +} +.oumta-attention-row:hover { border-color: var(--t-accent); } +.oumta-attention-name { flex: 1; color: var(--t-bright); font-size: 13px; } +.oumta-attention-ns { color: var(--t-dim); font-size: 11px; font-family: var(--font-content, monospace); } +.oumta-attention-reason { color: var(--t-muted); font-size: 11px; font-style: italic; } +.oumta-attention-state { + font-family: var(--font-pixel, 'Silkscreen', cursive); + font-size: 9px; + padding: 2px 6px; + border: 1px solid var(--t-border); + border-radius: 2px; +} +.oumta-attention-state[data-state="degraded"] { color: #fb923c; border-color: #fb923c; } +.oumta-attention-state[data-state="unavailable"] { color: var(--t-danger); border-color: var(--t-danger); } + +.oumta-empty { + padding: 32px; + text-align: center; + color: var(--t-dim); + font-size: 12px; + font-style: italic; +} + +/* ── Workload toolbar + table ── */ +.oumta-toolbar { + display: flex; gap: 10px; margin-bottom: 14px; + align-items: center; +} +.oumta-input { + background: var(--t-bg); + border: 1px solid var(--t-border); + color: var(--t-bright); + padding: 6px 10px; + border-radius: var(--ui-radius-input); + font-size: 12px; + font-family: var(--font-content, monospace); + outline: none; +} +.oumta-input:focus { border-color: var(--t-accent); } +#oumta-wl-search { flex: 1; min-width: 200px; } + +.oumta-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.oumta-table th { + text-align: left; + padding: 8px 10px; + font-family: var(--font-pixel, 'Silkscreen', cursive); + font-size: 9px; + color: var(--t-dim); + text-transform: uppercase; + border-bottom: 1px solid var(--t-border); + letter-spacing: 0.5px; +} +.oumta-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--t-border); + color: var(--t-text); + font-family: var(--font-content, monospace); + vertical-align: middle; +} +.oumta-table tbody tr { cursor: pointer; transition: background 0.15s; } +.oumta-table tbody tr:hover { background: var(--t-bg); } +.oumta-table .wl-state-healthy { color: #4ade80; } +.oumta-table .wl-state-degraded { color: #fb923c; } +.oumta-table .wl-state-unavailable{ color: var(--t-danger); } + +/* ── Timeline ── */ +.oumta-timeline-header { + display: flex; align-items: center; gap: 10px; margin-bottom: 14px; +} +.oumta-btn-ghost { + display: inline-flex; align-items: center; gap: 4px; + font-family: var(--font-pixel, 'Silkscreen', cursive); + font-size: 10px; + padding: 4px 8px; + background: transparent; + border: 1px solid var(--t-border); + color: var(--t-muted); + cursor: pointer; + border-radius: var(--ui-radius-button); + transition: all 0.15s; +} +.oumta-btn-ghost:hover { border-color: var(--t-accent); color: var(--t-accent); } + +.oumta-timeline-body { + display: flex; flex-direction: column; gap: 2px; + border-left: 2px solid var(--t-border); + padding-left: 16px; +} +.oumta-timeline-entry { + display: grid; + grid-template-columns: 140px 80px 1fr; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--t-border); + align-items: baseline; +} +.oumta-timeline-time { font-family: var(--font-content, monospace); font-size: 11px; color: var(--t-dim); } +.oumta-timeline-severity { + font-family: var(--font-pixel, 'Silkscreen', cursive); + font-size: 9px; + text-transform: uppercase; +} +.oumta-timeline-severity[data-sev="info"] { color: var(--t-accent); } +.oumta-timeline-severity[data-sev="warning"] { color: #fb923c; } +.oumta-timeline-severity[data-sev="error"] { color: var(--t-danger); } +.oumta-timeline-message { font-size: 12px; color: var(--t-bright); } +.oumta-timeline-meta { font-size: 10px; color: var(--t-dim); margin-top: 2px; } + +/* ── Live indicator ── */ +.oumta-dot { + width: 6px; height: 6px; border-radius: 50%; + background: var(--t-dim); + display: inline-block; + transition: background 0.2s; +} +#oumta-live-indicator[data-state="live"] .oumta-dot { background: #4ade80; box-shadow: 0 0 6px #4ade80; } +#oumta-live-indicator[data-state="polling"] .oumta-dot { background: #fb923c; } +#oumta-live-indicator[data-state="error"] .oumta-dot { background: var(--t-danger); } + +/* ── Settings tab ── */ +.oumta-settings-field { + display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; +} +.oumta-settings-field label { + font-family: var(--font-pixel, 'Silkscreen', cursive); + font-size: 9px; color: var(--t-dim); text-transform: uppercase; +} +.oumta-settings-field input { + background: var(--t-bg); + border: 1px solid var(--t-border); + color: var(--t-bright); + padding: 8px 10px; + border-radius: var(--ui-radius-input); + font-family: var(--font-content, monospace); + font-size: 12px; + outline: none; +} +.oumta-settings-field input:focus { border-color: var(--t-accent); } +.oumta-settings-help { font-size: 10px; color: var(--t-dim); line-height: 1.5; } diff --git a/public/plugins/oumta-dashboard/oumta-dashboard.html b/public/plugins/oumta-dashboard/oumta-dashboard.html new file mode 100644 index 0000000..2a4a963 --- /dev/null +++ b/public/plugins/oumta-dashboard/oumta-dashboard.html @@ -0,0 +1,117 @@ +
diff --git a/public/plugins/oumta-dashboard/oumta-dashboard.js b/public/plugins/oumta-dashboard/oumta-dashboard.js new file mode 100644 index 0000000..0a6ae8e --- /dev/null +++ b/public/plugins/oumta-dashboard/oumta-dashboard.js @@ -0,0 +1,532 @@ +// ── 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]. + $('oumta-cluster-card').dataset.state = o.clusterState || 'unknown'; + $('oumta-cluster-state').textContent = o.clusterState || 'unknown'; + $('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) : '—'; + + // 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 list = $('oumta-attention-list'); + if (!attention.length) { + list.innerHTML = 'Authorization: Bearer <token>. Leave empty if the API is unauthenticated.