tweak status change representation

This commit is contained in:
tu committed 2026-04-17 21:08:38 +02:00
1 parent 29e818ac64
commit 89d84d3538
2 files changed
+43 -123

No files matched your search

@@ -216,9 +216,11 @@
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-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-severity[data-sev="recovered"] { color: #4ade80; }
.oumta-timeline-severity[data-sev="image"] { color: #60a5fa; }
.oumta-timeline-message { font-size: 12px; color: var(--t-bright); }
.oumta-timeline-meta { font-size: 10px; color: var(--t-dim); margin-top: 2px; }
+38 -120
View File
@@ -1,9 +1,8 @@
// ── 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.
// All reads go through `api()` below; configure the backend URL in the
// hyprcrd settings overlay (oumta tab) or accept the localhost default.
const h = window.hyprcrd;
const $ = id => document.getElementById(id);
@@ -16,7 +15,7 @@ const $ = id => document.getElementById(id);
const LS_KEY = 'oumta-dashboard-settings';
const state = {
settings: {
baseUrl: '', // e.g. https://dashboard.oumta.internal
baseUrl: 'http://localhost:8080', // e.g. https://dashboard.oumta.internal; default fits local dev
authToken: '', // Keycloak bearer; empty = no auth yet
pollIntervalSec: 15,
},
@@ -41,116 +40,23 @@ function saveSettings() {
// ── 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(); }
// Every backend call routes through `api()`. Requests go against
// `state.settings.baseUrl`; the active namespace (if any) is attached
// as a `namespace` query parameter, and the configured auth token is
// sent as a bearer header. Response shapes are defined by the Go
// backend in `internal/httpapi/contracts.go`.
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)
const url = new URL(path, state.settings.baseUrl);
if (!url.searchParams.has('namespace') && state.activeNamespace) {
url.searchParams.set('namespace', state.activeNamespace.namespace);
}
if (path === '/api/timeline') return { items: mockTimeline(ns) }; // TODO(api): GET /api/timeline?namespace=
throw new Error('unknown api path: ' + path);
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();
}
// ── Fullscreen overlay show/hide ─────────────────────────────────
@@ -364,16 +270,28 @@ function renderTimeline() {
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>
body.innerHTML = events.map(e => {
// Recovery = letzter State-Uebergang endet in "-> healthy".
// Image-Rollout = vom Backend markiert via reason "ImageRolled".
// Beide kriegen eigene Badges, damit man Erholungen und neue
// Images von generischen Info-Events (nur-Revision-Rollouts)
// trennen kann.
const isRecovery = typeof e.message === 'string' && e.message.endsWith('-> healthy');
const isImageRoll = e.reason === 'ImageRolled';
let sev = e.severity;
if (isRecovery) sev = 'recovered';
else if (isImageRoll) sev = 'image';
return `
<div class="oumta-timeline-entry">
<div class="oumta-timeline-time">${formatTime(e.timestamp)}</div>
<div class="oumta-timeline-severity" data-sev="${sev}">${sev}</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>
</div>
`).join('');
`;
}).join('');
}
// ── Workload → detail-as-timeline navigation ──────────────────