Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ This file explains repo‑wide conventions and where to find scoped rules.
- Requires a running Docker daemon (start it with `sudo systemctl start docker` on systemd hosts, or `sudo service docker start` otherwise)
- 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
- `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
- 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/*`)
- 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
- After touching embedded assets, run `go build ./...` to confirm the embed still resolves
- Web package tests: `go test ./web/... -v -count=1` (`-count=1` bypasses the test cache)

Expand Down
140 changes: 140 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,146 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Origin badges and honest delete buttons.** Config-owned jobs (INI or
Docker labels) show an `ini`/`label` badge explaining they are deleted
at their source, and the UI no longer offers them a delete button that
could only end in a 403.
- **Running indicator** — a pulsing teal dot marks a job mid-run
(respects `prefers-reduced-motion`).
- **The Failing stat card toggles a failed-only table filter**
(keyboard-accessible, `aria-pressed`).
- **Result sparkline per job** — a "Last runs" column with one square per
recent execution (green/yellow/red, tooltip with time and outcome),
fed by a new additive `recentRuns` field on job payloads.
- **The Duration cell shows last / avg / max** over the job's recent
completed runs as three labeled lines — a slowing job shows up at a
glance.
- **Stat cards above the jobs table**: active jobs (with paused count),
jobs whose last run failed (red accent + ⚠ only when non-zero), and the
nearest upcoming run with a countdown. Computed from the same dashboard
poll — no extra requests.
- **Job table search and sorting; sortable history.** A search input above
the jobs table matches name, command, schedule, and the displayed Last
Run/Duration formats; header clicks sort jobs (name, schedule, command,
last run, duration — raw values, so durations sort numerically and ISO
timestamps chronologically) and history (date, duration, error), with
SVG chevron indicators. Both are reusable opt-in helpers
(`createTableSearch`, `createTableSort` + `data-sort` header flags) and
re-render purely from cached data — zero API calls per keystroke or
sort click.
- **Response compression, zstd or gzip.** Clients advertising a supported
codec get compressed pages, assets, and API responses (first page load
~140 kB → ~28 kB; each dashboard poll 5.8 kB → 1.6 kB). The wrapper
enables zstd next to gzip and prefers it at equal q-values, so Chrome,
Edge and Firefox — which send `Accept-Encoding: gzip, deflate, br,
zstd` — receive `Content-Encoding: zstd`, and clients without zstd
(Safari below 26, curl defaults, monitoring scripts) receive gzip.
Identity responses for everyone else. Delegated to
`klauspost/compress/gzhttp`, which handles Accept-Encoding qvalues,
content sniffing, bodiless statuses, and ranged requests.
- **`GET /api/dashboard` — aggregate snapshot endpoint.** Returns jobs,
disabled, removed, and config in one response (optionally a job's history
via `?history=<name>`). Additive: the per-resource endpoints are
unchanged. The web UI now polls this single endpoint per 5s tick instead
of 4–5 separate requests, which used to exhaust the 100-requests-per-
minute rate limit with two dashboard tabs open.

- **UI development mode.** When `OFELIA_UI_DEV_DIR` names a directory, the web
server serves UI assets from it on every request and re-parses the page
templates per request, so an edit is visible on the next browser reload
without rebuilding the binary. Unset in production; the embedded assets
remain the default.
- **Build version in the footer.** Fetched once from the auth-exempt `/health`
endpoint; release builds show the goreleaser version, dev builds show `dev`.

### Changed

- **BREAKING (API behavior):** `POST /api/jobs/update` now returns
`403 Forbidden` for jobs that came from INI config or Docker labels,
mirroring the delete gate, where it previously answered `200 OK`.
Scripts that edited config-owned jobs through the API must edit the
source config (or the container labels) instead. Pre-fix an update
silently overrode such a job in memory until the next config sync —
and rewrote the job's origin, after which the delete gate could be
bypassed and a label job deleted. The UI shows edit and delete on
those jobs as disabled buttons with a tooltip naming the source.
- **BREAKING (source-only, pre-1.0):** `core.DockerProvider` gains
`CopyContainerLogs`, needed to demux container output server-side.
Downstream Go code implementing the exported interface fails to
compile until it adds the method; permitted under
[SemVer §4](https://semver.org/#spec-item-4) for the current 0.y.z
line. Users of the provided implementations are unaffected.
- **BREAKING (source-only, pre-1.0):** `Scheduler.UpdateJob` now updates
a disabled job instead of returning `ErrJobNotFound`, so editing a
paused job no longer resumes it. Callers that relied on the error to
detect "not scheduled" must check the disabled state explicitly.
- **Only `/live` and `/ready` bypass the rate limiter.** A probe answered
429 reads as unhealthy and gets the daemon restarted, and both probes
are cheap. `/health` and `/healthz` stay token-free but counted:
`GetHealth` calls `runtime.ReadMemStats` on every request, which stops
the world, and answers with the version and goroutine count — an
exemption there would leave an unauthenticated, unthrottled endpoint
that pauses the GC per call. Every other request is counted too, static
assets included: each asset response is compressed per request, which
is exactly the work an unauthenticated flood would target. The UI stays
inside the budget by polling one aggregate endpoint per tick rather
than by being exempted. `/api/login` keeps its own stricter login
limiter.
- **A hidden browser tab stops polling** (Page Visibility API) and
refreshes immediately when it becomes visible again — n open dashboard
tabs cost one tab's request budget.
- **Short pages pin the footer to the bottom edge** (min-height 100dvh
flex column).
- **The web UI is assembled from templates and separate assets.** The former
single-file `static/ui/index.html` is split into `styles.css`, `app.js`, and
Go `html/template` partials (`templates/layout.html` plus one file per tab),
rendered server-side at `GET /`. No behavior or dependency change; still
vanilla CSS/JS with no build step.
- **Job history opens in a modal dialog** instead of a panel under the jobs
table. Close via the header button, Esc, or a backdrop click. The dialog is
anchored to the top of the viewport so the 5s refresh does not make it jump,
and its padding follows the compact/comfortable density setting.
- **Run output renders in a full-width subrow** of the history table instead
of inside the Output column. Expanding output no longer changes column
widths; long output wraps and scrolls in its own box; the run's row is
highlighted while open; expanded state still survives the 5s refresh, keyed
by execution timestamp and scoped to the shown job.
- **The tab bar moved into the sticky nav**, left-aligned next to the brand;
the footer spans the full page width. Both bars share the same horizontal
padding via the `--layout-pad-x` CSS variable, and repeated separator
borders use `--border-thin`. The nav dropped its `<ul><li>` wrappers —
single-item lists carried no semantics, and Pico's `nav li` padding
ignored the density setting.
- **Brand primary is teal `#2f99a4`** (was Pico blue), set via the
`--pico-primary*` token family for light, dark, and auto theme modes.
- **The dark theme background is neutral graphite** (`#181b1e`, cards
`#22262a`) instead of Pico's blue-tinted default, so the teal primary is
the only cool hue on screen. Form inputs and dropdowns follow the same
graphite family (`--pico-form-element-*` tokens).
- **Job-row action buttons are soft teal chips with SVG icons.** The emoji
glyphs (▶ ✎ ⏸ 🗑) became uniform inline stroke SVGs on a 24px grid,
colored via `currentColor`; delete is red-tinted at rest. Icon colors
come from `--action-fg`/`--action-del-fg` with per-theme shades.
- **Deleting a job asks for confirmation**, and API failures are no longer
silent: a reusable bottom-right toast (`toast.success/error/info`) shows
the server's message — notably the 403 explaining that INI-owned jobs
must be deleted in the config file. Run, pause, resume, and delete show
success toasts.
- **Job rows signal their clickability**: pointer cursor, hover tint, and
the job name is a link-styled button, so keyboard users can Tab to it
and open the history with Enter.
- **Tables are striped.** Pico's `.striped` variant on all four tables,
with the stripe color raised to 6% of the contrast color (Pico's ~4%
alpha was invisible on the graphite dark background). The history table
stripes in pure CSS by run/subrow pairs — the output subrow is always
rendered, shares its parent run's background, aligns with the Date
column, and gets density-scaled padding when open. The status-dot
column has a fixed narrow width (`--dot-col`) so rows stay aligned.
- **The rendered page and stylesheet pass the W3C Nu validator** (checked
locally via the `ghcr.io/validator/validator` Docker image).

## [0.29.1] - 2026-08-12

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).
Expand Down
4 changes: 4 additions & 0 deletions cli/config_initialize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ func (m *mockDockerProviderForInit) WaitContainer(ctx context.Context, container
return 0, nil
}

func (m *mockDockerProviderForInit) CopyContainerLogs(ctx context.Context, containerID string, stdout, stderr io.Writer, opts core.ContainerLogsOptions) error {
return nil
}

func (m *mockDockerProviderForInit) GetContainerLogs(ctx context.Context, containerID string, opts core.ContainerLogsOptions) (io.ReadCloser, error) {
return nil, nil
}
Expand Down
4 changes: 4 additions & 0 deletions cli/daemon_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ func (m *mockDockerProvider) WaitContainer(ctx context.Context, containerID stri
return 0, nil
}

func (m *mockDockerProvider) CopyContainerLogs(ctx context.Context, containerID string, stdout, stderr io.Writer, opts core.ContainerLogsOptions) error {
return nil
}

func (m *mockDockerProvider) GetContainerLogs(ctx context.Context, containerID string, opts core.ContainerLogsOptions) (io.ReadCloser, error) {
return nil, nil
}
Expand Down
4 changes: 4 additions & 0 deletions cli/docker_config_handler_timeout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ func (h *hangingHandlerProvider) GetContainerLogs(_ context.Context, _ string, _
return nil, nil
}

func (h *hangingHandlerProvider) CopyContainerLogs(_ context.Context, _ string, _, _ io.Writer, _ core.ContainerLogsOptions) error {
return nil
}

func (h *hangingHandlerProvider) CreateExec(_ context.Context, _ string, _ *domain.ExecConfig) (string, error) {
return "", nil
}
Expand Down
4 changes: 4 additions & 0 deletions cli/docker_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ func (m *mockDockerProviderForHandler) WaitContainer(ctx context.Context, contai
return 0, nil
}

func (m *mockDockerProviderForHandler) CopyContainerLogs(ctx context.Context, containerID string, stdout, stderr io.Writer, opts core.ContainerLogsOptions) error {
return nil
}

func (m *mockDockerProviderForHandler) GetContainerLogs(ctx context.Context, containerID string, opts core.ContainerLogsOptions) (io.ReadCloser, error) {
return nil, nil
}
Expand Down
4 changes: 4 additions & 0 deletions cli/doctor_docker_timeout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@
return 0, nil
}

func (h *hangingDoctorProvider) CopyContainerLogs(_ context.Context, _ string, _, _ io.Writer, _ core.ContainerLogsOptions) error {
return nil
}

func (h *hangingDoctorProvider) GetContainerLogs(_ context.Context, _ string, _ core.ContainerLogsOptions) (io.ReadCloser, error) {
return nil, nil
}
Expand All @@ -86,7 +90,7 @@
func (h *hangingDoctorProvider) RunExec(_ context.Context, _ string, _ *domain.ExecConfig, _, _ io.Writer) (int, error) {
return 0, nil
}
func (h *hangingDoctorProvider) PullImage(_ context.Context, _ string) error { return nil }

Check failure on line 93 in cli/doctor_docker_timeout_test.go

View workflow job for this annotation

GitHub Actions / go-check / golangci-lint

File is not properly formatted (gofumpt)
func (h *hangingDoctorProvider) EnsureImage(_ context.Context, _ string, _ bool) error { return nil }
func (h *hangingDoctorProvider) ConnectNetwork(_ context.Context, _, _ string) error { return nil }
func (h *hangingDoctorProvider) FindNetworkByName(_ context.Context, _ string) ([]domain.Network, error) {
Expand Down
5 changes: 3 additions & 2 deletions core/adapters/docker/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,9 @@ func (s *ContainerServiceAdapter) CopyLogs(
}
defer reader.Close()

if info.Config != nil && info.Config.HostConfig != nil {
// For TTY containers, copy directly
if info.Config != nil && info.Config.Tty {
// TTY containers emit a raw stream with no frame headers —
// copy directly.
if stdout != nil {
if _, err = io.Copy(stdout, reader); err != nil {
return fmt.Errorf("copying container output: %w", err)
Expand Down
5 changes: 3 additions & 2 deletions core/adapters/docker/container_wrappers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,8 +318,9 @@ func TestContainerServiceAdapter_CopyLogs_DemuxesNonTTY(t *testing.T) {
t.Parallel()

adapter := stubDaemon(t, map[string]http.HandlerFunc{
// Inspect must report a container without a HostConfig so CopyLogs
// takes the demultiplexing path rather than the raw TTY copy.
// Inspect reports a non-TTY container (Config.Tty absent/false)
// so CopyLogs takes the demultiplexing path rather than the raw
// TTY copy.
"/json": func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, map[string]any{
"Id": "abc123",
Expand Down
7 changes: 7 additions & 0 deletions core/docker_interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ type DockerProvider interface {
ListContainers(ctx context.Context, opts domain.ListOptions) ([]domain.Container, error)
WaitContainer(ctx context.Context, containerID string) (int64, error)
GetContainerLogs(ctx context.Context, containerID string, opts ContainerLogsOptions) (io.ReadCloser, error)
// CopyContainerLogs streams container logs into stdout/stderr,
// demultiplexing Docker's 8-byte-framed stream for non-TTY
// containers (TTY containers emit a raw stream and are copied
// verbatim). Use this instead of GetContainerLogs whenever the
// output is stored or displayed — the raw reader leaks frame
// headers into the log text.
CopyContainerLogs(ctx context.Context, containerID string, stdout, stderr io.Writer, opts ContainerLogsOptions) error

// Exec operations
CreateExec(ctx context.Context, containerID string, config *domain.ExecConfig) (string, error)
Expand Down
25 changes: 25 additions & 0 deletions core/docker_sdk_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,31 @@
return reader, nil
}

// CopyContainerLogs copies container logs into stdout/stderr, letting the
// adapter demultiplex Docker's stream framing for non-TTY containers.
func (p *SDKDockerProvider) CopyContainerLogs(
ctx context.Context, containerID string, stdout, stderr io.Writer, opts ContainerLogsOptions,
) error {
p.recordOperation("copy_logs")

logsOpts := domain.LogOptions{
ShowStdout: opts.ShowStdout,
ShowStderr: opts.ShowStderr,
Tail: opts.Tail,
Follow: opts.Follow,
}

if !opts.Since.IsZero() {
logsOpts.Since = opts.Since.Format(time.RFC3339Nano)
}

if err := p.client.Containers().CopyLogs(ctx, containerID, stdout, stderr, logsOpts); err != nil {
p.recordError("copy_logs")
return WrapContainerError("copy_logs", containerID, err)
}
return nil
}

// CreateExec creates an exec instance.
func (p *SDKDockerProvider) CreateExec(ctx context.Context, containerID string, config *domain.ExecConfig) (string, error) {
p.recordOperation("create_exec")
Expand Down Expand Up @@ -397,7 +422,7 @@

opts := domain.NetworkListOptions{
Filters: map[string][]string{
"name": {networkName}, //nolint:goconst // Docker SDK filter key — coincidental collision with other "name" string literals

Check failure on line 425 in core/docker_sdk_provider.go

View workflow job for this annotation

GitHub Actions / go-check / golangci-lint

directive `//nolint:goconst // Docker SDK filter key — coincidental collision with other "name" string literals` is unused for linter "goconst" (nolintlint)
},
}

Expand Down
22 changes: 6 additions & 16 deletions core/runjob.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,29 +271,19 @@ func (j *RunJob) startAndWait(ctx context.Context, jobCtx *Context) error {
return err
}

// Get logs since start time
// Copy logs since start time into the execution's streams.
// CopyContainerLogs demultiplexes stdout/stderr for non-TTY
// containers — copying the raw log reader would leak Docker's
// 8-byte frame headers into the stored output.
logsOpts := ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Since: startTime,
Follow: false,
}
reader, logsErr := j.Provider.GetContainerLogs(ctx, j.getContainerID(), logsOpts)
if logsErr != nil {
if logsErr := j.Provider.CopyContainerLogs(ctx, j.getContainerID(),
jobCtx.Execution.OutputStream, jobCtx.Execution.ErrorStream, logsOpts); logsErr != nil {
jobCtx.Warn("failed to fetch container logs: " + logsErr.Error())
} else if reader != nil {
defer reader.Close()
// Stream logs to execution output
buf := make([]byte, 32*1024)
for {
n, readErr := reader.Read(buf)
if n > 0 {
_, _ = jobCtx.Execution.OutputStream.Write(buf[:n])
}
if readErr != nil {
break
}
}
}
return err
}
Expand Down
24 changes: 18 additions & 6 deletions core/scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -723,16 +723,18 @@ func (s *Scheduler) lookupJob(name string) Job {
// in-flight invocations complete before the new schedule takes effect (because
// go-cron serializes entry mutations through the scheduler goroutine).
//
// Returns ErrJobNotFound if no active job with the given name exists.
// Disabled jobs are updated in place and stay disabled: refusing them would
// force callers into remove+add, which resumes the job and files the old copy
// under Removed.
//
// Returns ErrJobNotFound if no job with the given name exists.
func (s *Scheduler) UpdateJob(name string, newSchedule string, newJob Job) error {
s.mu.RLock()
oldJob, _ := getJob(s.Jobs, name)
_, disabled := s.disabledNames[name]
if oldJob == nil || disabled {
s.mu.RUnlock()
s.mu.RUnlock()
if oldJob == nil {
return fmt.Errorf(errFmtWrapQuoted, ErrJobNotFound, name)
}
s.mu.RUnlock()

newJob.Use(s.Middlewares()...)

Expand All @@ -742,14 +744,24 @@ func (s *Scheduler) UpdateJob(name string, newSchedule string, newJob Job) error

// Update internal state
s.mu.Lock()
defer s.mu.Unlock()
for i, j := range s.Jobs {
if j.GetName() == name {
s.Jobs[i] = newJob
break
}
}
s.jobsByName[name] = newJob
s.mu.Unlock()
// go-cron replaces the entry, so a pause is re-asserted rather than assumed
// to carry over. Pausing an already-paused entry is a no-op, so this is
// correct either way.
//
// Lock safety while calling PauseEntryByName: see DisableJob's doc comment.
if _, disabled := s.disabledNames[name]; disabled {
if err := s.cron.PauseEntryByName(name); err != nil {
return fmt.Errorf("re-pause updated job: %w", err)
}
}

s.Logger.Info(fmt.Sprintf("Job updated %q - %q", name, newSchedule))
return nil
Expand Down
Loading
Loading