oumta dashboard plugin (mockup)
Fullscreen cluster dashboard view consuming the Oumta Go backend. All data currently mocked — see plugin TODO.md for integration plan. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
69ad2c7e71
commit
39e94b8015
9 files changed
+1107
-2
No files matched your search
@@ -11,3 +11,7 @@
|
|||||||
electron/node_modules
|
electron/node_modules
|
||||||
data/hyprcrd.db-shm
|
data/hyprcrd.db-shm
|
||||||
data/hyprcrd.db-wal
|
data/hyprcrd.db-wal
|
||||||
|
|
||||||
|
/oumta-dashboard/
|
||||||
|
/oumta-dashboard-ui/
|
||||||
|
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.5.1');
|
define('APP_VERSION', '1.6.0');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "1.5.1",
|
"version": "1.6.0",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# oumta-dashboard plugin
|
||||||
|
|
||||||
|
Fullscreen view for the Oumta cluster dashboard (Go backend in `oumta-dashboard/`).
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
- **Oumta** button in the hyprcrd top bar (next to *Tasks*)
|
||||||
|
- `window._oumtaToggle()` from the console / other plugins
|
||||||
|
|
||||||
|
Close with **ESC** or the × button.
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
- **Overview** — cluster state, workload counts, attention list
|
||||||
|
- **Workloads** — filterable table (name, state, replicas, cpu/mem, image, reason)
|
||||||
|
- **Timeline** — event stream; clicking a workload filters down to its own events
|
||||||
|
|
||||||
|
## Namespaces
|
||||||
|
|
||||||
|
Tab row at the top. Currently two mocks (`oumta-dev`, `oumta-beta`). Real list comes from the backend once `/api/namespaces` exists.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
Open the hyprcrd settings overlay and choose the **oumta** tab:
|
||||||
|
|
||||||
|
- **API Base URL** — e.g. `http://localhost:8080` for local dev, or the cluster-internal URL
|
||||||
|
- **Auth token** — optional, sent as `Authorization: Bearer …`. Will eventually be filled automatically once Keycloak is wired up.
|
||||||
|
- **Poll interval** — polling fallback cadence when SSE is unavailable. Default 15 s.
|
||||||
|
|
||||||
|
## State today
|
||||||
|
|
||||||
|
Everything is mocked. The live indicator in the top right shows `polling` once the overlay is open, but no real HTTP call fires yet. See [`TODO.md`](./TODO.md) for the integration plan.
|
||||||
|
|
||||||
|
## Disabling
|
||||||
|
|
||||||
|
Add `"oumta-dashboard"` to the `disabled` array in `data/plugins.json`.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# oumta-dashboard — TODO
|
||||||
|
|
||||||
|
Everything in this file is work the plugin still needs to do before it's more than a mockup. Organised by phase.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- **Phase 0 (current)**: UI shell + mock data. Everything renders but no backend call is real.
|
||||||
|
- **Phase 1**: connect to real Go backend (`oumta-dashboard` repo) as endpoints become available.
|
||||||
|
- **Phase 2**: Keycloak bearer auth, PHP proxy decision, persisted settings.
|
||||||
|
- **Phase 3**: nice-to-haves (card auto-creation on incident, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — wire up the real API
|
||||||
|
|
||||||
|
All API calls live in `oumta-dashboard.js` → `api()`. Every case currently returns a `mock…` helper; swap them for `fetch` against the backend. The URL is constructed from `state.settings.baseUrl`.
|
||||||
|
|
||||||
|
### Endpoints to wire (listed in the order the UI consumes them)
|
||||||
|
|
||||||
|
| UI call | Backend endpoint (planned) | Status in backend | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `api('/api/namespaces')` | `GET /api/namespaces` | **not yet implemented** | returns the namespace tabs shown at the top. See "Open backend questions" below. |
|
||||||
|
| `api('/api/cluster/overview')` | `GET /api/cluster/overview?namespace=…` | **exists (stub)** | current stub returns zeros. Also need `namespace` query param. |
|
||||||
|
| `api('/api/workloads')` | `GET /api/workloads?namespace=…&state=…&since=…` | not yet | filters: `state`, `since` (ISO), also maybe `search`. |
|
||||||
|
| `api('/api/workloads/{ns}/{name}/timeline')` | `GET /api/workloads/{ns}/{name}/timeline` | not yet | workload-detail timeline |
|
||||||
|
| `api('/api/timeline')` | `GET /api/timeline?namespace=…` | not yet | global timeline |
|
||||||
|
|
||||||
|
### Real `fetch` implementation sketch
|
||||||
|
|
||||||
|
In `api()` replace the mock dispatch with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const url = new URL(path, state.settings.baseUrl);
|
||||||
|
if (!url.searchParams.has('namespace') && state.activeNamespace)
|
||||||
|
url.searchParams.set('namespace', state.activeNamespace.namespace);
|
||||||
|
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();
|
||||||
|
```
|
||||||
|
|
||||||
|
There's also a commented-out sketch inside `api()` itself — delete the mock branches once real endpoints work.
|
||||||
|
|
||||||
|
### Contract alignment — [REAL] vs [PROPOSED] fields
|
||||||
|
|
||||||
|
Mocks in `oumta-dashboard.js` are labelled inline:
|
||||||
|
|
||||||
|
- **[REAL]** — field matches the Go struct exactly (from `internal/httpapi/router.go`). Must stay byte-for-byte aligned when flipping to real.
|
||||||
|
- **[PROPOSED]** — our guess based on prose in backend `docs/README.md`. Backend author may pick different names; UI has one place per field to update, and there's a commented-out adapter slot in `api()` if several diverge.
|
||||||
|
|
||||||
|
**Overview** (`GET /api/cluster/overview`) — entirely [REAL]:
|
||||||
|
```
|
||||||
|
clusterState (string: healthy | degraded | down | unknown)
|
||||||
|
workloadsUp (int)
|
||||||
|
workloadsDown (int)
|
||||||
|
updatedAtUtc (string, RFC3339)
|
||||||
|
```
|
||||||
|
Extras shown on the Overview page (incidents count, attention list) are **derived client-side** from the workloads response — no contract addition needed from backend.
|
||||||
|
|
||||||
|
**Workloads** (`GET /api/workloads`) — [PROPOSED], derived from `docs/README.md §2`:
|
||||||
|
```
|
||||||
|
items: [{
|
||||||
|
workloadId // synthetic handle, format "deployment/{ns}/{name}"
|
||||||
|
name // ← from doc "Name"
|
||||||
|
namespace // ← "Namespace"
|
||||||
|
state // ← "Zustand" — vocab: healthy | degraded | unavailable
|
||||||
|
replicasReady // ← "Replikas (ready/…)"
|
||||||
|
replicasDesired // ← "Replikas (…/desired)"
|
||||||
|
cpu // ← "CPU-Auslastung"
|
||||||
|
memory // ← "Speicherverbrauch"
|
||||||
|
image // ← "Aktives Docker-Image"
|
||||||
|
lastReason // ← "Letzter bekannter Grund fuer Zustandswechsel"
|
||||||
|
lastTransitionAt // ← "inkl. letzter Statuswechsel-Zeit"
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timeline** (`GET /api/timeline`, `GET /api/workloads/{ns}/{name}/timeline`) — [PROPOSED], derived from `docs/README.md §3`:
|
||||||
|
```
|
||||||
|
items: [{
|
||||||
|
timestamp // RFC3339
|
||||||
|
severity // info | warning | error
|
||||||
|
workloadId // same handle as above
|
||||||
|
message // short human description
|
||||||
|
reason // cause code, e.g. CrashLoopBackOff, ImagePullBackOff
|
||||||
|
kind // deployment | incident | scale — optional UI hint, drop if absent
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Namespaces** (`GET /api/namespaces`) — [PROPOSED], new endpoint:
|
||||||
|
```
|
||||||
|
items: [{ id, label, namespace }]
|
||||||
|
```
|
||||||
|
`id` could equal `namespace` if backend treats them as the same. `label` is optional display text.
|
||||||
|
|
||||||
|
If/when real shapes differ, the fix path in priority order:
|
||||||
|
1. Rename in `mockXxx()` + renderer (one place each).
|
||||||
|
2. If multiple fields diverge on one endpoint, add a mapping step inside `api()` before `return`.
|
||||||
|
3. If the shape differs fundamentally, split the renderer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — auth, persistence, proxy decision
|
||||||
|
|
||||||
|
### Keycloak bearer flow
|
||||||
|
|
||||||
|
- Decision pending (see user note): probably SSO-wrapped. The plugin already sends `Authorization: Bearer <state.settings.authToken>` 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.
|
||||||
@@ -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; }
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<div class="overlay fixed inset-0 z-[75]" style="background:var(--t-overlay)" id="oumta-overlay">
|
||||||
|
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border shrink-0">
|
||||||
|
<i class="ph ph-gauge text-t-accent text-base"></i>
|
||||||
|
<h2 class="font-pixel text-xs text-t-accent">oumta_dashboard</h2>
|
||||||
|
<span id="oumta-generated-at" class="font-pixel text-[9px] text-t-dim"></span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button id="oumta-refresh" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Refresh now">
|
||||||
|
<i class="ph ph-arrows-clockwise text-sm"></i><span class="font-pixel text-[10px]">Refresh</span>
|
||||||
|
</button>
|
||||||
|
<button id="oumta-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
||||||
|
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Namespace tabs -->
|
||||||
|
<div id="oumta-ns-tabs" class="flex items-center gap-0 border-b border-t-border shrink-0 px-5"></div>
|
||||||
|
|
||||||
|
<!-- View tabs (Overview / Workloads / Timeline) -->
|
||||||
|
<div class="flex items-center gap-4 border-b border-t-border shrink-0 px-5 py-2 bg-t-bg">
|
||||||
|
<button class="oumta-view-tab active" data-view="overview"><i class="ph ph-squares-four text-sm"></i> Overview</button>
|
||||||
|
<button class="oumta-view-tab" data-view="workloads"><i class="ph ph-list text-sm"></i> Workloads</button>
|
||||||
|
<button class="oumta-view-tab" data-view="timeline"><i class="ph ph-clock-counter-clockwise text-sm"></i> Timeline</button>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<span id="oumta-live-indicator" class="flex items-center gap-1 font-pixel text-[9px] text-t-dim" data-tip="Realtime status">
|
||||||
|
<span class="oumta-dot"></span><span id="oumta-live-label">connecting…</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="flex-1 overflow-auto" id="oumta-body">
|
||||||
|
|
||||||
|
<!-- Overview -->
|
||||||
|
<section class="oumta-view" data-view="overview">
|
||||||
|
<div class="oumta-overview-grid">
|
||||||
|
<div class="oumta-card oumta-cluster-status" id="oumta-cluster-card">
|
||||||
|
<div class="oumta-card-label">Cluster state</div>
|
||||||
|
<div class="oumta-card-value" id="oumta-cluster-state">—</div>
|
||||||
|
<div class="oumta-card-sub" id="oumta-cluster-updated">no data</div>
|
||||||
|
</div>
|
||||||
|
<div class="oumta-card">
|
||||||
|
<div class="oumta-card-label">Workloads healthy</div>
|
||||||
|
<div class="oumta-card-value" id="oumta-workloads-up">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="oumta-card">
|
||||||
|
<div class="oumta-card-label">Workloads down</div>
|
||||||
|
<div class="oumta-card-value oumta-danger" id="oumta-workloads-down">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="oumta-card">
|
||||||
|
<div class="oumta-card-label">Open incidents</div>
|
||||||
|
<div class="oumta-card-value" id="oumta-incidents">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="oumta-card oumta-card-wide">
|
||||||
|
<div class="oumta-card-label">Last successful import</div>
|
||||||
|
<div class="oumta-card-value oumta-card-value-sm" id="oumta-last-import">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="oumta-section">
|
||||||
|
<div class="oumta-section-title">Workloads needing attention</div>
|
||||||
|
<div id="oumta-attention-list" class="oumta-attention-list">
|
||||||
|
<div class="oumta-empty">Nothing currently flagged.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Workloads -->
|
||||||
|
<section class="oumta-view hidden" data-view="workloads">
|
||||||
|
<div class="oumta-toolbar">
|
||||||
|
<input id="oumta-wl-search" type="text" placeholder="Filter by name…" class="oumta-input">
|
||||||
|
<select id="oumta-wl-status" class="oumta-input">
|
||||||
|
<option value="">All states</option>
|
||||||
|
<option value="healthy">healthy</option>
|
||||||
|
<option value="degraded">degraded</option>
|
||||||
|
<option value="unavailable">unavailable</option>
|
||||||
|
</select>
|
||||||
|
<select id="oumta-wl-timerange" class="oumta-input">
|
||||||
|
<option value="1h">last 1h</option>
|
||||||
|
<option value="24h" selected>last 24h</option>
|
||||||
|
<option value="7d">last 7d</option>
|
||||||
|
<option value="">all</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<table class="oumta-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Namespace</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th>Replicas</th>
|
||||||
|
<th>CPU</th>
|
||||||
|
<th>Memory</th>
|
||||||
|
<th>Image</th>
|
||||||
|
<th>Last reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="oumta-wl-body">
|
||||||
|
<tr><td colspan="8" class="oumta-empty">No workloads.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Timeline (workload-detail opens here as well) -->
|
||||||
|
<section class="oumta-view hidden" data-view="timeline">
|
||||||
|
<div class="oumta-timeline-header">
|
||||||
|
<button id="oumta-timeline-back" class="hidden oumta-btn-ghost"><i class="ph ph-caret-left text-sm"></i> back to all</button>
|
||||||
|
<span id="oumta-timeline-scope" class="font-pixel text-xs text-t-accent">all events</span>
|
||||||
|
</div>
|
||||||
|
<div id="oumta-timeline-body" class="oumta-timeline-body">
|
||||||
|
<div class="oumta-empty">No events.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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 => `
|
||||||
|
<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].
|
||||||
|
$('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 = '<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 <token></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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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' });
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "oumta-dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Oumta cluster dashboard — overview, workloads, timeline.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["oumta-dashboard.js"],
|
||||||
|
"css": ["oumta-dashboard.css"],
|
||||||
|
"html": ["oumta-dashboard.html"]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user