Skip to content

Commit 073eab8

Browse files
committed
docs: document the dashboard API, compression and the UI layout
- docs/API.md: /api/dashboard, the recentRuns field, the 403 on updating a config-owned job - docs/packages/web.md: what the rate limiter counts and what it exempts, and that compression negotiates zstd or gzip - docs/CONFIGURATION.md: OFELIA_UI_DEV_DIR - web/AGENTS.md: the UI is templates plus assets now, the polling and rate-limit budget, the CSP notes for external app.js - AGENTS.md: go:embed walks a matched directory recursively, so ui/* covers ui/templates/ — the previous note claimed the opposite - CHANGELOG: the Unreleased entries for all of the above, with the breaking API and interface changes called out under Changed Signed-off-by: Kamil Pešek <pesek.kamil@seznam.cz>
1 parent bedc545 commit 073eab8

6 files changed

Lines changed: 285 additions & 50 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ This file explains repo‑wide conventions and where to find scoped rules.
2323
- Requires a running Docker daemon (start it with `sudo systemctl start docker` on systemd hosts, or `sudo service docker start` otherwise)
2424
- Run the daemon with the bundled demo config: `go run . daemon --config example/ofelia.ini` — serves the web UI on `web-address` (default `:8081`, which binds all interfaces). The example config does not override it, so reach the UI at `http://127.0.0.1:8081/`; pass `--web-address 127.0.0.1:8081` to force loopback-only binding
2525
- `example/ofelia.ini` ships working demo jobs (`run-date` runs `date` in alpine every 30s; `local-echo` runs on the host every 45s); the swarm and compose examples are commented out as they need extra infrastructure
26-
- Web UI assets live in `static/ui/` and are embedded via `//go:embed ui/*` in `static/static.go`; new files added directly under `static/ui/` are picked up automatically (no registration needed). The `ui/*` pattern is not recursive, so a new nested subdirectory needs its own embed pattern (e.g. `ui/sub/*`)
26+
- Web UI assets live in `static/ui/` (`styles.css`, `app.js`, `templates/`) and are embedded via `//go:embed ui/*` in `static/static.go`; new files added under `static/ui/` are picked up automatically (no registration needed). When a pattern matches a directory, `go:embed` walks it recursively, so `ui/*` also covers `ui/templates/` — files whose name starts with `.` or `_` are the exception and stay out
2727
- After touching embedded assets, run `go build ./...` to confirm the embed still resolves
2828
- Web package tests: `go test ./web/... -v -count=1` (`-count=1` bypasses the test cache)
2929

CHANGELOG.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,146 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **Origin badges and honest delete buttons.** Config-owned jobs (INI or
13+
Docker labels) show an `ini`/`label` badge explaining they are deleted
14+
at their source, and the UI no longer offers them a delete button that
15+
could only end in a 403.
16+
- **Running indicator** — a pulsing teal dot marks a job mid-run
17+
(respects `prefers-reduced-motion`).
18+
- **The Failing stat card toggles a failed-only table filter**
19+
(keyboard-accessible, `aria-pressed`).
20+
- **Result sparkline per job** — a "Last runs" column with one square per
21+
recent execution (green/yellow/red, tooltip with time and outcome),
22+
fed by a new additive `recentRuns` field on job payloads.
23+
- **The Duration cell shows last / avg / max** over the job's recent
24+
completed runs as three labeled lines — a slowing job shows up at a
25+
glance.
26+
- **Stat cards above the jobs table**: active jobs (with paused count),
27+
jobs whose last run failed (red accent + ⚠ only when non-zero), and the
28+
nearest upcoming run with a countdown. Computed from the same dashboard
29+
poll — no extra requests.
30+
- **Job table search and sorting; sortable history.** A search input above
31+
the jobs table matches name, command, schedule, and the displayed Last
32+
Run/Duration formats; header clicks sort jobs (name, schedule, command,
33+
last run, duration — raw values, so durations sort numerically and ISO
34+
timestamps chronologically) and history (date, duration, error), with
35+
SVG chevron indicators. Both are reusable opt-in helpers
36+
(`createTableSearch`, `createTableSort` + `data-sort` header flags) and
37+
re-render purely from cached data — zero API calls per keystroke or
38+
sort click.
39+
- **Response compression, zstd or gzip.** Clients advertising a supported
40+
codec get compressed pages, assets, and API responses (first page load
41+
~140 kB → ~28 kB; each dashboard poll 5.8 kB → 1.6 kB). The wrapper
42+
enables zstd next to gzip and prefers it at equal q-values, so Chrome,
43+
Edge and Firefox — which send `Accept-Encoding: gzip, deflate, br,
44+
zstd` — receive `Content-Encoding: zstd`, and clients without zstd
45+
(Safari below 26, curl defaults, monitoring scripts) receive gzip.
46+
Identity responses for everyone else. Delegated to
47+
`klauspost/compress/gzhttp`, which handles Accept-Encoding qvalues,
48+
content sniffing, bodiless statuses, and ranged requests.
49+
- **`GET /api/dashboard` — aggregate snapshot endpoint.** Returns jobs,
50+
disabled, removed, and config in one response (optionally a job's history
51+
via `?history=<name>`). Additive: the per-resource endpoints are
52+
unchanged. The web UI now polls this single endpoint per 5s tick instead
53+
of 4–5 separate requests, which used to exhaust the 100-requests-per-
54+
minute rate limit with two dashboard tabs open.
55+
56+
- **UI development mode.** When `OFELIA_UI_DEV_DIR` names a directory, the web
57+
server serves UI assets from it on every request and re-parses the page
58+
templates per request, so an edit is visible on the next browser reload
59+
without rebuilding the binary. Unset in production; the embedded assets
60+
remain the default.
61+
- **Build version in the footer.** Fetched once from the auth-exempt `/health`
62+
endpoint; release builds show the goreleaser version, dev builds show `dev`.
63+
64+
### Changed
65+
66+
- **BREAKING (API behavior):** `POST /api/jobs/update` now returns
67+
`403 Forbidden` for jobs that came from INI config or Docker labels,
68+
mirroring the delete gate, where it previously answered `200 OK`.
69+
Scripts that edited config-owned jobs through the API must edit the
70+
source config (or the container labels) instead. Pre-fix an update
71+
silently overrode such a job in memory until the next config sync —
72+
and rewrote the job's origin, after which the delete gate could be
73+
bypassed and a label job deleted. The UI shows edit and delete on
74+
those jobs as disabled buttons with a tooltip naming the source.
75+
- **BREAKING (source-only, pre-1.0):** `core.DockerProvider` gains
76+
`CopyContainerLogs`, needed to demux container output server-side.
77+
Downstream Go code implementing the exported interface fails to
78+
compile until it adds the method; permitted under
79+
[SemVer §4](https://semver.org/#spec-item-4) for the current 0.y.z
80+
line. Users of the provided implementations are unaffected.
81+
- **BREAKING (source-only, pre-1.0):** `Scheduler.UpdateJob` now updates
82+
a disabled job instead of returning `ErrJobNotFound`, so editing a
83+
paused job no longer resumes it. Callers that relied on the error to
84+
detect "not scheduled" must check the disabled state explicitly.
85+
- **Only `/live` and `/ready` bypass the rate limiter.** A probe answered
86+
429 reads as unhealthy and gets the daemon restarted, and both probes
87+
are cheap. `/health` and `/healthz` stay token-free but counted:
88+
`GetHealth` calls `runtime.ReadMemStats` on every request, which stops
89+
the world, and answers with the version and goroutine count — an
90+
exemption there would leave an unauthenticated, unthrottled endpoint
91+
that pauses the GC per call. Every other request is counted too, static
92+
assets included: each asset response is compressed per request, which
93+
is exactly the work an unauthenticated flood would target. The UI stays
94+
inside the budget by polling one aggregate endpoint per tick rather
95+
than by being exempted. `/api/login` keeps its own stricter login
96+
limiter.
97+
- **A hidden browser tab stops polling** (Page Visibility API) and
98+
refreshes immediately when it becomes visible again — n open dashboard
99+
tabs cost one tab's request budget.
100+
- **Short pages pin the footer to the bottom edge** (min-height 100dvh
101+
flex column).
102+
- **The web UI is assembled from templates and separate assets.** The former
103+
single-file `static/ui/index.html` is split into `styles.css`, `app.js`, and
104+
Go `html/template` partials (`templates/layout.html` plus one file per tab),
105+
rendered server-side at `GET /`. No behavior or dependency change; still
106+
vanilla CSS/JS with no build step.
107+
- **Job history opens in a modal dialog** instead of a panel under the jobs
108+
table. Close via the header button, Esc, or a backdrop click. The dialog is
109+
anchored to the top of the viewport so the 5s refresh does not make it jump,
110+
and its padding follows the compact/comfortable density setting.
111+
- **Run output renders in a full-width subrow** of the history table instead
112+
of inside the Output column. Expanding output no longer changes column
113+
widths; long output wraps and scrolls in its own box; the run's row is
114+
highlighted while open; expanded state still survives the 5s refresh, keyed
115+
by execution timestamp and scoped to the shown job.
116+
- **The tab bar moved into the sticky nav**, left-aligned next to the brand;
117+
the footer spans the full page width. Both bars share the same horizontal
118+
padding via the `--layout-pad-x` CSS variable, and repeated separator
119+
borders use `--border-thin`. The nav dropped its `<ul><li>` wrappers —
120+
single-item lists carried no semantics, and Pico's `nav li` padding
121+
ignored the density setting.
122+
- **Brand primary is teal `#2f99a4`** (was Pico blue), set via the
123+
`--pico-primary*` token family for light, dark, and auto theme modes.
124+
- **The dark theme background is neutral graphite** (`#181b1e`, cards
125+
`#22262a`) instead of Pico's blue-tinted default, so the teal primary is
126+
the only cool hue on screen. Form inputs and dropdowns follow the same
127+
graphite family (`--pico-form-element-*` tokens).
128+
- **Job-row action buttons are soft teal chips with SVG icons.** The emoji
129+
glyphs (▶ ✎ ⏸ 🗑) became uniform inline stroke SVGs on a 24px grid,
130+
colored via `currentColor`; delete is red-tinted at rest. Icon colors
131+
come from `--action-fg`/`--action-del-fg` with per-theme shades.
132+
- **Deleting a job asks for confirmation**, and API failures are no longer
133+
silent: a reusable bottom-right toast (`toast.success/error/info`) shows
134+
the server's message — notably the 403 explaining that INI-owned jobs
135+
must be deleted in the config file. Run, pause, resume, and delete show
136+
success toasts.
137+
- **Job rows signal their clickability**: pointer cursor, hover tint, and
138+
the job name is a link-styled button, so keyboard users can Tab to it
139+
and open the history with Enter.
140+
- **Tables are striped.** Pico's `.striped` variant on all four tables,
141+
with the stripe color raised to 6% of the contrast color (Pico's ~4%
142+
alpha was invisible on the graphite dark background). The history table
143+
stripes in pure CSS by run/subrow pairs — the output subrow is always
144+
rendered, shares its parent run's background, aligns with the Date
145+
column, and gets density-scaled padding when open. The status-dot
146+
column has a fixed narrow width (`--dot-col`) so rows stay aligned.
147+
- **The rendered page and stylesheet pass the W3C Nu validator** (checked
148+
locally via the `ghcr.io/validator/validator` Docker image).
149+
10150
## [0.29.1] - 2026-08-12
11151

12152
A security release. Jobs defined through Docker container labels could carry privilege-bearing keys the label-security policy did not cover, letting an untrusted self-labeling container escalate against the host or read another container's secrets — in the default configuration. See [GHSA-h7m7-v83x-vfp3](https://github.com/netresearch/ofelia/security/advisories/GHSA-h7m7-v83x-vfp3).

docs/API.md

Lines changed: 109 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -148,53 +148,67 @@ POST /api/job/{name}/run
148148

149149
### Update Job Configuration
150150
```http
151-
PUT /api/job/{name}
151+
POST /api/jobs/update
152152
Content-Type: application/json
153153
154154
{
155+
"name": "backup",
156+
"type": "exec",
155157
"schedule": "0 3 * * *",
156158
"command": "backup.sh --verbose",
157-
"environment": ["BACKUP_DIR=/data", "COMPRESSION=gzip"]
159+
"container": "myapp"
158160
}
159161
```
160162

161-
**Response:**
162-
```json
163-
{
164-
"name": "backup",
165-
"updated": true,
166-
"message": "Job configuration updated successfully"
167-
}
168-
```
163+
The job is identified by `name`; there is no rename. Fields follow the same
164+
shape as `POST /api/jobs/create``image` for `run` jobs, `file`/`service`/`exec`
165+
for `compose` jobs.
166+
167+
**Responses:**
168+
169+
| Status | Meaning |
170+
|--------|---------|
171+
| `200 No Content` | Existing job updated in place |
172+
| `201 Created` | No job of that name existed, so one was created |
173+
| `400 Bad Request` | Invalid job name, body, or schedule |
174+
| `403 Forbidden` | Job came from INI or label config — edit it at its source |
175+
| `500 Internal Server Error` | Update failed in the scheduler |
176+
177+
A **paused job stays paused** across an update: the scheduler updates disabled
178+
entries in place, so editing a paused job never resumes it and never files the
179+
old copy under `/api/jobs/removed`. Clients must not follow an update with a
180+
compensating `POST /api/jobs/disable` — that would re-pause a job another client
181+
deliberately resumed in the meantime.
182+
183+
Jobs owned by INI files or container labels are rejected with `403`, mirroring
184+
delete. Use `POST /api/jobs/disable` to suppress such a job instead.
169185

170186
### Delete Job
171187
```http
172-
DELETE /api/job/{name}
173-
```
188+
POST /api/jobs/delete
189+
Content-Type: application/json
174190
175-
**Response:**
176-
```json
177-
{
178-
"name": "backup",
179-
"deleted": true,
180-
"message": "Job deleted successfully"
181-
}
191+
{"name": "backup"}
182192
```
183193

194+
Returns `204 No Content` on success, `403 Forbidden` for config-owned jobs, and
195+
`404 Not Found` when no such job exists.
196+
184197
### Enable/Disable Job
185198
```http
186-
PATCH /api/job/{name}/toggle
187-
```
199+
POST /api/jobs/disable
200+
Content-Type: application/json
188201
189-
**Response:**
190-
```json
191-
{
192-
"name": "backup",
193-
"enabled": false,
194-
"message": "Job disabled"
195-
}
202+
{"name": "backup"}
196203
```
197204

205+
`POST /api/jobs/enable` resumes it again. Both return `204 No Content`, or `404
206+
Not Found` when no such job exists. Both are idempotent.
207+
208+
Unlike update and delete, these accept config-owned jobs: operators can pause an
209+
INI or label job from the UI and the pause survives a restart, even though the
210+
job itself has to be edited at its source.
211+
198212
## Execution Management
199213

200214
### Get Execution Status
@@ -433,26 +447,41 @@ ofelia_job_duration_seconds_bucket{le="0.5"} 500
433447
GET /api/config
434448
```
435449

436-
**Response:**
450+
**Response** (abridged — keys are the Go field names, and durations are
451+
nanoseconds):
437452
```json
438453
{
439-
"global": {
440-
"dockerHost": "unix:///var/run/docker.sock",
441-
"dockerPollInterval": 30,
442-
"dockerEvents": true,
443-
"slackURL": "https://hooks.slack.com/...",
444-
"emailFrom": "ofelia@example.com",
445-
"emailTo": "admin@example.com"
454+
"Global": {
455+
"LogLevel": "info",
456+
"EnableWeb": true,
457+
"WebAddr": ":8081",
458+
"MaxRuntime": 86400000000000,
459+
"DefaultUser": "nobody"
446460
},
447-
"jobs": {
448-
"exec": [...],
449-
"run": [...],
450-
"local": [...],
451-
"service": [...]
452-
}
461+
"ExecJobs": null,
462+
"RunJobs": null,
463+
"ServiceJobs": null,
464+
"LocalJobs": null,
465+
"ComposeJobs": null,
466+
"Docker": { "UseEvents": true, "ConfigPollInterval": 10000000000 },
467+
"WebhookConfigs": { "Webhooks": {}, "Manager": null }
453468
}
454469
```
455470

471+
`WebPasswordHash` and `WebSecretKey` never appear — they are excluded from JSON
472+
at the struct level.
473+
474+
**Job definitions are stripped from this payload**, which is why every job
475+
collection reads `null` above rather than carrying its jobs. Job definitions
476+
carry commands and credential-bearing fields that have no business riding along
477+
in a configuration dump. Stripping is by shape — any map of names to job-config
478+
structs — so a job collection added in a later release is stripped without
479+
touching this code path.
480+
481+
Use `GET /api/jobs` for job definitions; it returns the redacted view intended
482+
for clients. The same stripped config is embedded in `GET /api/dashboard` under
483+
its `config` key.
484+
456485
### Update Global Configuration
457486
```http
458487
PATCH /api/config/global
@@ -464,6 +493,44 @@ Content-Type: application/json
464493
}
465494
```
466495

496+
### Recent run summary on job payloads
497+
498+
Job objects returned by `/api/jobs`, `/api/jobs/disabled`,
499+
`/api/jobs/removed`, and `/api/dashboard` carry an additive `recentRuns`
500+
field: the outcomes of the newest executions (oldest first, at most 10),
501+
each as `{date, duration, failed, skipped}` (duration in nanoseconds).
502+
Omitted when the job has no history.
503+
504+
### Get Dashboard Snapshot (aggregate)
505+
```http
506+
GET /api/dashboard
507+
GET /api/dashboard?history=<job-name>
508+
```
509+
510+
One request returning what `/api/jobs`, `/api/jobs/disabled`,
511+
`/api/jobs/removed`, and `/api/config` return individually, from a single
512+
moment in time. With the optional `history` parameter, the named job's
513+
execution history is included as an array (possibly empty). The `history`
514+
key is always present: it is `null` when the parameter was not given or
515+
the job does not exist (the poll never fails because of a vanished job),
516+
so consumers must check for an array, not for key presence.
517+
518+
Intended for polling consumers — the bundled web UI fetches this once per
519+
refresh tick instead of issuing four or five separate requests, which
520+
matters because the rate limiter counts every request. Scripts that need a
521+
single resource should keep using the per-resource endpoints.
522+
523+
**Response:**
524+
```json
525+
{
526+
"jobs": [...],
527+
"disabled": [...],
528+
"removed": [...],
529+
"config": {...},
530+
"history": [...]
531+
}
532+
```
533+
467534
## WebSocket Events
468535

469536
### Subscribe to Real-time Events

docs/CONFIGURATION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ services:
235235
| `OFELIA_ENABLE_PPROF` | Enable pprof profiling | false |
236236
| `OFELIA_PPROF_ADDRESS` | pprof bind address | 127.0.0.1:8080 |
237237
| `OFELIA_STATE_FILE` | JSON file persisting API-mutated jobs and disable flags across restarts (#593) | (none, disabled) |
238+
| `OFELIA_UI_DEV_DIR` | Serve the web UI from this directory instead of the embedded assets, re-parsing templates on every request. Development only; leave unset in production | (none, embedded assets) |
238239

239240
**Limitations of Labels-Only Configuration**:
240241
- No environment variable substitution in label values (`${VAR}` won't expand)

0 commit comments

Comments
 (0)