Skip to content
Merged
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
10 changes: 6 additions & 4 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ If omitted, the default agent is used.

- All request bodies must be `application/json`.
- All responses are `application/json` with `charset=utf-8`.
- Timestamps are **ISO-8601** strings in UTC: `"2026-06-22T14:30:00Z"`.
- Timestamps in **request bodies** should be ISO-8601 strings (e.g. `"2026-06-22T14:30:00Z"`); the agent converts them to the configured system timezone before storing. Timestamps in **responses** are MySQL `DATETIME` strings in the agent's configured timezone (default: `Europe/Berlin`, controlled by the `TZ` environment variable).
- Boolean values are JSON booleans (`true` / `false`), not `0`/`1`.

### Pagination
Expand Down Expand Up @@ -488,9 +488,10 @@ Execution history for a specific job.

| Parameter | Description |
|---|---|
| `limit` | Page size (default: 50) |
| `limit` | Page size (default: 50, max: 500) |
| `offset` | Pagination offset |
| `status` | Filter: `success`, `failed`, `running` |
| `status` | Filter: `success`, `failed`, `running`, `skipped`. `failed` excludes maintenance-skipped entries (exit_code -4); use `skipped` to retrieve those. |
| `unacknowledged_only` | `1` = only return executions where `acknowledged_at` is null |

**Response 200:**

Expand Down Expand Up @@ -926,7 +927,7 @@ Execution history across all jobs.
|---|---|
| `limit` | Page size (default: 100) |
| `offset` | Pagination offset |
| `status` | Filter: `success`, `failed`, `running` |
| `status` | Filter: `success`, `failed`, `running`, `skipped`. `failed` excludes maintenance-skipped entries (exit_code -4); use `skipped` to retrieve those. |
| `tag` | Filter by job tag |

**Response 200:**
Expand Down Expand Up @@ -1156,6 +1157,7 @@ to 60 seconds of delay before the daemon picks up the entry.

| Version | Change |
|---|---|
| 6.2.0 | Added `unacknowledged_only` query parameter to `GET /api/v1/jobs/{id}/history` (§8); filters to executions where `acknowledged_at IS NULL` |
| 6.0.0 | Added `POST /api/v1/executions/{id}/acknowledge` and `DELETE /api/v1/executions/{id}/acknowledge` (§16); new scope `executions:acknowledge` (operator profile and above); `acknowledged_at` and `acknowledged_by_user_id` fields added to execution history objects; two new audit-log event types `execution.acknowledged` / `execution.unacknowledged` |
| 4.8.0 | Added `GET /api/v1/targets` (§9) — distinct execution targets with job counts; optional `?active=` filter. Added `GET /api/v1/linux-users` (§9) — available Linux users for cron scheduling; includes `docker_mode` flag. Added three maintenance operation endpoints (§11): `POST /api/v1/maintenance/logs/purge`, `POST /api/v1/maintenance/history/cleanup` (optional `older_than_days`), `POST /api/v1/maintenance/once/cleanup`. All require `maintenance:write` scope. |
| 4.6.1 | Every agent-specific endpoint now includes `"agent_id"` as the first field in its response (jobs, maintenance, export/json, audit, settings, timeline, tags). Resolves ambiguity in multi-agent setups where the same numeric job ID may refer to different jobs on different agents. UI links (notifications, breadcrumbs, filter resets, pagination) now carry `?agent_id=X` throughout. |
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## [6.2.0] – branch: `feature/dashboard-acknowledge-ux`

### Added

- **Timeline: neuer Status-Filter „Übersprungen (Wartung)":** Der Filter `status=skipped` im Timeline-Dropdown zeigt ausschließlich Ausführungen mit exit_code -4 (Maintenance-Sentinel). Agent: `VALID_STATUSES` um `skipped` erweitert, neue Bedingung `el.exit_code = -4`.

### Fixed

- **`status=failed`-Filter schließt Maintenance-Sentinels (exit_code -4) aus:** Bisher zählte `status=failed` alle Einträge mit `exit_code != 0`, also auch die exit_code-(-4)-Sentinels, die beim Überspringen eines Jobs während einer Wartungszeit gesetzt werden. Folge: Das Dashboard zeigte „Zeige 3 von 3632 unbestätigten Fehlern", obwohl 3629 davon eigentlich Wartungs-Skips waren. Fix: `el.exit_code != -4` zusätzlich in die `failed`-Bedingung aufgenommen. Der clientseitige `-4`-Filter im `DashboardController` entfällt dadurch.

- **Dashboard-Link „Alle in History →" zeigt auf `/timeline` statt `/history`:** `/history` ist kein Web-Route, sondern ein Agent-Endpunkt — der Link führte zu einem 404. Korrigiert auf `/timeline?agent_id=X&status=failed`.

- **Zeitstempel-Konsistenz bei Docker-Deployment:** `ExecutionStartEndpoint` und `ExecutionFinishEndpoint` konvertierten empfangene ISO-8601-Zeitstempel hart-kodiert nach UTC (`new \DateTimeZone('UTC')`). Auf Hosts, bei denen die PHP-Zeitzone im Container von der System-Zeitzone abwich (z.B. `date.timezone=UTC` in `php.ini` bei `/etc/localtime → Europe/Berlin`), wurden Zeitstempel in der falschen Zeitzone gespeichert. Fix: Konvertierung auf `date_default_timezone_get()` umgestellt; zusätzlich wird in allen Agent-Compose-Dateien (`docker-compose-full.yml`, `docker-compose-agent.yml`) die Umgebungsvariable `TZ=${TZ:-Europe/Berlin}` gesetzt, sodass PHP-Timezone und System-Timezone konsistent sind. Das Fallback `date('Y-m-d H:i:s')` war bereits korrekt (nutzt implizit die PHP-Standardtimezone).

### Changed

- **Dashboard: Fehlerzähler zeigt echte Gesamtzahl unbestätigter Fehler:** Das Badge in der Kachel „Aktuelle Fehler" zeigte bisher `failedLast24h` – eine unvollständige Annäherung, die nur die bis zu 10 angezeigten Einträge der letzten 24 h berücksichtigte. Neu: Das Badge zeigt die tatsächliche Gesamtzahl aller unbestätigten Fehler (alle Zeiträume), mit der Beschriftung „offen" statt „(24h)". Dafür wurde im `HistoryEndpoint` des Agenten der neue optionale Parameter `unacknowledged_only=1` eingeführt (`AND el.acknowledged_at IS NULL` im WHERE). Der `DashboardController` nutzt diesen Parameter und fordert genau `limit=10` an; das server-seitige `total`-Feld liefert die Gesamtzahl. Der bisherige Web-seitige `array_slice`-Cap und der Web-seitige `acknowledged_at`-Filter entfallen.

- **Dashboard: „Zeige N von M" Hinweis wenn weitere Fehler vorhanden:** Wenn die Gesamtzahl unbestätigter Fehler die angezeigte Listenlänge (10) übersteigt, erscheint unter der Fehler-Tabelle ein Hinweis „Zeige N von M unbestätigten Fehlern – Alle in History →". Der Hinweis wird im 60-s-AJAX-Poll aktualisiert und zeigt dem Nutzer sofort, dass noch weitere Einträge warten – die bisherige Verwirrung (leeres Dashboard nach Bestätigen von 10 Einträgen, dann nach Reload wieder 10 neue) entfällt.

- **Dashboard: AJAX-Reload der Fehler-Sektion nach Bestätigen:** Nach jedem erfolgreichen AJAX-Acknowledge wird die Fehler-Sektion sofort vom Server neu geladen (`GET /dashboard?_json=1`). Der nächste unbestätigte Fehler füllt automatisch die Liste auf, Badge und Hinweis werden aktualisiert. Technisch: Der Acknowledge-Script feuert das Custom-Event `cm:ack-success`; der Haupt-Script hört darauf und führt `refresh()` aus – keine Code-Duplizierung, keine DOM-Manipulation außer dem sofortigen Entfernen der bestätigten Zeile.

---

## [6.1.0] – branch: `fix/singleton-multi-target`

### Fixed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ key rotation, troubleshooting), see **[MULTI-HOST.md](MULTI-HOST.md)**.

| Variable | Default | Description |
|---|---|---|
| `TZ` | `Europe/Berlin` | Container timezone — controls how incoming ISO-8601 timestamps are stored in MariaDB. Must match the timezone of the host that runs the cron jobs to keep displayed times consistent. |
| `AGENT_BIND_ADDRESS` | `0.0.0.0` | Bind address for the PHP HTTP server |
| `AGENT_PORT` | `8865` | Listening port (used by nginx TLS terminator) |
| `AGENT_TLS_ENABLED` | `true` | Enable nginx TLS reverse proxy (set `false` only for trusted internal networks) |
Expand Down
5 changes: 3 additions & 2 deletions TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -828,11 +828,12 @@ Paginated execution history.
| `job_id` | int | Filter by specific job |
| `tag` | string | Filter by tag name |
| `user` | string | Filter by Linux user |
| `status` | string | `success`, `failed`, or `running` |
| `status` | string | `success`, `failed`, `running`, or `skipped`. `failed` excludes maintenance-skipped entries (exit_code -4); use `skipped` to retrieve those. |
| `from` | string | Start date (`YYYY-MM-DD`) |
| `to` | string | End date (`YYYY-MM-DD`) |
| `limit` | int | Page size (default: 25) |
| `limit` | int | Page size (default: 50, max: 500) |
| `offset` | int | Pagination offset (default: 0) |
| `unacknowledged_only` | `0\|1` | `1` = only return executions where `acknowledged_at IS NULL` |

**Response:**
```json
Expand Down
2 changes: 1 addition & 1 deletion agent/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
6.1.0
6.2.0
5 changes: 3 additions & 2 deletions agent/src/Endpoints/ExecutionFinishEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,11 @@ public function handle(array $params): void
? $body['target']
: null;

// Normalise finished_at to the format MariaDB DATETIME expects (UTC).
// Normalise finished_at to the format MariaDB DATETIME expects in the
// configured system timezone (set via TZ env var in Docker).
try {
$dt = new \DateTimeImmutable((string) $body['finished_at']);
$finishedAt = $dt->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
$finishedAt = $dt->setTimezone(new \DateTimeZone(date_default_timezone_get()))->format('Y-m-d H:i:s');
} catch (\Exception) {
$finishedAt = date('Y-m-d H:i:s');
}
Expand Down
9 changes: 5 additions & 4 deletions agent/src/Endpoints/ExecutionStartEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,11 @@ public function handle(array $params): void

// Normalise the timestamp to the format MariaDB DATETIME expects.
// The wrapper script may send ISO 8601 with a timezone offset
// (e.g. "2026-03-17T22:35:01+01:00"); convert to "Y-m-d H:i:s" in UTC.
// (e.g. "2026-03-17T22:35:01+01:00"); convert to "Y-m-d H:i:s" in the
// configured system timezone (set via TZ env var in Docker).
try {
$dt = new \DateTimeImmutable((string) $body['started_at']);
$startedAt = $dt->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
$startedAt = $dt->setTimezone(new \DateTimeZone(date_default_timezone_get()))->format('Y-m-d H:i:s');
} catch (\Exception) {
$startedAt = date('Y-m-d H:i:s');
}
Expand Down Expand Up @@ -210,7 +211,7 @@ public function handle(array $params): void
// ------------------------------------------------------------------

if ($this->maintenanceRepo->isAgentInMaintenance()) {
$nowUtc = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
$nowUtc = (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s');

$stmt = $this->pdo->prepare(
'INSERT INTO execution_log
Expand Down Expand Up @@ -263,7 +264,7 @@ public function handle(array $params): void
if ($inMaintenance && !$runInMaintenance) {
// Record a skipped execution so the history is complete, then
// return HTTP 423 (Locked) so cron-wrapper knows to exit cleanly.
$nowUtc = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
$nowUtc = (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s');

$stmt = $this->pdo->prepare(
'INSERT INTO execution_log
Expand Down
54 changes: 32 additions & 22 deletions agent/src/Endpoints/HistoryEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,17 @@
* freely to narrow the result set.
*
* Supported query parameters:
* - job_id (int) Filter by a specific cron job ID.
* - tag (string) Filter by tag name – only jobs carrying this tag.
* - user (string) Filter by linux_user of the owning job.
* - status (string) One of: "failed", "success", "running".
* - search (string) Full-text LIKE filter on job description and command.
* - limit (int, 1–500) Max number of rows to return (default 50).
* - offset (int, ≥ 0) Pagination offset (default 0).
* - from (YYYY-MM-DD) Only executions started on or after this date.
* - to (YYYY-MM-DD) Only executions started on or before this date.
* - target (string) Filter by execution target (e.g. "local" or SSH host alias).
* - job_id (int) Filter by a specific cron job ID.
* - tag (string) Filter by tag name – only jobs carrying this tag.
* - user (string) Filter by linux_user of the owning job.
* - status (string) One of: "failed", "success", "running".
* - search (string) Full-text LIKE filter on job description and command.
* - limit (int, 1–500) Max number of rows to return (default 50).
* - offset (int, ≥ 0) Pagination offset (default 0).
* - from (YYYY-MM-DD) Only executions started on or after this date.
* - to (YYYY-MM-DD) Only executions started on or before this date.
* - target (string) Filter by execution target (e.g. "local" or SSH host alias).
* - unacknowledged_only (0|1) When "1", only return executions where acknowledged_at IS NULL.
*
* This class relies on the global `jsonResponse()` function being available
* in the calling scope (defined in agent.php).
Expand Down Expand Up @@ -75,7 +76,7 @@ final class HistoryEndpoint
private const MAX_LIMIT = 500;

/** Allowed values for the ?status query parameter. */
private const VALID_STATUSES = ['failed', 'success', 'running'];
private const VALID_STATUSES = ['failed', 'success', 'running', 'skipped'];

// -------------------------------------------------------------------------
// Constructor
Expand Down Expand Up @@ -115,16 +116,17 @@ public function handle(array $params): void
// 1. Parse and validate query parameters
// ------------------------------------------------------------------

$jobId = $this->parsePositiveInt($_GET['job_id'] ?? null);
$tag = isset($_GET['tag']) && $_GET['tag'] !== '' ? (string) $_GET['tag'] : null;
$user = isset($_GET['user']) && $_GET['user'] !== '' ? (string) $_GET['user'] : null;
$target = isset($_GET['target']) && $_GET['target'] !== '' ? (string) $_GET['target'] : null;
$status = isset($_GET['status']) && $_GET['status'] !== '' ? (string) $_GET['status'] : null;
$search = isset($_GET['search']) && $_GET['search'] !== '' ? (string) $_GET['search'] : null;
$limit = $this->parseLimit($_GET['limit'] ?? null);
$offset = $this->parseOffset($_GET['offset'] ?? null);
$from = $this->parseDate($_GET['from'] ?? null);
$to = $this->parseDate($_GET['to'] ?? null);
$jobId = $this->parsePositiveInt($_GET['job_id'] ?? null);
$tag = isset($_GET['tag']) && $_GET['tag'] !== '' ? (string) $_GET['tag'] : null;
$user = isset($_GET['user']) && $_GET['user'] !== '' ? (string) $_GET['user'] : null;
$target = isset($_GET['target']) && $_GET['target'] !== '' ? (string) $_GET['target'] : null;
$status = isset($_GET['status']) && $_GET['status'] !== '' ? (string) $_GET['status'] : null;
$search = isset($_GET['search']) && $_GET['search'] !== '' ? (string) $_GET['search'] : null;
$limit = $this->parseLimit($_GET['limit'] ?? null);
$offset = $this->parseOffset($_GET['offset'] ?? null);
$from = $this->parseDate($_GET['from'] ?? null);
$to = $this->parseDate($_GET['to'] ?? null);
$unacknowledgedOnly = isset($_GET['unacknowledged_only']) && $_GET['unacknowledged_only'] === '1';

// Validate status value
if ($status !== null && !in_array($status, self::VALID_STATUSES, true)) {
Expand Down Expand Up @@ -199,7 +201,11 @@ public function handle(array $params): void
} elseif ($status === 'success') {
$conditions[] = 'el.finished_at IS NOT NULL AND el.exit_code = 0';
} elseif ($status === 'failed') {
$conditions[] = 'el.finished_at IS NOT NULL AND el.exit_code != 0';
// Maintenance-skipped executions (exit_code -4) are a separate status;
// exclude them from "failed" so the count and list are accurate.
$conditions[] = 'el.finished_at IS NOT NULL AND el.exit_code != 0 AND el.exit_code != -4';
} elseif ($status === 'skipped') {
$conditions[] = 'el.finished_at IS NOT NULL AND el.exit_code = -4';
}

if ($search !== null) {
Expand All @@ -220,6 +226,10 @@ public function handle(array $params): void
$queryParams[':to'] = $to . ' 23:59:59';
}

if ($unacknowledgedOnly) {
$conditions[] = 'el.acknowledged_at IS NULL';
}

$whereClause = $conditions !== []
? 'WHERE ' . implode(' AND ', $conditions)
: '';
Expand Down
2 changes: 2 additions & 0 deletions docker/docker-compose-agent.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ services:
# Container entrypoint script
- /opt/cronmanager/agent/docker/agent/entrypoint.sh:/entrypoint.sh:ro
environment:
# Timezone – must match the host to keep timestamps consistent
TZ: "${TZ:-Europe/Berlin}"
# Optional: InfluxDB 2.x metrics (set in Portainer stack env or .env)
INFLUXDB_ENABLED: "${INFLUXDB_ENABLED:-false}"
INFLUXDB_URL: "${INFLUXDB_URL:-http://influxdb:8086}"
Expand Down
2 changes: 2 additions & 0 deletions docker/docker-compose-full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ services:
AGENT_BIND_ADDRESS: "0.0.0.0"
AGENT_PORT: "8865"
AGENT_TLS_ENABLED: "true"
# Timezone – must match the host to keep timestamps consistent
TZ: "${TZ:-Europe/Berlin}"
# Concurrency: worker processes of the PHP built-in server (v4.7.0)
PHP_CLI_SERVER_WORKERS: "${PHP_CLI_SERVER_WORKERS:-8}"
# TLS_CERT_FILE: "/opt/cronmanager/agent/tls/cert.pem" # auto-generated if omitted
Expand Down
4 changes: 2 additions & 2 deletions tests/Integration/Endpoints/ExecutionFinishEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,12 @@ public function happyPathUpdatesExecutionLogAndReturns200(): void
}

#[Test]
public function iso8601TimestampWithPositiveOffsetIsConvertedToUtc(): void
public function iso8601TimestampWithPositiveOffsetIsConvertedToSystemTimezone(): void
{
$jobId = $this->seedJob();
$executionId = $this->seedRunningExecution($jobId);

// +02:00 UTC is two hours earlier
// +02:00 offset; test environment uses UTC → stored as 10:00:00
$this->callHandle($this->makeEndpoint(), [
'execution_id' => $executionId,
'job_id' => $jobId,
Expand Down
4 changes: 2 additions & 2 deletions tests/Integration/Endpoints/ExecutionStartEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,11 @@ public function happyPathCreatesExecutionLogRowAndReturns201(): void
}

#[Test]
public function iso8601TimestampWithOffsetIsConvertedToUtc(): void
public function iso8601TimestampWithOffsetIsConvertedToSystemTimezone(): void
{
$jobId = $this->seedJob();

// +01:00 offsetUTC is one hour earlier
// +01:00 offset; test environment uses UTC → stored as 10:00:00
$this->callHandle($this->makeEndpoint(), [
'job_id' => $jobId,
'started_at' => '2026-01-15T11:00:00+01:00',
Expand Down
Loading
Loading