diff --git a/CHANGELOG.md b/CHANGELOG.md index cf31b6f..9f7f92a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- -## [Unreleased] – branch: `fix/execute-now-cleanup-retry` +## [5.2.0] – branch: `feature/minor-improvements` ### Fixed @@ -15,6 +15,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **CrontabManager – `getOnceEntrySchedule()`:** Neue Methode liest den Zeitplan-String (`{min} {hour} {dom} {month} *`) aus dem Crontab-Eintrag, der auf den once-Marker des angegebenen Jobs folgt. Wird von `ExecuteNowEndpoint::isScheduleInPast()` genutzt um veraltete Einträge zu erkennen. - **Tests – `tearDown()`-Guard:** `CronBulkDeleteEndpointTest` und `ExecuteNowEndpointTest` initialisierten `$crontabDir` nicht mit einem Default-Wert; wenn `parent::setUp()` wegen fehlender Test-DB via `markTestSkipped()` abbrach, führte `tearDown()` zu einem Typed-Property-Initialisierungsfehler. Beide Properties werden jetzt mit `= ''` initialisiert und `rmdirRecursive()` nur aufgerufen wenn der Wert nicht leer ist. - **ExecuteNowEndpointTest – Szenario 4:** Neuer Integrationstest deckt den Auto-Clean-Pfad ab: staler once-Eintrag mit Zeitplan 1 Stunde in der Vergangenheit + abgeschlossener `execution_log`-Row (exit code 126) → Endpoint gibt 200 zurück, schreibt einen neuen once-Eintrag, entfernt den alten. +- **ExportController – HMAC-Signatur für `/export/download`:** Der Download-Pfad baut die Guzzle-Anfrage manuell (Streaming). Die Signatur verwendete das alte Format ohne `"\0" + userId + "\0" + username`-Felder, die seit v4.3.0 Pflicht sind. Alle Anfragen wurden deshalb mit HTTP 401 abgewiesen und führten zu einem 503 im Browser. Signatur entspricht jetzt dem in `HostAgentClient::sign()` dokumentierten Format. + +### Changed + +- **Job-Liste (`/crons`) – „Läuft"-Badge:** Wenn ein Job aktuell ausgeführt wird (`finished_at IS NULL` auf dem letzten `execution_log`-Eintrag), zeigt die Spalte „Exit-Code" jetzt einen blauen „Läuft"-Badge anstelle des letzten Exit-Codes. `CronListEndpoint` liefert dafür das neue Feld `is_running` (abgeleitet aus `el_last.finished_at`). Der bisher angezeigte Wert war der Exit-Code der letzten *abgeschlossenen* Ausführung — irreführend wenn der Job gerade läuft. +- **Einzel-Benutzer-Modus (Docker):** Alle User-spezifischen UI-Elemente werden ausgeblendet, wenn der Agent nur einen Linux-User kennt (`count(unique linux_users) <= 1`). Betrifft: User-Filter-Dropdown auf `/crons`, `/timeline` und `/export`, `linux_user`-Spalte in den Tabellen auf `/crons` und `/timeline`, `linux_user`-Spalte in der Fehlertabelle auf dem Dashboard, gesamter „Jobs nach Benutzer"-Block auf dem Dashboard. Die Entscheidung trifft der jeweilige Controller anhand der bereits vorhandenen User-Liste; kein neues API-Feld oder Konfigurationsschalter erforderlich. Fallback `true` in den Templates stellt sicher, dass die Elemente bei fehlendem Wert sichtbar bleiben. +- **Dashboard – Layout-Überarbeitung:** Die zweite Zeile nutzt jetzt `lg:grid-cols-4`; die Fehler-Tabelle belegt 3/4 der Breite (`lg:col-span-3`), das neue Statistik-Widget 1/4 (`lg:col-span-1`). „Jobs nach Benutzer" (Multi-User-Modus) rutscht in eine separate dritte Zeile. +- **Dashboard – Ausgabe-Vorschau in der Fehler-Tabelle:** Die letzte Spalte zeigt die letzten 120 Zeichen des Job-Outputs als `font-mono`-Vorschau mit `title`-Tooltip (vollständiger Text). Lässt sich per `$showOutputPreview = false` am Anfang von `dashboard.php` sofort ausblenden. +- **Dashboard – Ausführungsstatistik-Widget:** Neues `GET /stats`-Endpoint auf dem Agent liefert via zwei INDEX-Range-Scans (`idx_el_started_at`) vier Zähler: ausgeführte und fehlerhafte Ausführungen jeweils für „heute" (seit Mitternacht) und „letzte 24 Stunden". Der Widget-Call wird in denselben `getMultiple()`-Batch wie die übrigen Dashboard-Requests eingebettet (kein Extra-Round-Trip). Lässt sich per `SHOW_EXECUTION_STATS = false` in `DashboardController` ausschalten — deaktiviert sowohl den API-Call als auch die Anzeige. + +### Fixed (nachträglich) + +- **Dashboard – fehlende Tailwind-Klassen:** `lg:col-span-3` und `lg:col-span-1` fehlten in der vorgenerierten `tailwind.css`; die Fehler-Tabelle belegte deshalb nur eine statt drei Kachel-Breiten. Die fehlenden Media-Query-Regeln wurden in `assets/css/tailwind.css` ergänzt. +- **Dashboard – Ausgabe-Vorschau zeigte Anfang statt Ende:** CSS-Klasse `truncate` (`white-space: nowrap; text-overflow: ellipsis`) kollabierte Zeilenumbrüche und schnitt den Text von rechts ab. Die letzten 120 Zeichen wurden von PHP korrekt ermittelt, aber die CSS-Kürzung versteckte den Schluss des Ausschnitts — genau die Stelle, an der Fehlermeldungen stehen. Ersetzt durch `whitespace-pre-wrap break-words`; der vollständige Tooltip bleibt erhalten. +- **„Läuft"-Badge – inkonsistente Farbe:** Status-Badge war auf der Job-Übersichtsseite blau, auf der Job-Detailseite und in der Monitor-Ansicht hingegen gelb. Alle drei Stellen verwenden jetzt einheitlich die blauen Klassen (`bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200`). + +### Documentation + +- **HOST-AGENT.md** – neues separates Dokument für die Host-Agent-Installation (systemd-Service direkt auf dem Docker-Host): Architektur-Diagramm, Voraussetzungen, `deploy.sh`-Deployment, Schritte 1–7 (manuell), Konfigurationsreferenz, Crontab lesen, Aktualisieren, Migration zu Docker-Modus. +- **README.md** – auf Docker-Hub-Installation fokussiert; Quick-Start, Prerequisites, Guided Setup und Detailed-Installation-Kapitel entfernt; Host-Agent-Themen zu HOST-AGENT.md verlagert. +- **deploy.sh** – `--docker`-Modus und alle docker-spezifischen Branches entfernt; das Skript dient ausschließlich der Host-Agent-Installation. +- **simple_debian_setup.sh** entfernt (nicht mehr gewartet). --- diff --git a/HOST-AGENT.md b/HOST-AGENT.md new file mode 100644 index 0000000..a56eb22 --- /dev/null +++ b/HOST-AGENT.md @@ -0,0 +1,459 @@ +# Host-Agent Installation + +This document describes running Cronmanager in **host-agent mode**: the PHP agent runs as +a systemd service directly on the Docker host, while the web UI and MariaDB still run as +Docker containers. + +For the simpler recommended installation using pre-built Docker images for all three +components, see the [Docker Hub section in README.md](README.md#docker-hub--recommended-installation). + +--- + +## Table of Contents + +1. [Architecture](#architecture) +2. [Prerequisites](#prerequisites) +3. [Deployment with deploy.sh](#deployment-with-deploysh) +4. [Step 1 – Install PHP and shared libraries](#step-1--install-php-and-shared-libraries-on-the-host) +5. [Step 2 – Deploy the files](#step-2--deploy-the-files) +6. [Step 3 – Configure the host agent](#step-3--configure-the-host-agent) +7. [Step 4 – Start the host agent service](#step-4--start-the-host-agent-service) +8. [Step 5 – Configure the web application](#step-5--configure-the-web-application) +9. [Step 6 – Start the Docker stack](#step-6--start-the-docker-stack) +10. [Step 7 – First login](#step-7--first-login) +11. [Configuration Reference](#configuration-reference) +12. [Reading the Crontab](#reading-the-crontab) +13. [Updating](#updating) +14. [Migrating to Docker mode](#migrating-to-docker-mode) + +--- + +## Architecture + +``` +Browser + │ + ▼ +┌──────────────────────────┐ +│ Web UI (Docker) │ PHP-FPM + Nginx · Port 8880 +│ /opt/cronmanager/www │ +└────────────┬─────────────┘ + │ HMAC-signed HTTPS (host.docker.internal:8865) + ▼ +┌──────────────────────────┐ +│ Host Agent │ nginx (TLS) → PHP CLI server · Port 8865 +│ /opt/cronmanager/agent │ systemd service on the Docker host +└────────────┬─────────────┘ + │ reads/writes crontab files + │ reports execution results via PDO + ▼ + Linux cron daemon MariaDB container (cronmanager-db) +``` + +The agent runs directly on the Docker host. The web container reaches it via +`host.docker.internal:8865` (provided by Docker's `extra_hosts: host-gateway` mechanism). +Communication is encrypted with TLS; see [Agent TLS](README.md#agent-tls) for details. + +--- + +## Prerequisites + +| Component | Requirement | +|---|---| +| Docker + Docker Compose | v2.0 or later | +| PHP on the **host** | 8.4 with extensions: `cli`, `json`, `pdo_mysql`, `openssl`, `mbstring` | +| Composer | 2.x (to install shared PHP libraries) | +| curl | For the cron wrapper script | +| openssl | For HMAC-SHA256 signing in the wrapper | +| SSH client | Required only for remote job execution | + +--- + +## Deployment with deploy.sh + +`deploy.sh` automates the file deployment, directory creation, systemd service installation, +and database initialisation. It supports local and SSH (remote) targets. + +### Configure deployment + +```bash +cp deploy.env.example deploy.env +``` + +Edit `deploy.env`: + +```bash +DEPLOY_TYPE=SSH # SSH (remote host) or LOCAL (same machine) +DEPLOY_SSH=myserver # Host alias from ~/.ssh/config +DEPLOY_COMPOSER=/opt/phplib/ +DEPLOY_COMPOSER_VENDOR=/opt/phplib/vendor/ +``` + +### Configure database credentials + +```bash +cp db.credentials.example db.credentials +``` + +Edit `db.credentials`: + +```bash +DB_NAME=cronmanager +DB_USER=cronmanager +DB_PASSWORD= +DB_ROOT_USER=root +DB_ROOT_PASSWORD= +``` + +> `db.credentials` contains plain-text passwords. Keep it out of version control. + +### Run the full deployment + +```bash +./deploy.sh --host-agent full +``` + +The script will: +- Create all required directories on the target +- Sync all application files via rsync +- Deploy the example configuration files (only if no config exists yet) +- Generate the MariaDB init script from your credentials +- Install and enable the systemd service and start the agent + +--- + +## Step 1 – Install PHP and shared libraries on the host + +Cronmanager uses a shared vendor directory (`/opt/phplib/vendor`) that is loaded by +both the host agent (directly on the filesystem) and the web container (via Docker volume mount). + +**Install PHP 8.4 on the host (Debian/Ubuntu):** + +```bash +sudo apt-get install -y php8.4-cli php8.4-mysql php8.4-mbstring curl openssl +``` + +**Install Composer (if not already present):** + +```bash +curl -sS https://getcomposer.org/installer | php +sudo mv composer.phar /usr/local/bin/composer +``` + +**Install PHP dependencies into the shared vendor directory:** + +```bash +# Create the shared library directory +sudo mkdir -p /opt/phplib + +# Copy the project's composer.json there +# (deploy.sh does this automatically on the first full deployment if the file is absent) +sudo cp composer.json /opt/phplib/composer.json + +# Install packages +cd /opt/phplib +sudo composer install --no-dev --optimize-autoloader +``` + +The resulting `/opt/phplib/vendor/autoload.php` is used by both the host agent and the +web container. + +--- + +## Step 2 – Deploy the files + +After completing Step 1 and configuring `deploy.env` and `db.credentials` as shown above, +run the deployment: + +```bash +./deploy.sh --host-agent full +``` + +The script creates all required directories, syncs all application files, deploys the +example configuration files (only if no config exists yet), generates the MariaDB init +script, and installs the systemd service. + +> Deployment paths are fixed: agent → `/opt/cronmanager/agent`, web → `/opt/cronmanager/www`, DB → `/opt/cronmanager/db`. + +--- + +## Step 3 – Configure the host agent + +The agent configuration is at `/opt/cronmanager/agent/config/config.json`. +On the first deployment, the example configuration is placed there automatically. + +**Minimum required changes:** + +```json +{ + "agent": { + "bind_address": "0.0.0.0", + "port": 8865, + "hmac_secret": "" + }, + "database": { + "host": "127.0.0.1", + "port": 3306, + "name": "cronmanager", + "user": "cronmanager", + "password": "" + } +} +``` + +Generate a strong HMAC secret: + +```bash +openssl rand -hex 32 +``` + +> The same `hmac_secret` value must appear in both the agent config and the web app config. + +--- + +## Step 4 – Start the host agent service + +The deployment script installs and starts the systemd service automatically. +You can manage it with standard systemd commands: + +```bash +# Check service status +sudo systemctl status cronmanager-agent + +# View live logs +sudo journalctl -u cronmanager-agent -f + +# Restart after a config change +sudo systemctl restart cronmanager-agent + +# Verify the agent is reachable (TLS with self-signed cert) +curl -k https://127.0.0.1:8865/health +# → {"status":"ok","timestamp":"2026-03-18T10:00:00+00:00"} +``` + +--- + +## Step 5 – Configure the web application + +The web configuration is at `/opt/cronmanager/www/conf/config.json`. +On the first deployment, the example configuration is placed there automatically. + +**Minimum required changes:** + +```json +{ + "database": { + "host": "cronmanager-db", + "port": 3306, + "name": "cronmanager", + "user": "cronmanager", + "password": "" + }, + "agent": { + "url": "https://host.docker.internal:8865", + "hmac_secret": "", + "timeout": 10, + "ssl_verify": false, + "ssl_ca_bundle": "" + } +} +``` + +`host.docker.internal` resolves to the Docker host from within the web container and is +configured automatically via the `extra_hosts` entry in `docker/docker-compose.yml`. + +`ssl_verify: false` is correct for the default self-signed certificate. Set it to `true` +and supply `ssl_ca_bundle` when using a certificate signed by a private CA. + +--- + +## Step 6 – Start the Docker stack + +**Option A – docker compose directly on the host:** + +```bash +cd /opt/cronmanager/www + +export DB_NAME=cronmanager +export DB_USER=cronmanager +export DB_PASSWORD= +export DB_ROOT_PASSWORD= + +docker compose up -d +``` + +**Option B – Portainer:** + +1. Open Portainer → Stacks → Add Stack +2. Paste the contents of `docker/docker-compose.yml` +3. Add the following environment variables: + - `DB_NAME` = `cronmanager` + - `DB_USER` = `cronmanager` + - `DB_PASSWORD` = your password + - `DB_ROOT_PASSWORD` = your root password +4. Deploy the stack + +**Apply the database schema** (first deployment only, once the MariaDB container is healthy): + +```bash +ssh myserver 'docker exec -i cronmanager-db mariadb \ + -u cronmanager -p cronmanager \ + < /opt/cronmanager/agent/sql/schema.sql' +``` + +--- + +## Step 7 – First login + +Open `http://:8880/` in your browser. + +If no users exist in the database yet, you are automatically redirected to the +**Setup wizard**: + +1. Enter a username for the initial admin account +2. Enter and confirm a password (minimum 8 characters) +3. Click **Create admin account** + +You are then redirected to the login page. Log in with the credentials you just created. + +--- + +## Configuration Reference + +### Web application config + +| Key | Default | Description | +|---|---|---| +| `database.host` | `cronmanager-db` | MariaDB hostname (Docker service name) | +| `database.port` | `3306` | MariaDB port | +| `database.name` | `cronmanager` | Database name | +| `database.user` | `cronmanager` | Database user | +| `database.password` | | Database password | +| `agent.url` | `https://host.docker.internal:8865` | Host agent base URL | +| `agent.hmac_secret` | | Shared HMAC secret (must match agent) | +| `agent.timeout` | `10` | HTTP timeout in seconds | +| `agent.ssl_verify` | `false` | `false` = accept self-signed cert; `true` = require trusted CA; path string = use custom CA bundle | +| `agent.ssl_ca_bundle` | `""` | Path to a PEM CA bundle inside the container | +| `logging.path` | `/var/www/log/cronmanager-web.log` | Log file path | +| `logging.level` | `info` | `debug`, `info`, `warning`, `error`, `critical` | +| `logging.max_days` | `30` | Log file retention in days | +| `session.lifetime` | `3600` | Session cookie max-age in seconds | +| `session.idle_timeout` | `3600` | Server-side idle expiry in seconds | +| `session.name` | `cronmanager_sess` | Session cookie name | +| `i18n.default_language` | `en` | Default language (`en` or `de`) | +| `auth.oidc_enabled` | `false` | Enable OIDC SSO | +| `auth.oidc_provider_url` | | OIDC provider base URL (with trailing slash) | +| `auth.oidc_client_id` | | OAuth 2.0 Client ID | +| `auth.oidc_client_secret` | | OAuth 2.0 Client Secret | +| `auth.oidc_redirect_uri` | | Callback URL (`https://your-domain/auth/callback`) | +| `auth.oidc_ssl_verify` | `true` | `true` = system CA, `false` = disable, or path to CA bundle | +| `auth.oidc_ssl_ca_bundle` | `""` | Path to custom PEM CA bundle (empty = system CA) | +| `auth.oidc_auto_provision` | `auto` | SSO provisioning mode: `auto`, `disabled`, or `group` | +| `auth.oidc_group_claim` | `groups` | OIDC claim containing the user's group list | +| `auth.oidc_group_admin` | `""` | Group name → `admin` role | +| `auth.oidc_group_operator` | `""` | Group name → `operator` role | +| `auth.oidc_group_viewer` | `""` | Group name → `viewer` role | +| `auth.oidc_default_role` | `""` | Fallback role for unmatched SSO users; empty = deny login | +| `mail.host` | `""` | SMTP hostname for web UI emails (invitations, password reset); empty = disabled | +| `mail.port` | `587` | SMTP port | +| `mail.username` | `""` | SMTP username | +| `mail.password` | `""` | SMTP password | +| `mail.from` | `""` | Sender address | +| `mail.from_name` | `Cronmanager` | Sender display name | +| `mail.encryption` | `tls` | `tls` (STARTTLS) or `ssl` (SMTPS) | + +### Agent config + +| Key | Default | Description | +|---|---|---| +| `agent.bind_address` | `0.0.0.0` | Bind address for the internal PHP server | +| `agent.port` | `8865` | External port (nginx TLS) | +| `agent.tls_enabled` | `true` | Enable nginx TLS terminator; read by `cron-wrapper.sh` to decide HTTP vs HTTPS | +| `agent.hmac_secret` | | Shared HMAC secret (must match web config) | +| `database.host` | `127.0.0.1` | MariaDB hostname | +| `database.port` | `3306` | MariaDB port | +| `database.name` | `cronmanager` | Database name | +| `database.user` | `cronmanager` | Database user | +| `database.password` | | Database password | +| `logging.path` | `/opt/cronmanager/agent/log/cronmanager-agent.log` | Log file path | +| `logging.level` | `info` | Log level | +| `logging.max_days` | `30` | Log file retention in days | +| `mail.enabled` | `false` | Enable email failure alerts | +| `mail.host` | | SMTP server hostname | +| `mail.port` | `587` | SMTP port | +| `mail.username` | | SMTP username | +| `mail.password` | | SMTP password | +| `mail.from` | | Sender address | +| `mail.from_name` | `Cronmanager` | Sender display name | +| `mail.to` | | Recipient address for alerts | +| `mail.encryption` | `tls` | `tls` (STARTTLS, port 587) or `ssl` (SMTPS, port 465) | +| `mail.smtp_timeout` | `15` | SMTP connection timeout in seconds | +| `telegram.enabled` | `false` | Enable Telegram failure alerts | +| `telegram.bot_token` | | Bot API token from @BotFather | +| `telegram.chat_id` | | Target chat, channel, or group ID | +| `telegram.timeout` | `15` | HTTP request timeout in seconds | +| `cron.wrapper_script` | `/opt/cronmanager/agent/bin/cron-wrapper.sh` | Wrapper script path | + +--- + +## Reading the Crontab + +The agent manages crontab files directly on the host for each configured Linux user: + +```bash +# View the crontab for a specific user +crontab -u -l + +# View the raw crontab file +cat /var/spool/cron/crontabs/ +``` + +Managed entries are prefixed with a `# Cronmanager:` comment line and call the wrapper script: + +``` +# Cronmanager: My job id:42 +*/5 * * * * /opt/cronmanager/agent/bin/cron-wrapper.sh 42 local +``` + +--- + +## Updating + +Deploy only changed files (configuration files are never overwritten): + +```bash +./deploy.sh --host-agent update +``` + +Restart the host agent to load code changes: + +```bash +sudo systemctl restart cronmanager-agent +``` + +Apply database migrations when indicated in the release notes: + +```bash +ssh myserver 'docker exec -i cronmanager-db mariadb \ + -u cronmanager -p cronmanager \ + < /opt/cronmanager/agent/sql/migrations/.sql' +``` + +--- + +## Migrating to Docker mode + +To move from host-agent mode to the fully-containerised Docker mode (agent in a container), +run the migration helper: + +```bash +./deploy.sh --host-agent migrate +``` + +The script will: +1. Stop and disable the systemd service +2. Remove managed crontab entries from all host user crontabs +3. Patch `agent/config/config.json` (database host, wrapper path, log path) +4. Patch `www/conf/config.json` (agent URL → Docker service name) + +After the script completes, follow the remaining manual steps printed in the summary. diff --git a/README.md b/README.md index e05f067..63e7d6d 100644 --- a/README.md +++ b/README.md @@ -18,26 +18,22 @@ history, email failure alerts, execution limits, multi-host support, and SSO int 2. [Architecture Overview](#architecture-overview) 3. [Docker Hub – Recommended Installation](#docker-hub--recommended-installation) - [Environment variables reference](#environment-variables-reference) -4. [Prerequisites](#prerequisites) -5. [Guided Setup (Alternative)](#guided-setup-alternative) -6. [Quick Start](#quick-start) -7. [Detailed Installation](#detailed-installation) -8. [OIDC / SSO Setup](#oidc--sso-setup) → [SSO.md](SSO.md) -9. [Configuration Reference](#configuration-reference) -10. [Agent TLS](#agent-tls) -11. [Failure Alerts](#failure-alerts-email--telegram) → [ALERTS.md](ALERTS.md) -12. [InfluxDB Metrics](#influxdb-metrics) → [INFLUXDB.md](INFLUXDB.md) -13. [Multi-Host Execution](#multi-host-execution) → [MULTI-HOST.md](MULTI-HOST.md) -14. [Crontab Import](#crontab-import) -15. [Reading the Crontab](#reading-the-crontab) -16. [Settings](#settings) -17. [Multi-Agent Setup](#multi-agent-setup) → [MULTI-AGENT.md](MULTI-AGENT.md) -18. [Maintenance Windows](#maintenance-windows) -19. [Export](#export) -20. [User Management](#user-management) -21. [External REST API](#external-rest-api) -22. [Updating](#updating) -23. [Troubleshooting](#troubleshooting) → [TROUBLESHOOTING.md](TROUBLESHOOTING.md) +4. [Host-Agent Installation](#host-agent-installation) +5. [OIDC / SSO Setup](#oidc--sso-setup) → [SSO.md](SSO.md) +6. [Agent TLS](#agent-tls) +7. [Failure Alerts](#failure-alerts-email--telegram) → [ALERTS.md](ALERTS.md) +8. [InfluxDB Metrics](#influxdb-metrics) → [INFLUXDB.md](INFLUXDB.md) +9. [Multi-Host Execution](#multi-host-execution) → [MULTI-HOST.md](MULTI-HOST.md) +10. [Crontab Import](#crontab-import) +11. [Reading the Crontab](#reading-the-crontab) +12. [Settings](#settings) +13. [Multi-Agent Setup](#multi-agent-setup) → [MULTI-AGENT.md](MULTI-AGENT.md) +14. [Maintenance Windows](#maintenance-windows) +15. [Export](#export) +16. [User Management](#user-management) +17. [External REST API](#external-rest-api) +18. [Updating](#updating) +19. [Troubleshooting](#troubleshooting) → [TROUBLESHOOTING.md](TROUBLESHOOTING.md) --- @@ -51,7 +47,7 @@ history, email failure alerts, execution limits, multi-host support, and SSO int | **Kill running execution** | Admins can terminate a running job mid-flight from the detail page (local: SIGTERM; SSH: remote kill) | | **Singleton mode** | Flag a job so that new executions are silently skipped while a previous instance is still running | | **Job monitor** | Per-job statistics page with KPI cards (success rate, avg/min/max duration, alerts), an execution duration line chart, and a stacked bar chart – selectable time window from 1 hour to 1 year; period and target switching updates in-place via AJAX with auto-refresh for short windows | -| **Dashboard** | At-a-glance view of total jobs, active/inactive counts, and recent failures; KPI cards refresh every 60 s via AJAX | +| **Dashboard** | At-a-glance view of total jobs, active/inactive counts, recent failures, and execution statistics; KPI cards refresh every 60 s via AJAX | | **Bulk operations** | Select multiple jobs on the list page to activate, deactivate, delete, or re-tag them in a single action; running executions block bulk delete with a clear error message | | **Timeline** | Filterable, paginated history of all executions across all jobs | | **Swimlane** | Visual schedule overview: planned fire times per job across a time-of-day axis, filterable by hour range, day of week, tag, and target | @@ -86,36 +82,6 @@ history, email failure alerts, execution limits, multi-host support, and SSO int ## Architecture Overview -Cronmanager supports two deployment modes. - -### Host-agent mode - -``` -Browser - │ - ▼ -┌──────────────────────────┐ -│ Web UI (Docker) │ PHP-FPM + Nginx · Port 8880 -│ /opt/cronmanager/www │ -└────────────┬─────────────┘ - │ HMAC-signed HTTPS (host.docker.internal:8865) - ▼ -┌──────────────────────────┐ -│ Host Agent │ nginx (TLS) → PHP CLI server · Port 8865 -│ /opt/cronmanager/agent │ systemd service on the Docker host -└────────────┬─────────────┘ - │ reads/writes crontab files - │ reports execution results via PDO - ▼ - Linux cron daemon MariaDB container (cronmanager-db) -``` - -The agent runs directly on the Docker host. The web container reaches it via -`host.docker.internal:8865` (provided by Docker's `extra_hosts: host-gateway` mechanism). -Communication is encrypted with TLS; see [Agent TLS](#agent-tls) for details. - -### Docker mode - ``` Browser │ @@ -128,7 +94,7 @@ Browser ▼ ┌──────────────────────────┐ │ Agent container │ nginx (TLS) → PHP CLI server · Port 8865 -│ cs1711/cs_cronmanageragent (internal Docker network) +│ cs1711/cronmanager-agent (internal Docker network) └────────────┬─────────────┘ │ manages container's crontab (root) │ reports execution results via PDO @@ -136,15 +102,13 @@ Browser Container cron daemon MariaDB container (cronmanager-db) ``` -In docker mode the agent runs in its own container alongside the web UI. All three services share a private `cronmanager-internal` Docker network. -No PHP installation is required on the host. - -The web container never touches crontab files directly. -All privileged operations are delegated to the agent via HMAC-secured HTTPS calls. - +The web container never touches crontab files directly — all privileged operations are +delegated to the agent via HMAC-secured HTTPS calls. A MariaDB container (`cronmanager-db`) stores users, job metadata, tags, and execution logs. +> For the alternative **host-agent** deployment mode (agent as a systemd service on the Docker host), see **[HOST-AGENT.md](HOST-AGENT.md)**. + --- ## Docker Hub – Recommended Installation @@ -221,8 +185,69 @@ To use a specific version, replace `:latest` in `docker-compose-full.yml`: image: cs1711/cronmanager-agent:2.5.0 image: cs1711/cronmanager-web:2.5.0 ``` -No host directory mounts are needed. Optional host-path alternatives are available as -commented-out lines in `docker-compose-full.yml`. + +### Updating to a new release + +```bash +docker compose -f docker-compose-full.yml pull +docker compose -f docker-compose-full.yml up -d +``` + +The agent container automatically applies any new SQL migrations on startup. + +### SSH – the foundation of Docker-mode job execution + +> **Important:** In Docker mode, the agent runs inside a container. A job with target +> `local` executes **inside that container**, not on the Docker host. To run jobs on +> the **Docker host itself** — or on any other machine — you must use an SSH target. +> SSH is therefore the primary mechanism for most real-world workloads. + +The `docker-compose-full.yml` mounts `/root/.ssh` from the Docker host into the agent +container. This gives the container access to your existing SSH key pairs and +`~/.ssh/config` host aliases without any extra setup. + +#### Minimum SSH setup (single Docker host) + +If you just want to manage jobs that run on the Docker host itself, add one alias to +the host's `~/.ssh/config`: + +``` +Host dockerhost + HostName host.docker.internal + User root + IdentityFile ~/.ssh/id_ed25519 + BatchMode yes + ConnectTimeout 10 + StrictHostKeyChecking accept-new +``` + +> `host.docker.internal` resolves to the Docker gateway IP inside the container +> (provided by the `extra_hosts: host-gateway` entry in `docker-compose-full.yml`). + +Then allow the key on the host: + +```bash +cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys +chmod 600 ~/.ssh/authorized_keys +``` + +After the stack is running, test the connection from inside the agent container: + +```bash +docker exec cronmanager-agent ssh -o BatchMode=yes dockerhost echo ok +# → ok +``` + +Create your jobs with **Execution target: `dockerhost`** (or whatever alias you chose). +You can also use the **Test** button on the Maintenance Windows page to verify any +SSH target from the web UI. + +> **Tip:** Use the **Crontab Import** feature (`/crons/import`) to detect and import jobs +> that were previously managed directly in the host's crontab. After import, the agent +> runs them via SSH on the `dockerhost` target automatically. + +For more complex setups (multiple remote hosts, dedicated agent SSH directory, +key rotation, troubleshooting), see **[MULTI-HOST.md](MULTI-HOST.md)**. ### Environment variables reference @@ -308,364 +333,12 @@ commented-out lines in `docker-compose-full.yml`. | `WEB_MAIL_FROM_NAME` | `Cronmanager` | Sender display name | | `WEB_MAIL_ENCRYPTION` | `tls` | `tls` (STARTTLS, port 587) or `ssl` (SMTPS, port 465) | -### Updating to a new release - -```bash -docker compose -f docker-compose-full.yml pull -docker compose -f docker-compose-full.yml up -d -``` - -The agent container automatically applies any new SQL migrations on startup. - -### SSH keys for remote job execution and crontab import - -The `docker-compose-full.yml` mounts `/root/.ssh` from the Docker host into the agent -container by default, giving it access to all configured SSH key pairs and host aliases. - -For SSH key setup, using a dedicated agent-specific SSH directory, reaching the Docker -host from inside the agent container, and troubleshooting SSH, see **[MULTI-HOST.md](MULTI-HOST.md)**. - ---- - -## Prerequisites - -| Component | Requirement | -|---|---| -| Docker + Docker Compose | v2.0 or later | -| PHP on the **host** | 8.4 with extensions: `cli`, `json`, `pdo_mysql`, `openssl`, `mbstring` — **host-agent mode only**; not required for docker mode | -| Composer | 2.x (to install shared PHP libraries) | -| curl | For the cron wrapper script | -| openssl | For HMAC-SHA256 signing in the wrapper | -| SSH client | Required only for remote job execution | - -The Docker image used for the web container (`cs1711/cs_cronmanagerweb:latest`) is a -Debian-based image (derived from `cs_php-nginx-fpm:latest-debian`) that includes -PHP-FPM 8.4, Nginx, and supervisord with production PHP settings pre-configured. - -> **Alternative images**: Any Docker image that bundles PHP-FPM **8.4** (or later 8.x) with -> Nginx (or Apache) and the required PHP extensions (`pdo_mysql`, `json`, `mbstring`, `openssl`, -> `curl`) is supported. Official images such as `php:8.4-fpm` combined with a separate -> Nginx container, or community images like `webdevops/php-nginx:8.4`, are equally valid. -> Update the `image:` field in `docker-compose.yml` accordingly. - -> **APCu extension**: The swimlane view uses APCu for in-memory caching of pre-computed cron -> fire-time patterns. The `cs1711/cs_cronmanagerweb` image includes `php8.4-apcu` out of -> the box. If you use a custom base image, install the `php8.4-apcu` package (Debian/Ubuntu) -> for best performance. The swimlane view works without APCu but will recompute all patterns -> on every page load. Verify with: -> ```bash -> docker exec php -r "var_dump(extension_loaded('apcu'));" -> ``` - ---- - -## Guided Setup (Alternative) - -For a fresh installation on a Debian or Ubuntu host, the easiest path is the -interactive setup script included in the repository. It guides you through -every step in a single session — no manual config file editing required. - -### One-command download and run - -```bash -curl -fsSL https://raw.githubusercontent.com/csoscd/cronmanager/main/simple_debian_setup.sh | sudo bash -``` - -> **Note:** Piping directly into `bash` is convenient but means you trust the -> content of the script at that URL. If you prefer to review it first: -> ```bash -> curl -fsSL https://raw.githubusercontent.com/csoscd/cronmanager/main/simple_debian_setup.sh \ -> -o simple_debian_setup.sh -> less simple_debian_setup.sh # review -> sudo bash simple_debian_setup.sh # run -> ``` - -### What the script covers - -| Step | What happens | -|---|---| -| **Target host** | Choose local installation or a remote server via SSH. SSH connectivity and root access are verified before anything else. | -| **Prerequisites** | Checks for PHP 8.4, required extensions, Docker, Composer, git, openssl, rsync and more — **on the target host**. Lists any missing packages and offers to install them via `apt`. | -| **Repository clone** | Clones the repository locally, then deploys files to the target. | -| **Composer / PHP libraries** | Verifies that all required third-party libraries are present on the target. Offers to add missing packages to `composer.json` and run `composer install`. | -| **Configuration interview** | Collects all settings interactively — paths, database credentials, agent and web settings — before touching anything on disk. | -| **HMAC secret** | Generates a cryptographically random 64-character secret with `openssl rand -hex 32`. Both the agent and web application receive the same value automatically. | -| **Host agent deployment** | Deploys agent files, patches paths, writes `config/config.json`, installs the systemd service, starts it, and runs a health check. | -| **Web application deployment** | Deploys web files (including the pre-built Tailwind stylesheet), downloads Chart.js, writes `conf/config.json`. | -| **Docker Compose** | Generates a customised `docker-compose.yml` from your settings, displays it, and optionally runs `docker compose up -d`. | -| **Database schema** | Waits for MariaDB to become healthy, then applies `schema.sql` and all migrations via `docker exec`. | -| **Optional: OIDC** | Asks for provider URL, client credentials, redirect URI, and SSL/CA settings. | -| **Optional: Email alerts** | Asks for SMTP host, port, credentials and encryption. | -| **Summary** | Prints all paths, management commands, the web UI URL, and the generated HMAC secret. | - -> **Requirements:** Debian 12+ or Ubuntu 22.04+, internet access on the target, root access. - ---- - -## Quick Start - -```bash -# 1. Clone the repository on your development / deployment machine -git clone cronmanager -cd cronmanager - -# 2. Configure deployment -cp deploy.env.example deploy.env # edit SSH host and target paths -cp db.credentials.example db.credentials # set database passwords - -# 3. Full deployment to the target host -# Use --host-agent if the agent runs as a systemd service on the host, -# or --docker if the agent runs as a Docker container. -./deploy.sh --host-agent full # host-agent mode -# or: -./deploy.sh --docker full # docker mode - -# 4. Open the web UI -http://:8880/ -# → First visit shows the setup wizard to create the initial admin account -``` - ---- - -## Detailed Installation - -### Step 1 – Install PHP and shared libraries on the host - -Cronmanager uses a shared vendor directory (`/opt/phplib/vendor`) that is loaded by -both the host agent (directly on the filesystem) and the web container (via Docker volume mount). - -**Install PHP 8.4 on the host (Debian/Ubuntu):** - -```bash -sudo apt-get install -y php8.4-cli php8.4-mysql php8.4-mbstring curl openssl -``` - -**Install Composer (if not already present):** - -```bash -curl -sS https://getcomposer.org/installer | php -sudo mv composer.phar /usr/local/bin/composer -``` - -**Install PHP dependencies into the shared vendor directory:** - -```bash -# Create the shared library directory -sudo mkdir -p /opt/phplib - -# Copy the project's composer.json there -# (deploy.sh does this automatically on the first full deployment if the file is absent) -sudo cp composer.json /opt/phplib/composer.json - -# Install packages -cd /opt/phplib -sudo composer install --no-dev --optimize-autoloader -``` - -The resulting `/opt/phplib/vendor/autoload.php` is used by both the host agent and the -web container. - ---- - -### Step 2 – Deploy the files - -**Configure deployment:** - -```bash -cp deploy.env.example deploy.env -``` - -Edit `deploy.env`: - -```bash -DEPLOY_TYPE=SSH # SSH (remote host) or LOCAL (same machine) -DEPLOY_SSH=myserver # Host alias from ~/.ssh/config -DEPLOY_COMPOSER=/opt/phplib/ -DEPLOY_COMPOSER_VENDOR=/opt/phplib/vendor/ -``` - -> Deployment paths are fixed: agent → `/opt/cronmanager/agent`, web → `/opt/cronmanager/www`, DB → `/opt/cronmanager/db`. These are not configurable in `deploy.env`. - -**Configure database credentials:** - -```bash -cp db.credentials.example db.credentials -``` - -Edit `db.credentials`: - -```bash -DB_NAME=cronmanager -DB_USER=cronmanager -DB_PASSWORD= -DB_ROOT_USER=root -DB_ROOT_PASSWORD= -``` - -> `db.credentials` contains plain-text passwords. Keep it out of version control. - -**Run the deployment:** - -```bash -./deploy.sh --host-agent full # host-agent mode -# or: -./deploy.sh --docker full # docker mode -``` - -The script will: -- In `--host-agent` mode: installs and enables the systemd service for the host agent -- In `--docker` mode: skips systemd; use docker-compose to start the agent container -- Create all required directories on the target -- Sync all application files via rsync -- Deploy the example configuration files (only if no config exists yet) -- Generate the MariaDB init script from your credentials -- Attempt to apply the database schema (once the container is running) - ---- - -### Step 3 – Configure the host agent - -The agent configuration is at `/opt/cronmanager/agent/config/config.json`. -On the first deployment, the example configuration is placed there automatically. - -**Minimum required changes:** - -```json -{ - "agent": { - "bind_address": "0.0.0.0", - "port": 8865, - "hmac_secret": "" - }, - "database": { - "host": "127.0.0.1", - "port": 3306, - "name": "cronmanager", - "user": "cronmanager", - "password": "" - } -} -``` - -Generate a strong HMAC secret: - -```bash -openssl rand -hex 32 -``` - -> The same `hmac_secret` value must appear in both the agent config and the web app config. - ---- - -### Step 4 – Start the host agent service - -The deployment script installs and starts the systemd service automatically. -You can manage it with standard systemd commands: - -```bash -# Check service status -sudo systemctl status cronmanager-agent - -# View live logs -sudo journalctl -u cronmanager-agent -f - -# Restart after a config change -sudo systemctl restart cronmanager-agent - -# Verify the agent is reachable (TLS with self-signed cert) -curl -k https://127.0.0.1:8865/health -# → {"status":"ok","timestamp":"2026-03-18T10:00:00+00:00"} -``` - ---- - -### Step 5 – Configure the web application - -The web configuration is at `/opt/cronmanager/www/conf/config.json`. -On the first deployment, the example configuration is placed there automatically. - -**Minimum required changes:** - -```json -{ - "database": { - "host": "cronmanager-db", - "port": 3306, - "name": "cronmanager", - "user": "cronmanager", - "password": "" - }, - "agent": { - "url": "https://host.docker.internal:8865", - "hmac_secret": "", - "timeout": 10, - "ssl_verify": false, - "ssl_ca_bundle": "" - } -} -``` - -> **Docker mode:** set `agent.url` to `https://cronmanager-agent:8865` instead. `deploy.sh --docker full` patches this automatically on the first deployment. - -`host.docker.internal` resolves to the Docker host from within the container and is -configured automatically via the `extra_hosts` entry in `docker/docker-compose.yml` (host-agent mode). In docker mode, use `cronmanager-agent` as the hostname instead — this is the Docker service name on the shared internal network. - -`ssl_verify: false` is correct for the default self-signed certificate. Set it to `true` and supply `ssl_ca_bundle` when using a certificate signed by a private CA. - --- -### Step 6 – Start the Docker stack - -**Option A – docker compose directly on the host:** - -```bash -# Host-agent mode: web + MariaDB only -cd /opt/cronmanager/www # place docker-compose.yml here, or use the file from docker/docker-compose.yml - -# Docker mode: agent + web + MariaDB -cd /opt/cronmanager/www # use docker/docker-compose-agent.yml +## Host-Agent Installation -export DB_NAME=cronmanager -export DB_USER=cronmanager -export DB_PASSWORD= -export DB_ROOT_PASSWORD= - -docker compose up -d -``` - -**Option B – Portainer:** - -1. Open Portainer → Stacks → Add Stack -2. Paste the contents of `docker-compose.yml` -3. Add the following environment variables: - - `DB_NAME` = `cronmanager` - - `DB_USER` = `cronmanager` - - `DB_PASSWORD` = your password - - `DB_ROOT_PASSWORD` = your root password -4. Deploy the stack - -**Apply the database schema** (first deployment only, once the MariaDB container is healthy): - -```bash -ssh myserver 'docker exec -i cronmanager-db mariadb \ - -u cronmanager -p cronmanager \ - < /opt/cronmanager/agent/sql/schema.sql' -``` - ---- - -### Step 7 – First login and initial setup - -Open `http://:8880/` in your browser. - -If no users exist in the database yet, you are automatically redirected to the -**Setup wizard**: - -1. Enter a username for the initial admin account -2. Enter and confirm a password (minimum 8 characters) -3. Click **Create admin account** - -You are then redirected to the login page. Log in with the credentials you just created. +For running the agent as a systemd service directly on the Docker host instead of as a +Docker container, see **[HOST-AGENT.md](HOST-AGENT.md)**. --- @@ -680,84 +353,6 @@ full group-mapping example (provider side + Cronmanager side), see **[SSO.md](SS --- -## Configuration Reference - -### Web application config - -| Key | Default | Description | -|---|---|---| -| `database.host` | `cronmanager-db` | MariaDB hostname (Docker service name) | -| `database.port` | `3306` | MariaDB port | -| `database.name` | `cronmanager` | Database name | -| `database.user` | `cronmanager` | Database user | -| `database.password` | | Database password | -| `agent.url` | `https://host.docker.internal:8865` (host-agent) / `https://cronmanager-agent:8865` (docker) | Host agent base URL | -| `agent.hmac_secret` | | Shared HMAC secret (must match agent) | -| `agent.timeout` | `10` | HTTP timeout in seconds | -| `agent.ssl_verify` | `false` | `false` = accept self-signed cert; `true` = require trusted CA; path string = use custom CA bundle | -| `agent.ssl_ca_bundle` | `""` | Path to a PEM CA bundle inside the container (used when `ssl_verify` is `true` with a private CA) | -| `logging.path` | `/var/www/log/cronmanager-web.log` | Log file path | -| `logging.level` | `info` | `debug`, `info`, `warning`, `error`, `critical` | -| `logging.max_days` | `30` | Log file retention in days | -| `session.lifetime` | `3600` | Session cookie max-age in seconds | -| `session.idle_timeout` | `3600` | Server-side idle expiry in seconds | -| `session.name` | `cronmanager_sess` | Session cookie name | -| `i18n.default_language` | `en` | Default language (`en` or `de`) | -| `auth.oidc_enabled` | `false` | Enable OIDC SSO | -| `auth.oidc_provider_url` | | OIDC provider base URL (with trailing slash) | -| `auth.oidc_client_id` | | OAuth 2.0 Client ID | -| `auth.oidc_client_secret` | | OAuth 2.0 Client Secret | -| `auth.oidc_redirect_uri` | | Callback URL (`https://your-domain/auth/callback`) | -| `auth.oidc_ssl_verify` | `true` | `true` = system CA, `false` = disable, or path to CA bundle | -| `auth.oidc_ssl_ca_bundle` | `""` | Path to custom PEM CA bundle (empty = system CA) | -| `auth.oidc_auto_provision` | `auto` | SSO provisioning mode: `auto`, `disabled`, or `group` | -| `auth.oidc_group_claim` | `groups` | OIDC claim containing the user's group list | -| `auth.oidc_group_admin` | `""` | Group name → `admin` role | -| `auth.oidc_group_operator` | `""` | Group name → `operator` role | -| `auth.oidc_group_viewer` | `""` | Group name → `viewer` role | -| `auth.oidc_default_role` | `""` | Fallback role for unmatched SSO users; empty = deny login | -| `mail.host` | `""` | SMTP hostname for web UI emails (invitations, password reset); empty = email features disabled | -| `mail.port` | `587` | SMTP port | -| `mail.username` | `""` | SMTP username | -| `mail.password` | `""` | SMTP password | -| `mail.from` | `""` | Sender address | -| `mail.from_name` | `Cronmanager` | Sender display name | -| `mail.encryption` | `tls` | `tls` (STARTTLS) or `ssl` (SMTPS) | - -### Agent config - -| Key | Default | Description | -|---|---|---| -| `agent.bind_address` | `0.0.0.0` | Bind address for the internal PHP server (nginx handles the external port) | -| `agent.port` | `8865` | External port (nginx TLS) | -| `agent.tls_enabled` | `true` | Written automatically from `AGENT_TLS_ENABLED`; read by `cron-wrapper.sh` to decide HTTP vs HTTPS | -| `agent.hmac_secret` | | Shared HMAC secret (must match web config) | -| `database.host` | `127.0.0.1` | MariaDB hostname | -| `database.port` | `3306` | MariaDB port | -| `database.name` | `cronmanager` | Database name | -| `database.user` | `cronmanager` | Database user | -| `database.password` | | Database password | -| `logging.path` | `/opt/cronmanager/agent/log/cronmanager-agent.log` | Log file path | -| `logging.level` | `info` | Log level | -| `logging.max_days` | `30` | Log file retention in days | -| `mail.enabled` | `false` | Enable email failure alerts | -| `mail.host` | | SMTP server hostname | -| `mail.port` | `587` | SMTP port | -| `mail.username` | | SMTP username | -| `mail.password` | | SMTP password | -| `mail.from` | | Sender address | -| `mail.from_name` | `Cronmanager` | Sender display name | -| `mail.to` | | Recipient address for alerts | -| `mail.encryption` | `tls` | `tls` (STARTTLS, port 587) or `ssl` (SMTPS, port 465) | -| `mail.smtp_timeout` | `15` | SMTP connection timeout in seconds | -| `telegram.enabled` | `false` | Enable Telegram failure alerts | -| `telegram.bot_token` | | Bot API token from @BotFather | -| `telegram.chat_id` | | Target chat, channel, or group ID | -| `telegram.timeout` | `15` | HTTP request timeout in seconds | -| `cron.wrapper_script` | `/opt/cronmanager/agent/bin/cron-wrapper.sh` | Wrapper script path | - ---- - ## Agent TLS All communication between the web container and the host agent is encrypted with TLS. @@ -784,7 +379,7 @@ volumes: ### Web container – certificate verification -Set `agent.ssl_verify` (or `AGENT_SSL_VERIFY`) on the web container: +Set `AGENT_SSL_VERIFY` on the web container: | Value | When to use | |---|---| @@ -792,13 +387,13 @@ Set `agent.ssl_verify` (or `AGENT_SSL_VERIFY`) on the web container: | `true` | Certificate from a public/trusted CA | | `/path/to/ca.pem` | Certificate from a private CA – provide the CA bundle path | -When using a custom CA bundle, also set `agent.ssl_ca_bundle` to the path of the PEM +When using a custom CA bundle, also set `AGENT_SSL_CA_BUNDLE` to the path of the PEM file **inside the web container**. ### Disabling TLS TLS can be disabled by setting `AGENT_TLS_ENABLED=false` on the agent container and -changing `agent.url` back to `http://` in the web config. This is only recommended +changing `AGENT_URL` to `http://` in the web container env. This is only recommended for isolated internal networks where encryption is provided at another layer. --- @@ -854,27 +449,8 @@ and replaced with managed wrapper-script entries. ## Reading the Crontab -### Host-agent mode - -The agent manages crontab files directly on the host for each configured Linux user: - -```bash -# View the crontab for a specific user -crontab -u -l - -# View the raw crontab file -cat /var/spool/cron/crontabs/ -``` - -Managed entries are prefixed with a `# Cronmanager:` comment line and call the wrapper script: -``` -# Cronmanager: My job id:42 -*/5 * * * * /opt/cronmanager/agent/bin/cron-wrapper.sh 42 local -``` - -### Docker mode - -In docker mode the agent runs inside the `cronmanager-agent` container and cron jobs run as `root` inside that container. The crontab is the container root user's crontab. +In Docker mode the agent runs inside the `cronmanager-agent` container and cron jobs run +as `root` inside that container. The crontab is the container root user's crontab. ```bash # View the crontab inside the agent container @@ -884,10 +460,12 @@ docker exec cronmanager-agent crontab -l docker exec cronmanager-agent cat /var/spool/cron/crontabs/root ``` -> **Note:** After migrating from host-agent to docker mode, use **Housekeeping → Crontab Sync** in the web UI to write all active jobs into the container's crontab. Without this step the container crontab will be empty and no jobs will execute. +> **Note:** After migrating from host-agent to docker mode, use **Settings → Crontab Sync** in the web UI to write all active jobs into the container's crontab. Without this step the container crontab will be empty and no jobs will execute. > **Linux user requirement:** In docker mode all jobs run as `root` inside the container. Ensure every job's **Linux user** is set to `root` before running Crontab Sync. +For host-agent mode crontab access, see **[HOST-AGENT.md](HOST-AGENT.md#reading-the-crontab)**. + --- ## Settings @@ -1106,32 +684,14 @@ details, see **[API.md](API.md)**. ## Updating -Deploy only changed files (configuration files are never overwritten): - ```bash -./deploy.sh --host-agent update # host-agent mode -# or: -./deploy.sh --docker update # docker mode -``` - -Restart the host agent to load code changes: - -```bash -sudo systemctl restart cronmanager-agent -``` - -In docker mode, restart the agent container instead: -```bash -docker restart cronmanager-agent +docker compose -f docker-compose-full.yml pull +docker compose -f docker-compose-full.yml up -d ``` -Apply database migrations when indicated in the release notes: +The agent container automatically applies any new SQL migrations on startup. -```bash -ssh myserver 'docker exec -i cronmanager-db mariadb \ - -u cronmanager -p cronmanager \ - < /opt/cronmanager/agent/sql/migrations/.sql' -``` +For host-agent installations, see **[HOST-AGENT.md](HOST-AGENT.md#updating)**. --- diff --git a/TECHNICAL.md b/TECHNICAL.md index 560d29d..0b869d3 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -9,9 +9,8 @@ see [README.md](README.md). ## Table of Contents 1. [System Overview](#system-overview) -2. [Directory Layout](#directory-layout) -3. [Technology Stack](#technology-stack) -4. [Host Agent](#host-agent) +2. [Technology Stack](#technology-stack) +3. [Host Agent](#host-agent) - [Entry point](#entry-point-agentphp) - [Bootstrap](#bootstrap) - [Router](#router) @@ -74,181 +73,6 @@ In host-agent mode the web container reaches the agent via `host.docker.internal --- -## Directory Layout - -``` -/opt/dev/cronmanager/ ← development root -├── composer.json ← shared PHP dependencies -├── deploy.sh ← deployment script -├── deploy.env[.example] ← deployment configuration -├── db.credentials[.example] ← database passwords (not in VCS) -├── .github/ -│ └── workflows/ -│ ├── docker-release.yml ← builds & pushes Docker Hub images on GitHub release -│ └── auto-patch-release.yml ← increments patch version and creates a GitHub release (triggered on base image rebuild) -├── docker/ -│ ├── docker-compose.yml ← host-agent mode (web + MariaDB) -│ ├── docker-compose-agent.yml ← docker mode (agent + web + MariaDB, file-mounted source) -│ ├── docker-compose-full.yml ← Docker Hub mode (self-contained images, named volumes) -│ ├── agent/ -│ │ ├── Dockerfile ← self-contained agent image -│ │ └── entrypoint.sh ← agent container entrypoint (generates config from env) -│ └── web/ -│ ├── Dockerfile ← self-contained web image -│ └── entrypoint.sh ← web container entrypoint (generates config from env) -├── README.md -├── TECHNICAL.md -│ -├── agent/ ← host agent source -│ ├── agent.php ← CLI entry point -│ ├── config/config.json ← agent configuration -│ ├── bin/ -│ │ ├── cron-wrapper.sh ← injected into every crontab entry -│ │ ├── send-notification.php ← background SMTP dispatcher (spawned by ExecutionFinishEndpoint) -│ │ ├── check-limits.php ← execution limit checker (runs every minute via /etc/cron.d) -│ │ ├── startup-cleanup.php ← one-shot orphan cleanup; called by start-agent.sh on every start -│ │ ├── resync-crontab.php ← CLI: rebuild all crontab entries from the database -│ │ ├── start-agent.sh ← manual start helper -│ │ └── create-admin.php ← CLI tool: create first admin -│ ├── sql/ -│ │ ├── schema.sql ← full schema (run on first deploy) -│ │ └── migrations/ ← incremental SQL migrations -│ │ ├── 001_…sql -│ │ ├── … -│ │ └── 006_maintenance_windows.sql -│ ├── systemd/ -│ │ └── cronmanager-agent.service -│ └── src/ -│ ├── Bootstrap.php -│ ├── Router.php -│ ├── Database/Connection.php -│ ├── Security/HmacValidator.php -│ ├── Cron/CrontabManager.php -│ ├── Notification/MailNotifier.php -│ ├── Ssh/SshConfigParser.php -│ ├── Util/ExitCodeMatcher.php -│ ├── Repository/MaintenanceWindowRepository.php -│ └── Endpoints/ -│ ├── CronListEndpoint.php -│ ├── CronGetEndpoint.php -│ ├── CronCreateEndpoint.php -│ ├── CronUpdateEndpoint.php -│ ├── CronDeleteEndpoint.php -│ ├── CronBulkStatusEndpoint.php -│ ├── CronBulkDeleteEndpoint.php -│ ├── CronBulkTagEndpoint.php -│ ├── CronUsersEndpoint.php -│ ├── CronUnmanagedEndpoint.php -│ ├── ExecutionStartEndpoint.php -│ ├── ExecutionFinishEndpoint.php -│ ├── ExecutionUpdatePidEndpoint.php -│ ├── ExecutionKillEndpoint.php -│ ├── TagListEndpoint.php -│ ├── TagCreateEndpoint.php -│ ├── TagDeleteEndpoint.php -│ ├── SshHostsEndpoint.php -│ ├── ImportSshTargetsEndpoint.php -│ ├── SshTestEndpoint.php -│ ├── HistoryEndpoint.php -│ ├── ExportEndpoint.php -│ ├── MonitorEndpoint.php -│ ├── MaintenanceCrontabResyncEndpoint.php -│ ├── MaintenanceStuckEndpoint.php -│ ├── MaintenanceResolveEndpoint.php -│ ├── MaintenanceDeleteExecutionEndpoint.php -│ ├── MaintenanceHistoryCleanupEndpoint.php -│ ├── MaintenanceWindowListEndpoint.php -│ ├── MaintenanceWindowGetEndpoint.php -│ ├── MaintenanceWindowCreateEndpoint.php -│ ├── MaintenanceWindowUpdateEndpoint.php -│ ├── MaintenanceWindowDeleteEndpoint.php -│ └── MaintenanceWindowConflictEndpoint.php -│ -└── web/ ← web application source - ├── index.php ← front controller - ├── config/config.json ← web configuration (deployed to conf/) - ├── assets/ - │ ├── css/ - │ │ └── brand.css ← custom CSS variables and component styles - │ └── js/ - │ ├── tailwind.min.js ← Tailwind CSS runtime (downloaded by deploy.sh) - │ ├── chart.min.js ← Chart.js 4 UMD build (downloaded by deploy.sh) - │ └── cm-fetch.js ← shared AJAX utilities (cmFetch, cmToast, cmPoll) - ├── lang/ - │ ├── en.php - │ └── de.php - ├── templates/ - │ ├── layout.php - │ ├── login.php - │ ├── setup.php - │ ├── dashboard.php - │ ├── timeline.php - │ ├── swimlane.php - │ ├── export.php - │ ├── error.php - │ ├── api_keys/ - │ │ ├── index.php ← list of own API keys with scope badges - │ │ ├── create.php ← key creation form (profile picker, scope checkboxes, expiry, IP list) - │ │ └── created.php ← one-time plain-text display of new key (copy button) - │ ├── cron/ - │ │ ├── list.php - │ │ ├── detail.php - │ │ ├── form.php - │ │ ├── import.php - │ │ └── monitor.php - │ ├── users/list.php - │ ├── maintenance/ - │ │ ├── list.php ← maintenance window list (per-target + _agent_) - │ │ └── form.php ← maintenance window create/edit form - │ └── housekeeping/ - │ └── index.php ← admin tools (crontab resync, stuck executions, etc.) - └── src/ - ├── Bootstrap.php - ├── Database/Connection.php - ├── Session/SessionManager.php - ├── I18n/Translator.php - ├── Http/ - │ ├── Router.php - │ ├── Request.php - │ └── Response.php - ├── Agent/HostAgentClient.php - ├── Api/ ← external REST API controllers (Bearer token auth) - │ ├── BaseApiController.php ← abstract base: jsonResponse(), errorResponse(), agentClient() - │ ├── JobsApiController.php ← scopes: jobs:read, jobs:write, jobs:execute - │ ├── ExportApiController.php ← scope: export:read - │ ├── MaintenanceApiController.php ← scopes: maintenance:read, maintenance:write - │ └── SettingsApiController.php ← scopes: settings:read, settings:write - ├── Auth/ - │ ├── ApiKey.php ← value object (id, userId, name, scopes, agentIds, …) - │ ├── ApiKeyMiddleware.php ← Bearer-token gate: token → hash → expiry → IP → scope - │ ├── ApiKeyRepository.php ← PDO CRUD for api_keys table - │ ├── LocalAuthProvider.php - │ ├── OidcAuthProvider.php - │ └── ScopeHelper.php ← allowedScopesForRole(), PROFILES constant - ├── Bootstrap/ - │ ├── AgentSchema.php ← CREATE TABLE IF NOT EXISTS agents + seed from config - │ └── ApiKeySchema.php ← CREATE TABLE IF NOT EXISTS api_keys (idempotent) - ├── Repository/ - │ ├── AgentRepository.php ← PDO CRUD for the agents table - │ └── UserPreferenceRepository.php - └── Controller/ - ├── BaseController.php - ├── AgentController.php ← agent CRUD + connectivity test + session switch - ├── ApiKeyController.php ← UI: list, create, delete own API keys - ├── AuthController.php - ├── SetupController.php - ├── DashboardController.php - ├── CronController.php - ├── TimelineController.php - ├── SwimlaneController.php - ├── ExportController.php - ├── UserController.php - ├── MaintenanceController.php - └── TargetController.php -``` - ---- - ## Technology Stack | Layer | Library / Tool | Version | @@ -323,13 +147,18 @@ the handler. Routes are matched in registration order. `src/Security/HmacValidator.php` validates the `X-Agent-Signature` header on every non-health request. -**Signature algorithm:** +**Signature algorithm (v4.3.0+):** ``` -signature = hmac_sha256(hmac_secret, METHOD + PATH + BODY) +signature = hmac_sha256(hmac_secret, METHOD + PATH + BODY + "\0" + userId + "\0" + username) ``` -No separator is added between the three parts. The result is a lowercase hex string. +`userId` and `username` are the acting web user's ID and login name, taken from the +`X-User-Id` / `X-User-Name` request headers (set by `HostAgentClient`). The cron-wrapper +has no user context and signs with `userId=0` and `username=""` (empty string). +`agent.php` must default to `''` (not `'system'`) for a missing `X-User-Name` header — +any other fallback causes all cron-wrapper requests to fail with HTTP 401. The result is a +lowercase hex string. The web application's `HostAgentClient` computes and attaches the signature before every request. The validator uses `hash_equals()` for constant-time comparison to prevent @@ -963,6 +792,26 @@ Return a paginated list of audit log entries. --- +### GET /stats + +Returns aggregated execution counts for the dashboard statistics widget. +Uses `idx_el_started_at` for efficient range scans — no full table scan. + +**Response:** +```json +{ + "executed_today": 613, + "failed_today": 8, + "executed_24h": 719, + "failed_24h": 28 +} +``` + +`today` = since midnight (server timezone); `24h` = rolling `NOW() − INTERVAL 24 HOUR`. +`failed_*` counts exclude exit codes `0` and `−4` (maintenance sentinels). + +--- + ### GET /history Paginated execution history. @@ -1301,19 +1150,22 @@ Controllers call `isJsonRequest()` before `render()` and return early with `json Currently used by `DashboardController::index()` (returns stats array) and `CronController::monitor()` (returns chart/KPI data). The JavaScript utility `cmFetch` in `cm-fetch.js` appends `?_json=1` automatically for AJAX callers. +Roles: `viewer` (read-only) ⊂ `operator` (manage jobs + maintenance) ⊂ `admin` (full). `api-only` accounts cannot log in to the web UI. + | Controller | Routes | Min role | |---|---|---| -| `AuthController` | `GET/POST /login`, `GET /logout`, `GET /auth/callback`, `GET /auth/oidc` | public | +| `AuthController` | `GET/POST /login`, `GET /logout`, `GET /auth/callback`, `GET /auth/oidc`, `GET/POST /password/reset`, `GET/POST /password/reset/{token}`, `GET/POST /invite/{token}` | public | | `SetupController` | `GET/POST /setup` | public | -| `DashboardController` | `GET /dashboard` | view | -| `CronController` | `GET /crons`, `POST /crons/bulk` (admin), `GET /crons/{id}`, `GET /crons/{id}/monitor`, `GET/POST /crons/new`, `GET/POST /crons/{id}/edit`, `POST /crons/{id}/delete`, `GET/POST /crons/import` | view/admin | -| `TimelineController` | `GET /timeline` | view | -| `SwimlaneController` | `GET /swimlane`, `GET /swimlane?debug=1` | view | -| `ExportController` | `GET /export`, `GET /export/download` | view | +| `DashboardController` | `GET /dashboard` | viewer | +| `CronController` | `GET /crons`, `POST /crons/bulk` (operator), `GET /crons/{id}`, `GET /crons/{id}/monitor`, `GET/POST /crons/new`, `GET/POST /crons/{id}/edit`, `POST /crons/{id}/delete`, `GET/POST /crons/import` | viewer/operator | +| `TimelineController` | `GET /timeline` | viewer | +| `SwimlaneController` | `GET /swimlane`, `GET /swimlane?debug=1` | viewer | +| `ExportController` | `GET /export`, `GET /export/download` | viewer | | `AuditController` | `GET /audit` | admin | -| `UserController` | `GET /users`, `POST /users/{id}/role`, `POST /users/{id}/delete` | admin | -| `AgentController` | `GET /settings/agents/create`, `POST /settings/agents`, `GET /settings/agents/{id}/edit`, `POST /settings/agents/{id}`, `POST /settings/agents/{id}/delete`, `POST /settings/agents/{id}/test` (JSON), `POST /agent/select` | admin (select: view) | -| `ApiKeyController` | `GET /api-keys`, `GET /api-keys/create`, `POST /api-keys`, `POST /api-keys/{id}/delete` | view (own keys only) | +| `UserController` | `GET /users`, `GET/POST /users/create`, `GET/POST /users/{id}/edit`, `POST /users/{id}/delete`, `POST /users/{id}/resend-invite` | admin | +| `ProfileController` | `GET/POST /profile` | viewer (own account only) | +| `AgentController` | `GET /settings/agents/create`, `POST /settings/agents`, `GET /settings/agents/{id}/edit`, `POST /settings/agents/{id}`, `POST /settings/agents/{id}/delete`, `POST /settings/agents/{id}/test` (JSON), `POST /agent/select` | admin (select: viewer) | +| `ApiKeyController` | `GET /api-keys`, `GET /api-keys/create`, `POST /api-keys`, `POST /api-keys/{id}/delete` | viewer (own keys only) | | `MaintenanceController` | `GET /settings`, `POST /settings/resync`, `POST /settings/executions/{id}/finish`, `POST /settings/executions/{id}/delete`, `POST /settings/executions/bulk`, `POST /settings/history/cleanup`, `POST /settings/once/cleanup`, `POST /settings/logs/prune`, `POST /settings/notification/test` | admin | | `TargetController` | `GET /maintenance`, `GET /maintenance/{target}/windows/new`, `POST /maintenance/{target}/windows`, `GET /maintenance/windows/{id}/edit`, `POST /maintenance/windows/{id}/edit`, `POST /maintenance/windows/{id}/delete`, `GET /maintenance/windows/conflict`, `POST /maintenance/ssh/test` | admin | @@ -1526,13 +1378,30 @@ On the **first start** after upgrading to v4.0.0, `AgentSchema::ensure()` checks | Column | Type | Notes | |---|---|---| -| `id` | INT UNSIGNED PK | | -| `username` | VARCHAR(128) UNIQUE | Email or chosen username | -| `password_hash` | VARCHAR(255) NULL | NULL for OIDC-only users | -| `role` | ENUM('view','admin') | Default: `view` | +| `id` | INT PK AUTO_INCREMENT | | +| `username` | VARCHAR(128) UNIQUE | Login name | +| `password_hash` | VARCHAR(255) NULL | bcrypt hash; NULL for OIDC-only / api-only accounts | +| `role` | ENUM('viewer','operator','admin','api-only') | `viewer` = read-only; `operator` = manage jobs + maintenance; `admin` = full; `api-only` = no web login | +| `active` | TINYINT(1) | `1` = active (default); `0` = deactivated (login blocked) | +| `email` | VARCHAR(255) NULL | Contact address for invitations and password-reset emails | +| `agent_ids` | JSON NULL | `null` = all agents; `[1,3]` = restricted to these agent IDs | | `oauth_sub` | VARCHAR(255) NULL UNIQUE | OIDC subject identifier | | `created_at` | DATETIME | | -| `cron_list_page_size` | SMALLINT UNSIGNED NULL | Per-user preference; NULL = default | +| `cron_list_page_size` | SMALLINT UNSIGNED NULL | Per-user preference; `0` = show all; NULL = default (25) | + +### `auth_tokens` + +One-time tokens for invitation links and password-reset flows. + +| Column | Type | Notes | +|---|---|---| +| `id` | INT PK AUTO_INCREMENT | | +| `user_id` | INT FK → `users.id` | CASCADE DELETE | +| `token_hash` | VARCHAR(64) UNIQUE | `sha256(plain_token)` — plain text is never stored | +| `type` | ENUM('invite','reset') | `invite` = first-login invitation; `reset` = password reset | +| `expires_at` | DATETIME | Token expiry (default: 72 h after creation) | +| `used_at` | DATETIME NULL | Set when the token is consumed; NULL = not yet used | +| `created_at` | DATETIME | | ### `cronjobs` @@ -1545,7 +1414,12 @@ On the **first start** after upgrading to v4.0.0, `AgentSchema::ensure()` checks | `description` | VARCHAR(255) | Human-readable name | | `active` | TINYINT(1) | `1` = enabled | | `notify_on_failure` | TINYINT(1) | Send email/Telegram on failure or limit exceeded | -| `notify_on_recovery` | TINYINT(1) | Send notification when job recovers after a notified failure streak; DEFAULT 0 (opt-in) | +| `notify_on_recovery` | TINYINT(1) | Send recovery notification after a notified failure streak; DEFAULT 0 (opt-in) | +| `notify_on_silence` | TINYINT(1) | Alert when the job has not started within its expected schedule window; DEFAULT 0 (opt-in) | +| `silence_grace_minutes` | INT UNSIGNED NULL | Per-job override for global `silence.grace_minutes`; NULL = use global default | +| `last_silence_alert_at` | DATETIME NULL | Timestamp of last silence alert; reset on job start; dedup max once per hour | +| `last_execution_id` | INT NULL | ID of the most recent `execution_log` row for this job; maintained by `ExecutionStartEndpoint` | +| `last_finished_execution_id` | INT NULL | ID of the most recent finished `execution_log` row; maintained by `ExecutionFinishEndpoint` with monotonic guard | | `execution_limit_seconds` | INT UNSIGNED NULL | Maximum allowed runtime; NULL = no limit | | `auto_kill_on_limit` | TINYINT(1) | `1` = auto-kill when limit is exceeded | | `singleton` | TINYINT(1) | `1` = skip new execution if a previous instance is still running | @@ -1637,7 +1511,7 @@ The table is bootstrapped by `ApiKeySchema::ensure()` using `CREATE TABLE IF NOT ### `audit_log` -Created by migration `011_audit_log.sql`. +Created by migration `013_audit_log.sql`. | Column | Type | Notes | |---|---|---| @@ -1675,14 +1549,15 @@ Every request from the web container to the host agent carries: X-Agent-Signature: ``` -Where `` is: +Where `` is (v4.3.0+): ``` -HMAC-SHA256(hmac_secret, HTTP_METHOD + REQUEST_PATH + REQUEST_BODY) +HMAC-SHA256(hmac_secret, HTTP_METHOD + REQUEST_PATH + REQUEST_BODY + "\0" + userId + "\0" + username) ``` -The parts are concatenated without separators. An attacker who intercepts a request -cannot forge a different method, path, or body without knowing the secret. +`userId` and `username` identify the acting web user (cron-wrapper uses `0` and `""`). +An attacker who intercepts a request cannot forge a different method, path, body, or +actor without knowing the secret. Validation uses `hash_equals()` to prevent timing attacks. The `hmac_secret` is stored only in the two config files (agent and web). @@ -2012,7 +1887,12 @@ only included when `debug=1` is present; normal requests are unaffected. ## Configuration Reference -For the full list of available options see [README.md – Configuration Reference](README.md#configuration-reference). +In **Docker Hub mode** (recommended), all settings are supplied as environment variables +and `config.json` is generated by the container entrypoint on every start — no manual +editing required. For the full env-var reference see [README.md – Environment variables reference](README.md#environment-variables-reference). + +In **host-agent mode**, both the agent and the web app use a hand-edited `config.json`. +For the key reference and deployment steps see [HOST-AGENT.md – Configuration Reference](HOST-AGENT.md#configuration-reference). Config is loaded via `Noodlehaus\Config::load('/path/to/config.json')`. Use dot-notation to access nested keys: @@ -2026,17 +1906,15 @@ $config->get('logging.max_days', 30); // with default ## Deployment Script -`deploy.sh` reads `deploy.env` and `db.credentials` and supports two transport modes, -two target modes, and four deployment modes. +`deploy.sh` is a **host-agent deployment tool** — it deploys the PHP agent as a systemd +service on the Docker host. For the Docker Hub installation (recommended), no deployment +script is needed; see [README.md](README.md#docker-hub--recommended-installation). -### Required argument: target mode +`deploy.sh` reads `deploy.env` and `db.credentials`. -One of these flags is **required** on every invocation: +### Required argument -| Flag | Behaviour | -|---|---| -| `--host-agent` | Agent runs as a systemd service on the host; `deploy.sh` installs/restarts the service | -| `--docker` | Agent runs as a Docker container; systemd steps are skipped; on first deploy, configs are automatically patched with Docker service names | +`--host-agent` is the only supported target mode. ### Transport modes (set in `deploy.env`) @@ -2049,10 +1927,10 @@ One of these flags is **required** on every invocation: | Argument | Behaviour | |---|---| -| `full` | Creates directories + full rsync mirror (`--delete`); deploys example configs if absent; patches config values based on target mode | +| `full` | Creates directories + full rsync mirror (`--delete`); deploys example configs if absent | | `update` | rsync with `--checksum` (only changed files); config files are never overwritten | -| `migrate` | Migrates a running host-agent installation to docker mode: deploys changed files, stops and disables the systemd service, removes managed crontab entries from all host users, patches `database.host` and `agent.url` in both config files | -| `undeploy` | `--host-agent` only: stops and removes the systemd service; PHP files and config are kept on the target | +| `migrate` | Migrates a host-agent installation to Docker mode: stops and disables the systemd service, removes managed crontab entries from all host users, patches `database.host` and `agent.url` in both config files | +| `undeploy` | Stops and removes the systemd service; PHP files and config are kept on the target | ### Fixed deployment paths @@ -2070,19 +1948,10 @@ All paths are hardcoded — not configurable in `deploy.env`: ```bash ./deploy.sh --host-agent update --agent # deploy only the host agent -./deploy.sh --docker update --web # deploy only the web application +./deploy.sh --host-agent update --web # deploy only the web application ./deploy.sh --host-agent full # deploy both (default) ``` -### Config auto-patching on first deploy - -When `deploy.sh` deploys the example config files for the first time (i.e., no config exists on the target yet), it automatically patches mode-specific values: - -| Config file | Key | `--host-agent` | `--docker` | -|---|---|---|---| -| Agent `config.json` | `database.host` | `127.0.0.1` | `cronmanager-db` | -| Web `config.json` | `agent.url` | `http://host.docker.internal:8865` | `http://cronmanager-agent:8865` | - ### Composer handling On every deployment, `deploy.sh` checks whether `${DEPLOY_COMPOSER}/composer.json` @@ -2095,15 +1964,15 @@ exists on the target: ### Static asset downloads -During deployment `deploy.sh` automatically downloads static assets that are not -checked into the repository: +`deploy.sh` downloads one static asset that is not version-controlled: | Asset | Target path | Condition | |---|---|---| -| Tailwind CSS | `assets/js/tailwind.min.js` | Downloaded if absent | | Chart.js 4 UMD | `assets/js/chart.min.js` | Downloaded if absent | -Both files are excluded from `rsync --delete` so re-deployments never remove them. +The Tailwind CSS stylesheet (`assets/css/tailwind.css`) is a **pre-built, purged file +committed to the repository** — it is deployed via rsync like any other source file. +No download or build step is required. ### SSH host override @@ -2342,10 +2211,10 @@ ssh myserver 'docker exec -i cronmanager-db mariadb \ **Migration tracking via `schema_migrations`:** Every applied migration is recorded in the `schema_migrations` table (filename + timestamp). -Both the Docker entrypoint and `simple_debian_setup.sh` check this table before applying a file, -so re-running the installer on an existing deployment is always safe. On a fresh install, all -bundled migration filenames are seeded into the table immediately after `schema.sql` is applied, -since all their changes are already included in the baseline schema. +The Docker entrypoint checks this table before applying a file, so re-running the container +on an existing deployment is always safe. On a fresh install, all bundled migration filenames +are seeded into the table immediately after `schema.sql` is applied, since all their changes +are already included in the baseline schema. **Existing migrations:** @@ -2362,6 +2231,14 @@ since all their changes are already included in the baseline schema. | `009_notify_after_limit_exceeded.sql` | Added `notify_after_limit_exceeded` column to `cronjobs` | | `010_notify_on_recovery.sql` | Added `notify_on_recovery` column to `cronjobs` | | `011_restart_on_exitcodes.sql` | Added `restart_on_exitcodes` column to `cronjobs` for per-job exit-code filter on auto-retry | +| `012_agent_settings.sql` | Added `agent_settings` table for persistent per-agent notification/integration config (mail, Telegram, InfluxDB) | +| `013_audit_log.sql` | Added `audit_log` table; all write operations are recorded with actor, resource, and diff/snapshot | +| `014_performance_log.sql` | Added `performance_log` table for optional per-request timing data (Performance Monitor feature) | +| `015_execution_log_indexes.sql` | Added `idx_el_started_at`, `idx_el_job_finished`, and related indexes to `execution_log` for COUNT and history query performance | +| `016_silence_detection.sql` | Added `notify_on_silence`, `silence_grace_minutes`, `last_silence_alert_at` columns to `cronjobs` | +| `017_performance_indexes.sql` | Added composite indexes for `CronListEndpoint` and `HistoryEndpoint`; repairs `schema.sql` to include objects from migrations 011, 012, and 016 | +| `018_last_execution_refs.sql` | Added `last_execution_id` and `last_finished_execution_id` to `cronjobs` (denormalised pointers for O(1) last-run lookups; replaces the previous `MAX(id) GROUP BY` aggregate) | +| `019_user_management_v2.sql` | Added `active`, `email`, `agent_ids` to `users`; changed `role` ENUM to `('viewer','operator','admin','api-only')`; added `auth_tokens` table for invitation and password-reset tokens | Always apply migrations in order. The full schema in `agent/sql/schema.sql` reflects the current state after all migrations and is used for fresh installations. diff --git a/agent/VERSION b/agent/VERSION index 831446c..7cbea07 100644 --- a/agent/VERSION +++ b/agent/VERSION @@ -1 +1 @@ -5.1.0 +5.2.0 \ No newline at end of file diff --git a/agent/agent.php b/agent/agent.php index 70a2244..296e7e5 100644 --- a/agent/agent.php +++ b/agent/agent.php @@ -332,6 +332,11 @@ function jsonResponse(int $statusCode, array $data): void $history = new \Cronmanager\Agent\Endpoints\HistoryEndpoint($pdo, $logger); $router->addRoute('GET', '/history', [$history, 'handle']); + // -- Stats (dashboard widget) --------------------------------------------- + + $stats = new \Cronmanager\Agent\Endpoints\StatsEndpoint($pdo, $logger); + $router->addRoute('GET', '/stats', [$stats, 'handle']); + // -- Audit log ------------------------------------------------------------ $auditLog = new \Cronmanager\Agent\Endpoints\AuditLogEndpoint($pdo, $logger); diff --git a/agent/src/Endpoints/CronListEndpoint.php b/agent/src/Endpoints/CronListEndpoint.php index ccd66c3..b04a488 100644 --- a/agent/src/Endpoints/CronListEndpoint.php +++ b/agent/src/Endpoints/CronListEndpoint.php @@ -258,6 +258,7 @@ private function fetchJobs(?string $userFilter, ?string $tagFilter, ?string $tar GROUP_CONCAT(DISTINCT t.name ORDER BY t.name SEPARATOR ',') AS tags, GROUP_CONCAT(DISTINCT jt.target ORDER BY jt.target SEPARATOR ',') AS targets, el_last.started_at AS last_run, + el_last.finished_at AS last_finished_at, el_last_fin.exit_code AS last_exit_code FROM cronjobs j LEFT JOIN cronjob_tags ct ON ct.cronjob_id = j.id @@ -370,6 +371,8 @@ private function normaliseRow(array $row): array : null, 'last_run' => isset($row['last_run']) && $row['last_run'] !== null ? (string) $row['last_run'] : null, 'last_exit_code' => isset($row['last_exit_code']) && $row['last_exit_code'] !== null ? (int) $row['last_exit_code'] : null, + 'is_running' => isset($row['last_run']) && $row['last_run'] !== null + && (!isset($row['last_finished_at']) || $row['last_finished_at'] === null), ]; } } diff --git a/agent/src/Endpoints/StatsEndpoint.php b/agent/src/Endpoints/StatsEndpoint.php new file mode 100644 index 0000000..4f4b0f5 --- /dev/null +++ b/agent/src/Endpoints/StatsEndpoint.php @@ -0,0 +1,94 @@ + + * @license GNU General Public License version 3 or later + */ + +namespace Cronmanager\Agent\Endpoints; + +use Monolog\Logger; +use PDO; + +/** + * Class StatsEndpoint + * + * Provides aggregate execution statistics for the dashboard widget. + * Two queries, both resolved via idx_el_started_at (index range scan). + */ +final class StatsEndpoint +{ + public function __construct( + private readonly PDO $pdo, + private readonly Logger $logger, + ) {} + + /** + * Handle GET /stats. + * + * @param array $params Unused path parameters. + * + * @return void + */ + public function handle(array $params): void + { + $this->logger->debug('StatsEndpoint: handling GET /stats'); + + try { + // Today: started_at >= CURDATE() uses idx_el_started_at (range scan) + $stmtToday = $this->pdo->query( + "SELECT + COUNT(*) AS total, + COALESCE(SUM(CASE WHEN exit_code NOT IN (0, -4) THEN 1 END), 0) AS failed + FROM execution_log + WHERE started_at >= CURDATE()" + ); + /** @var array{total:string,failed:string}|false $today */ + $today = $stmtToday !== false ? $stmtToday->fetch(PDO::FETCH_ASSOC) : false; + + // Last 24 h: different cut-off, same index + $stmt24h = $this->pdo->query( + "SELECT + COUNT(*) AS total, + COALESCE(SUM(CASE WHEN exit_code NOT IN (0, -4) THEN 1 END), 0) AS failed + FROM execution_log + WHERE started_at >= NOW() - INTERVAL 24 HOUR" + ); + /** @var array{total:string,failed:string}|false $last24h */ + $last24h = $stmt24h !== false ? $stmt24h->fetch(PDO::FETCH_ASSOC) : false; + } catch (\Throwable $e) { + $this->logger->error('StatsEndpoint: query failed', ['message' => $e->getMessage()]); + jsonResponse(500, ['error' => 'Internal Server Error', 'message' => 'Stats query failed.', 'code' => 500]); + return; + } + + jsonResponse(200, [ + 'executed_today' => $today !== false ? (int) $today['total'] : 0, + 'failed_today' => $today !== false ? (int) $today['failed'] : 0, + 'executed_24h' => $last24h !== false ? (int) $last24h['total'] : 0, + 'failed_24h' => $last24h !== false ? (int) $last24h['failed'] : 0, + ]); + } +} diff --git a/deploy.sh b/deploy.sh index eecd898..bf6aabb 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,22 +1,23 @@ #!/bin/bash # ============================================================================= -# Cronmanager – Deployment Script +# Cronmanager – Host-Agent Deployment Script +# +# Deploys the host-agent installation (PHP agent as systemd service on the +# Docker host). For the recommended Docker-only installation, use the +# pre-built images from Docker Hub (see README.md). # # Configuration is read from deploy.env in the same directory. # Database credentials are read from db.credentials in the same directory. # # Usage: -# ./deploy.sh <--host-agent|--docker> [full|update|migrate] [ssh-host] [--agent|--web] -# -# --host-agent – Target is a host-agent installation (installs/restarts systemd service) -# --docker – Target is a docker-agent installation (skips systemd; use docker-compose) +# ./deploy.sh --host-agent [full|update|migrate|undeploy] [ssh-host] [--agent|--web] # # full – Create folder structure + deploy all files (default) # update – Deploy only changed files (rsync checksum comparison) # migrate – Migrate host-agent installation → docker-agent: # deploys changed files, stops+disables systemd service, # patches agent and web config.json for docker-mode values. -# undeploy – Remove systemd service only (--host-agent only); +# undeploy – Remove systemd service only; # PHP files and config are kept on the target system. # --agent – Deploy agent only (skip web app) # --web – Deploy web app only (skip agent) @@ -78,7 +79,7 @@ fi # --------------------------------------------------------------------------- DEPLOY_MODE="full" # full = mirror; update = changed files only -DEPLOY_TARGET="" # host-agent or docker (required) +DEPLOY_TARGET="" # must be host-agent DEPLOY_AGENT_PART=true # deploy the host agent? DEPLOY_WEB_PART=true # deploy the web application? SSH_HOST="${DEPLOY_SSH:-}" # may be overridden by positional CLI arg @@ -88,9 +89,6 @@ for arg in "$@"; do --host-agent) DEPLOY_TARGET="host-agent" ;; - --docker) - DEPLOY_TARGET="docker" - ;; full|update|migrate|undeploy) DEPLOY_MODE="${arg}" ;; @@ -104,7 +102,7 @@ for arg in "$@"; do ;; -*) echo "[deploy] ERROR: Unknown option '${arg}'." >&2 - echo "[deploy] Usage: ./deploy.sh <--host-agent|--docker> [full|update|migrate] [ssh-host] [--agent|--web]" >&2 + echo "[deploy] Usage: ./deploy.sh --host-agent [full|update|migrate|undeploy] [ssh-host] [--agent|--web]" >&2 exit 1 ;; *) @@ -115,8 +113,8 @@ for arg in "$@"; do done if [[ -z "${DEPLOY_TARGET}" ]]; then - echo "[deploy] ERROR: --host-agent or --docker is required." >&2 - echo "[deploy] Usage: ./deploy.sh <--host-agent|--docker> [full|update|migrate] [ssh-host] [--agent|--web]" >&2 + echo "[deploy] ERROR: --host-agent is required." >&2 + echo "[deploy] Usage: ./deploy.sh --host-agent [full|update|migrate|undeploy] [ssh-host] [--agent|--web]" >&2 exit 1 fi @@ -248,7 +246,6 @@ fi log "============================================================" log " Deploy mode : ${DEPLOY_MODE}" -log " Deploy target: ${DEPLOY_TARGET}" log " Transport : ${DEPLOY_TYPE}" if [[ "${DEPLOY_TYPE}" == "SSH" ]]; then log " SSH host : ${SSH_HOST}" @@ -428,21 +425,6 @@ if [[ "${DEPLOY_AGENT_PART}" == "true" ]]; then if ! file_exists_on_target "${AGENT_TARGET}/config/config.json" 2>/dev/null; then log "No existing agent config – deploying example config..." copy_to_target "${AGENT_SRC}/config/config.json" "${AGENT_TARGET}/config/config.json" - # Patch database.host for docker mode (container reaches DB via service name) - if [[ "${DEPLOY_TARGET}" == "docker" ]]; then - log "Docker mode: patching agent config database.host → cronmanager-db..." - _run_remote_python \ -'import json, sys -path = sys.argv[1] -with open(path, "r") as fh: - cfg = json.load(fh) -if isinstance(cfg.get("database"), dict): - cfg["database"]["host"] = "cronmanager-db" -with open(path, "w") as fh: - json.dump(cfg, fh, indent=4, ensure_ascii=False) -print("[deploy] Agent config database.host patched.")' \ - "${AGENT_TARGET}/config/config.json" - fi else log "Agent config already exists – skipping (update manually if needed)." fi @@ -469,7 +451,7 @@ print("[deploy] Agent config database.host patched.")' \ # Install / reload systemd service (skipped in migrate mode) # ------------------------------------------------------------------------- - if [[ "${DEPLOY_TARGET}" == "host-agent" && "${DEPLOY_MODE}" != "migrate" ]]; then + if [[ "${DEPLOY_MODE}" != "migrate" ]]; then log "Installing systemd service..." run_on_target "cp '${AGENT_TARGET}/systemd/cronmanager-agent.service' /etc/systemd/system/cronmanager-agent.service" run_on_target "systemctl daemon-reload" @@ -479,10 +461,8 @@ print("[deploy] Agent config database.host patched.")' \ run_on_target "systemctl is-active --quiet cronmanager-agent \ && echo '[deploy] Service is running.' \ || echo '[deploy] WARNING: Service is NOT running – check: journalctl -u cronmanager-agent'" - elif [[ "${DEPLOY_MODE}" == "migrate" ]]; then - log "Migrate mode: skipping systemd service install (will be stopped in migration step)." else - log "Docker mode: skipping systemd service install." + log "Migrate mode: skipping systemd service install (will be stopped in migration step)." fi # ------------------------------------------------------------------------- @@ -524,54 +504,6 @@ EOF || log "WARNING: Schema application failed – container may not be running yet. Apply manually." fi - # ── Database migrations ─────────────────────────────────────────────────── - # Applies pending SQL migrations from agent/sql/migrations/ in sorted order. - # Each migration is recorded in schema_migrations so it runs at most once. - # Runs on both full and update deploys (safe to re-run; schema.sql already - # contains the full column set, so migrations are no-ops on fresh installs - # once the tracking table catches up). - if [[ "${DEPLOY_TARGET}" == "docker" ]]; then - log "Checking pending database migrations..." - - run_on_target "docker exec -i cronmanager-db mariadb \ - -u '${DB_USER}' -p'${DB_PASSWORD}' '${DB_NAME}' \ - -e \"CREATE TABLE IF NOT EXISTS schema_migrations ( - migration VARCHAR(255) NOT NULL PRIMARY KEY, - applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\"" 2>/dev/null \ - && log "Migrations tracking table ready." \ - || log "WARNING: Could not create schema_migrations table – DB may not be running yet." - - _mig_dir="${AGENT_TARGET}/sql/migrations" - _mig_list=$(run_on_target "ls '${_mig_dir}/'*.sql 2>/dev/null | sort" 2>/dev/null || true) - - if [[ -z "${_mig_list}" ]]; then - log "No migration files found in ${_mig_dir}." - else - while IFS= read -r _mig_path; do - [[ -z "${_mig_path}" ]] && continue - _mig_name=$(basename "${_mig_path}") - _applied=$(run_on_target "docker exec -i cronmanager-db mariadb \ - -u '${DB_USER}' -p'${DB_PASSWORD}' '${DB_NAME}' \ - -sNe \"SELECT COUNT(*) FROM schema_migrations WHERE migration='${_mig_name}'\"" 2>/dev/null || echo "0") - if [[ "${_applied}" == "0" ]]; then - log "Applying migration: ${_mig_name}..." - if run_on_target "docker exec -i cronmanager-db mariadb \ - -u '${DB_USER}' -p'${DB_PASSWORD}' '${DB_NAME}' \ - < '${_mig_path}'" 2>/dev/null; then - run_on_target "docker exec -i cronmanager-db mariadb \ - -u '${DB_USER}' -p'${DB_PASSWORD}' '${DB_NAME}' \ - -e \"INSERT IGNORE INTO schema_migrations (migration) VALUES ('${_mig_name}')\"" 2>/dev/null || true - log "Migration applied: ${_mig_name}" - else - log "WARNING: Migration failed: ${_mig_name} – apply manually if needed." - fi - else - log "Migration already applied: ${_mig_name}" - fi - done <<< "${_mig_list}" - fi - fi fi # --------------------------------------------------------------------------- @@ -593,63 +525,11 @@ if [[ "${DEPLOY_WEB_PART}" == "true" ]]; then "${WEB_SRC}/" "${TARGET_PREFIX}${WEB_WWW_TARGET}/" fi - # ── TLS migration: upgrade pre-2.8.0 http:// agent URL in existing config ── - # Runs on every update deploy. Idempotent: no-op when already on https or - # when ssl_verify key is already present (written by 2.8.0+ entrypoint). - if [[ "${DEPLOY_TARGET}" == "docker" && "${DEPLOY_MODE}" == "update" ]]; then - if run_on_target "test -f '${WEB_CONF_TARGET}/config.json'" 2>/dev/null; then - log "Checking web config for pre-2.8.0 http:// agent URL..." - _run_remote_python \ -'import json, sys -path = sys.argv[1] -with open(path, "r") as fh: - cfg = json.load(fh) -agent = cfg.get("agent", {}) -url = agent.get("url", "") -changed = False -if url.startswith("http://"): - agent["url"] = "https://" + url[len("http://"):] - changed = True -if "ssl_verify" not in agent: - agent["ssl_verify"] = False - agent["ssl_ca_bundle"] = agent.get("ssl_ca_bundle", "") - changed = True -if changed: - cfg["agent"] = agent - with open(path, "w") as fh: - json.dump(cfg, fh, indent=4, ensure_ascii=False) - print("[deploy] Web config migrated: agent.url upgraded to https://, ssl_verify added.") -else: - print("[deploy] Web config already up to date – no migration needed.")' \ - "${WEB_CONF_TARGET}/config.json" - fi - fi - # Deploy example web config only on full deploy if none exists yet if [[ "${DEPLOY_MODE}" == "full" ]]; then if ! file_exists_on_target "${WEB_CONF_TARGET}/config.json" 2>/dev/null; then log "No existing web config – deploying example config..." copy_to_target "${WEB_SRC}/config/config.json" "${WEB_CONF_TARGET}/config.json" - # Patch agent.url for docker mode (web → agent via Docker service name) - if [[ "${DEPLOY_TARGET}" == "docker" ]]; then - log "Docker mode: patching web config agent.url → https://cronmanager-agent:8865..." - _run_remote_python \ -'import json, re, sys -path = sys.argv[1] -with open(path, "r") as fh: - cfg = json.load(fh) -if isinstance(cfg.get("agent"), dict) and "url" in cfg["agent"]: - url = cfg["agent"]["url"] - m = re.search(r":(\d+)", url) - port = m.group(1) if m else "8865" - cfg["agent"]["url"] = "https://cronmanager-agent:" + port - cfg["agent"]["ssl_verify"] = False - cfg["agent"]["ssl_ca_bundle"] = "" -with open(path, "w") as fh: - json.dump(cfg, fh, indent=4, ensure_ascii=False) -print("[deploy] Web config agent.url patched.")' \ - "${WEB_CONF_TARGET}/config.json" - fi else log "Web config already exists – skipping." fi @@ -829,17 +709,6 @@ if [[ "${DEPLOY_AGENT_PART}" == "true" && "${DEPLOY_WEB_PART}" == "true" ]]; the log "Next steps:" log " 1. Review and adjust ${AGENT_TARGET}/config/config.json" log " 2. Review and adjust ${WEB_CONF_TARGET}/config.json" -if [[ "${DEPLOY_TARGET}" == "docker" ]]; then -log " 3. Deploy Docker stack via Portainer:" -log " - Paste the contents of docker/docker-compose-agent.yml into a new Portainer stack" -log " - Add the following environment variables in Portainer:" -log " DB_NAME = ${DB_NAME}" -log " DB_USER = ${DB_USER}" -log " DB_PASSWORD = (your password)" -log " DB_ROOT_PASSWORD = (your root password)" -log " 4. Apply DB schema: docker exec -i cronmanager-db mariadb -u ${DB_USER} -p${DB_PASSWORD} ${DB_NAME} < ${AGENT_TARGET}/sql/schema.sql" -log " 5. Test agent health: docker exec cronmanager-agent curl -s http://localhost:8865/health" -else log " 3. Deploy Docker stack via Portainer:" log " - Paste the contents of docker/docker-compose.yml into a new Portainer stack" log " - Add the following environment variables in Portainer:" @@ -848,7 +717,6 @@ log " DB_USER = ${DB_USER}" log " DB_PASSWORD = (your password)" log " DB_ROOT_PASSWORD = (your root password)" log " 4. Apply DB schema: docker exec -i cronmanager-db mariadb -u ${DB_USER} -p${DB_PASSWORD} ${DB_NAME} < ${AGENT_TARGET}/sql/schema.sql" -log " 5. Test agent health: curl http://localhost:8865/health" -fi +log " 5. Test agent health: curl https://localhost:8865/health" log " 6. Open web UI: http://:8880/ (first visit creates the admin account)" fi diff --git a/simple_debian_setup.sh b/simple_debian_setup.sh deleted file mode 100755 index 640058f..0000000 --- a/simple_debian_setup.sh +++ /dev/null @@ -1,1204 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# Cronmanager – Simple Debian Setup Script -# -# Guides through a complete installation of Cronmanager on a local or -# remote Debian / Ubuntu based host. Covers: -# • Prerequisite check and installation on the target host -# • Repository clone -# • Composer / PHP library setup on the target host -# • Host agent deployment and systemd service -# • Web application deployment (Docker + MariaDB) -# • Optional OIDC / SSO integration -# • Optional email failure notifications -# -# Run this script from any machine that can reach the target host via SSH. -# For local installation: answer "local" when asked for the target. -# -# @author Christian Schulz -# @license GNU General Public License version 3 or later -# ============================================================================= - -set -uo pipefail - -SCRIPT_VERSION="2.0.0" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# ── Colours ────────────────────────────────────────────────────────────────── -# Use $'...' ANSI-C quoting so the actual ESC byte (0x1b) is stored in the -# variable – this makes the codes work correctly in both echo and read -p. -if [[ -t 1 ]]; then - RED=$'\033[0;31m' - GREEN=$'\033[0;32m' - YELLOW=$'\033[1;33m' - CYAN=$'\033[0;36m' - BLUE=$'\033[0;34m' - BOLD=$'\033[1m' - NC=$'\033[0m' -else - RED=''; GREEN=''; YELLOW=''; CYAN=''; BLUE=''; BOLD=''; NC='' -fi - -# ── Output helpers ──────────────────────────────────────────────────────────── -header() { - echo - echo -e "${BOLD}${CYAN}══════════════════════════════════════════════════${NC}" - echo -e "${BOLD}${CYAN} $*${NC}" - echo -e "${BOLD}${CYAN}══════════════════════════════════════════════════${NC}" - echo -} -step() { echo -e " ${BOLD}▶ $*${NC}"; } -info() { echo -e " ${BLUE}→${NC} $*"; } -ok() { echo -e " ${GREEN}✔${NC} $*"; } -warn() { echo -e " ${YELLOW}⚠${NC} $*"; } -error() { echo -e " ${RED}✘${NC} $*" >&2; } -die() { error "$*"; exit 1; } -# warn_continue: show an error and ask the user whether to continue or abort. -# Use this instead of die() for recoverable operational failures. -warn_continue() { - error "$*" - ask_yn _wc_cont "Continue despite this error?" "no" - [[ "$_wc_cont" == "no" ]] && exit 1 - return 0 -} -sep() { echo -e " ${CYAN}──────────────────────────────────────────────────${NC}"; } -blank() { echo; } - -# ── Interactive input helpers ───────────────────────────────────────────────── - -# All read calls use = 1 && _input <= ${#_opts[@]} )); then - printf -v "$_var" '%s' "${_opts[$((_input-1))]}" - _valid=true - else - warn "Please enter a number between 1 and ${#_opts[@]}." - fi - done -} - -# ── Target execution helpers ────────────────────────────────────────────────── -# All operations on the install target go through these functions so the -# rest of the script works identically for local and remote deployments. - -TARGET_TYPE="local" # "local" or "remote" -TARGET_SSH="" # SSH host alias (remote only) - -# Run a shell command string on the target -target_exec() { - if [[ "$TARGET_TYPE" == "local" ]]; then - bash -c "$1" - else - ssh "$TARGET_SSH" "$1" - fi -} - -# Pipe a multi-line bash script to the target for execution -target_script() { - if [[ "$TARGET_TYPE" == "local" ]]; then - bash -s - else - ssh "$TARGET_SSH" bash -s - fi -} - -# Write stdin to a file on the target (create / overwrite) -target_write() { - local _dst="$1" - if [[ "$TARGET_TYPE" == "local" ]]; then - cat > "$_dst" - else - ssh "$TARGET_SSH" "cat > $(printf '%q' "$_dst")" - fi -} - -# Copy a local path (file or directory) to a path on the target -target_copy() { - local _src="$1" _dst="$2" - if [[ "$TARGET_TYPE" == "local" ]]; then - rsync -a "$_src" "$_dst" - else - rsync -a -e "ssh" "$_src" "${TARGET_SSH}:${_dst}" - fi -} - -# ── Temp directory – cleaned up on exit ────────────────────────────────────── -CLONE_DIR="" -cleanup() { [[ -n "$CLONE_DIR" && -d "$CLONE_DIR" ]] && rm -rf "$CLONE_DIR"; } -interrupted() { echo; warn "Setup interrupted by user (CTRL-C). You can re-run the script at any time."; cleanup; exit 130; } -trap cleanup EXIT -trap interrupted INT TERM - -# ═════════════════════════════════════════════════════════════════════════════ -# 1. WELCOME BANNER -# ═════════════════════════════════════════════════════════════════════════════ - -clear -echo -e "${BOLD}${CYAN}" -echo " ╔══════════════════════════════════════════════════════════════╗" -echo " ║ Cronmanager – Simple Debian Setup ║" -echo " ║ Version ${SCRIPT_VERSION} ║" -echo " ╚══════════════════════════════════════════════════════════════╝" -echo -e "${NC}" -echo " This script installs Cronmanager on a local or remote host." -echo " Includes:" -echo " • PHP 8.4 CLI agent + systemd" -echo " • Web application + MariaDB (Docker)" -echo " • Optional: OIDC / SSO authentication" -echo " • Optional: Email failure notifications" -echo -echo -e " ${YELLOW}Target host requirements: Debian 12+ / Ubuntu 22.04+ • root access${NC}" -blank - -# ═════════════════════════════════════════════════════════════════════════════ -# 2. TARGET HOST SELECTION -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 1 – Target Host" - -echo " Where should Cronmanager be installed?" -blank -echo " 1) This machine (local)" -echo " 2) A remote server via SSH" -blank -read -r -p " ${BOLD}Target${NC} [1]: " _target_choice < /dev/tty -_target_choice="${_target_choice:-1}" - -if [[ "$_target_choice" == "2" ]]; then - TARGET_TYPE="remote" - blank - info "Enter a host alias from ~/.ssh/config or user@hostname." - ask TARGET_SSH "SSH host" "" - - step "Testing SSH connection to ${TARGET_SSH} ..." - if ssh -o ConnectTimeout=10 -o BatchMode=yes "$TARGET_SSH" "echo ok" &>/dev/null; then - ok "SSH connection to ${TARGET_SSH} successful." - else - die "Cannot connect to ${TARGET_SSH} via SSH. Check your SSH config and key authentication." - fi - - step "Checking root access on ${TARGET_SSH} ..." - REMOTE_USER=$(ssh "$TARGET_SSH" "id -un" 2>/dev/null || echo "") - if [[ "$REMOTE_USER" == "root" ]]; then - ok "Connected as root on ${TARGET_SSH}." - else - die "SSH connection is not as root (got: ${REMOTE_USER}). Use root@${TARGET_SSH} or configure SSH accordingly." - fi -else - TARGET_TYPE="local" - blank - [[ "$EUID" -ne 0 ]] && die "Local installation requires root. Please run: sudo bash $0" - ok "Installing locally on this machine." -fi - - -# ═════════════════════════════════════════════════════════════════════════════ -# 3. PREREQUISITES CHECK ON TARGET -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 2 – Prerequisites" - -if [[ "$TARGET_TYPE" == "remote" ]]; then - info "Checking required packages on ${TARGET_SSH}..." -else - info "Checking required packages on this machine..." -fi -blank - -MISSING_PKGS=() - -check_pkg() { - local _label="$1" _apt_pkg="$2" _bin="${3:-}" - local _check="dpkg -l '$_apt_pkg' 2>/dev/null | grep -q '^ii'" - [[ -n "$_bin" ]] && _check="{ ${_check}; } || command -v '$_bin' >/dev/null 2>&1" - if target_exec "$_check" 2>/dev/null; then - ok "${_label} – OK" - else - warn "${_label} – NOT installed (package: ${_apt_pkg})" - MISSING_PKGS+=("$_apt_pkg") - fi -} - -check_php_ext() { - local _ext="$1" _apt_pkg="$2" - if target_exec "php -m 2>/dev/null | grep -qi '^${_ext}\$'" 2>/dev/null; then - ok "PHP extension ${_ext} – OK" - else - warn "PHP extension ${_ext} – missing (package: ${_apt_pkg})" - MISSING_PKGS+=("$_apt_pkg") - fi -} - -check_pkg "PHP 8.4 CLI" "php8.4-cli" "php" -check_php_ext "pdo_mysql" "php8.4-mysql" -check_php_ext "mbstring" "php8.4-mbstring" -check_php_ext "curl" "php8.4-curl" -check_pkg "curl" "curl" "curl" -check_pkg "jq" "jq" "jq" -check_pkg "git" "git" "git" -check_pkg "openssl" "openssl" "openssl" -check_pkg "rsync" "rsync" "rsync" -check_pkg "unzip" "unzip" "unzip" -check_pkg "python3" "python3" "python3" - -if target_exec "command -v docker >/dev/null 2>&1" 2>/dev/null; then - ok "Docker – OK" -else - warn "Docker – NOT installed" - MISSING_PKGS+=("docker.io") -fi - -if target_exec "docker compose version >/dev/null 2>&1" 2>/dev/null; then - ok "docker compose (v2) – OK" -elif target_exec "command -v docker-compose >/dev/null 2>&1" 2>/dev/null; then - ok "docker-compose (v1) – OK (v2 recommended)" -else - warn "docker compose – NOT installed" - MISSING_PKGS+=("docker-compose-plugin") -fi - -blank -MISSING_PKGS=($(printf '%s\n' "${MISSING_PKGS[@]}" | sort -u)) - -if [[ ${#MISSING_PKGS[@]} -eq 0 ]]; then - ok "All prerequisites satisfied." -else - warn "The following packages need to be installed on the target:" - for _pkg in "${MISSING_PKGS[@]}"; do - echo -e " ${YELLOW}•${NC} $_pkg" - done - blank - ask_yn INSTALL_PKGS "Install missing packages now via apt?" "yes" - - if [[ "$INSTALL_PKGS" == "yes" ]]; then - step "Updating package index on target..." - target_exec "apt-get update -qq" || warn_continue "apt-get update failed." - - _pkgs=$(printf '%s ' "${MISSING_PKGS[@]}") - step "Installing: ${_pkgs}" - target_exec "DEBIAN_FRONTEND=noninteractive apt-get install -y ${_pkgs}" \ - || warn_continue "Package installation failed. Some packages may be missing." - ok "All packages installed." - - for _ext in pdo_mysql mbstring curl; do - target_exec "php -m 2>/dev/null | grep -qi '^${_ext}\$'" \ - || warn_continue "PHP extension ${_ext} still unavailable. Check your PHP setup." - done - else - die "Cannot continue without required packages." - fi -fi - -# ═════════════════════════════════════════════════════════════════════════════ -# 4. CLONE REPOSITORY (always local – files are then copied to target) -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 3 – Clone Repository" - -DETECTED_URL=$(git -C "$SCRIPT_DIR" remote get-url origin 2>/dev/null || true) -ask REPO_URL "Repository URL" "${DETECTED_URL:-https://github.com/csoscd/cronmanager.git}" - -CLONE_DIR=$(mktemp -d /tmp/cronmanager-setup-XXXXXX) -step "Cloning into ${CLONE_DIR} ..." -git clone --depth=1 "$REPO_URL" "$CLONE_DIR" 2>&1 \ - || die "git clone failed. Check the URL and your internet connection." - -[[ -f "$CLONE_DIR/agent/agent.php" ]] \ - || die "Cloned repo missing agent/agent.php – unexpected structure." -[[ -f "$CLONE_DIR/composer.json" ]] \ - || die "composer.json not found in cloned repository." -ok "Repository cloned successfully." - -# ═════════════════════════════════════════════════════════════════════════════ -# 5. COMPOSER CHECK AND INSTALL (on target) -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 4 – Composer" - -COMPOSER_BIN="composer" -if target_exec "command -v composer >/dev/null 2>&1" 2>/dev/null; then - _ver=$(target_exec "composer --version 2>/dev/null | head -1" || true) - ok "Composer is installed: ${_ver}" -elif target_exec "test -f /usr/local/bin/composer" 2>/dev/null; then - ok "Composer found at /usr/local/bin/composer." - COMPOSER_BIN="/usr/local/bin/composer" -else - warn "Composer is not installed on the target." - ask_yn INSTALL_COMPOSER "Install Composer on the target now?" "yes" - - if [[ "$INSTALL_COMPOSER" == "yes" ]]; then - step "Downloading and installing Composer on target..." - target_exec \ - "curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet" \ - || warn_continue "Composer installation failed." - COMPOSER_BIN="/usr/local/bin/composer" - ok "Composer installed." - else - die "Cannot continue without Composer on the target." - fi -fi - -# ═════════════════════════════════════════════════════════════════════════════ -# 6. PHP LIBRARY CHECK (on target) -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 5 – PHP Libraries" - -ask PHPLIB_DIR "Shared PHP library directory on target" "/opt/phplib" -VENDOR_DIR="${PHPLIB_DIR}/vendor" - -declare -A REQUIRED_PACKAGES=( - ["hassankhan/config"]="^2.1" - ["monolog/monolog"]="^3.6" - ["guzzlehttp/guzzle"]="^7.8" - ["phpmailer/phpmailer"]="^6.8" - ["dragonmantank/cron-expression"]="^3.3" - ["lorisleiva/cron-translator"]="^0.4" -) - -step "Checking installed packages in ${VENDOR_DIR} on target..." -MISSING_PACKAGES=() - -for _pkg in "${!REQUIRED_PACKAGES[@]}"; do - if target_exec "test -d '${VENDOR_DIR}/${_pkg}'" 2>/dev/null; then - ok "$_pkg – OK" - else - warn "$_pkg – NOT found" - MISSING_PACKAGES+=("$_pkg") - fi -done - -if [[ ${#MISSING_PACKAGES[@]} -eq 0 ]]; then - ok "All required PHP libraries are installed." -else - warn "The following libraries are missing:" - for _pkg in "${MISSING_PACKAGES[@]}"; do - echo -e " ${YELLOW}•${NC} $_pkg ${REQUIRED_PACKAGES[$_pkg]}" - done - blank - ask_yn ADD_LIBS "Add missing libraries to composer.json on target and run composer install?" "yes" - - if [[ "$ADD_LIBS" == "yes" ]]; then - target_exec "mkdir -p '${PHPLIB_DIR}'" - - # Build a merged composer.json locally, then send it to the target - _tmp_composer=$(mktemp /tmp/cronmanager-composer-XXXXXX.json) - python3 - "$CLONE_DIR/composer.json" "$_tmp_composer" << 'PYEOF' -import json, sys - -src_path = sys.argv[1] -dest_path = sys.argv[2] - -try: - with open(dest_path) as f: - data = json.load(f) -except Exception: - data = {"name": "local/phplib", "description": "Shared PHP libraries", "type": "project"} - -with open(src_path) as f: - src = json.load(f) - -data.setdefault("require", {}) -for pkg, ver in src.get("require", {}).items(): - data["require"].setdefault(pkg, ver) - -data.setdefault("config", {})["optimize-autoloader"] = True - -with open(dest_path, "w") as f: - json.dump(data, f, indent=4) -PYEOF - target_write "${PHPLIB_DIR}/composer.json" < "$_tmp_composer" - rm -f "$_tmp_composer" - - step "Running composer install on target..." - target_exec "${COMPOSER_BIN} install --no-dev --optimize-autoloader --working-dir='${PHPLIB_DIR}'" \ - || warn_continue "composer install failed." - ok "PHP libraries installed." - else - die "Cannot continue without required PHP libraries." - fi -fi - -# ═════════════════════════════════════════════════════════════════════════════ -# 7. COLLECT CONFIGURATION -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 6 – Configuration" -echo " Please answer the following questions. Press Enter to accept the default." -blank - -# ── Installation paths ───────────────────────────────────────────────────── -sep -echo -e " ${BOLD}Installation Paths (on target)${NC}" -blank -ask AGENT_DIR "Host agent directory" "/opt/cronmanager/agent" -ask WEB_DIR "Web application base directory" "/opt/cronmanager/www" -ask DB_DIR "MariaDB data directory" "/opt/cronmanager/db" - -WEB_WWW="${WEB_DIR}/html" -WEB_CONF="${WEB_DIR}/conf" -WEB_LOG="${WEB_DIR}/log" -DB_DATA="${DB_DIR}/data" -DB_CONF="${DB_DIR}/conf" -DB_LOG="${DB_DIR}/log" -DB_INIT="${DB_DIR}/init" -COMPOSE_DIR="$WEB_DIR" -blank - -# ── Database credentials ──────────────────────────────────────────────────── -sep -echo -e " ${BOLD}Database Credentials${NC}" -blank -ask DB_NAME "Database name" "cronmanager" -ask DB_USER "Database user" "cronmanager" -ask_secret DB_PASSWORD "Database password" -ask_secret DB_ROOT_PASSWORD "MariaDB root password" -blank - -# ── Agent settings ────────────────────────────────────────────────────────── -sep -echo -e " ${BOLD}Agent Settings${NC}" -blank -ask AGENT_BIND "Agent listen address (0.0.0.0 = all interfaces)" "0.0.0.0" -ask AGENT_PORT "Agent listen port" "8865" -ask_choice AGENT_LOG_LEVEL "Agent log level" "info" "debug" "warning" "error" -blank - -# ── Web application settings ──────────────────────────────────────────────── -sep -echo -e " ${BOLD}Web Application Settings${NC}" -blank -ask WEB_PORT "Web application HTTP port (external Docker port)" "8880" -ask WEB_LANG "Default language (en / de)" "en" -ask_choice WEB_LOG_LEVEL "Web application log level" "info" "debug" "warning" "error" -blank - -AGENT_URL="http://host.docker.internal:${AGENT_PORT}" -info "Agent URL for web app (Docker → host): ${AGENT_URL}" -blank - -# ── OIDC ──────────────────────────────────────────────────────────────────── -sep -echo -e " ${BOLD}OIDC / SSO Configuration${NC}" -blank -ask_yn OIDC_ENABLED "Enable OIDC single sign-on?" "no" -OIDC_PROVIDER_URL=""; OIDC_CLIENT_ID=""; OIDC_CLIENT_SECRET="" -OIDC_REDIRECT_URI=""; OIDC_SSL_VERIFY="true"; OIDC_CA_BUNDLE="" - -if [[ "$OIDC_ENABLED" == "yes" ]]; then - ask OIDC_PROVIDER_URL "OIDC provider URL (with trailing slash)" "" - ask OIDC_CLIENT_ID "OIDC client ID" "" - ask_secret OIDC_CLIENT_SECRET "OIDC client secret" - ask OIDC_REDIRECT_URI "OIDC redirect URI (callback URL)" "" - blank - ask_yn OIDC_SSL_NO_VERIFY "Disable SSL certificate verification for OIDC?" "no" - if [[ "$OIDC_SSL_NO_VERIFY" == "yes" ]]; then - OIDC_SSL_VERIFY="false" - warn "SSL verification disabled – only use this in trusted environments." - else - ask_yn USE_PRIVATE_CA "Use a private / self-signed CA certificate?" "no" - if [[ "$USE_PRIVATE_CA" == "yes" ]]; then - ask OIDC_CA_BUNDLE "Path to PEM CA certificate on the web container" "" - OIDC_SSL_VERIFY="$OIDC_CA_BUNDLE" - fi - fi -fi -blank - -# ── Email notifications ───────────────────────────────────────────────────── -sep -echo -e " ${BOLD}Email Failure Notifications${NC}" -blank -ask_yn MAIL_ENABLED "Enable email notifications for job failures?" "no" -MAIL_HOST=""; MAIL_PORT="587"; MAIL_USER=""; MAIL_PASS="" -MAIL_FROM=""; MAIL_FROM_NAME="Cronmanager"; MAIL_TO="" -MAIL_ENC="tls"; MAIL_TIMEOUT="15" - -if [[ "$MAIL_ENABLED" == "yes" ]]; then - ask MAIL_HOST "SMTP server hostname" "" - ask MAIL_PORT "SMTP port (587=STARTTLS, 465=SSL)" "587" - ask MAIL_USER "SMTP username (email address)" "" - ask_secret MAIL_PASS "SMTP password" - ask MAIL_FROM "Sender address" "$MAIL_USER" - ask MAIL_FROM_NAME "Sender display name" "Cronmanager" - ask MAIL_TO "Recipient address for alerts" "" - ask_choice MAIL_ENC "Encryption" "tls" "ssl" "none" - ask MAIL_TIMEOUT "SMTP connection timeout (seconds)" "15" -fi -blank - -# ── Summary ────────────────────────────────────────────────────────────────── -sep -echo -e " ${BOLD}Configuration Summary${NC}" -blank -if [[ "$TARGET_TYPE" == "remote" ]]; then - echo -e " Target : ${CYAN}${TARGET_SSH} (remote SSH)${NC}" -else - echo -e " Target : ${CYAN}local${NC}" -fi -echo -e " Agent dir : ${CYAN}${AGENT_DIR}${NC}" -echo -e " Web dir : ${CYAN}${WEB_DIR}${NC}" -echo -e " DB dir : ${CYAN}${DB_DIR}${NC}" -echo -e " PHP libs : ${CYAN}${PHPLIB_DIR}${NC}" -echo -e " Agent listen : ${CYAN}${AGENT_BIND}:${AGENT_PORT}${NC}" -echo -e " Agent URL : ${CYAN}${AGENT_URL}${NC}" -echo -e " Web HTTP port : ${CYAN}${WEB_PORT}${NC}" -echo -e " Database : ${CYAN}${DB_NAME}${NC} (user: ${DB_USER})" -echo -e " Default lang : ${CYAN}${WEB_LANG}${NC}" -echo -e " OIDC : ${CYAN}${OIDC_ENABLED}${NC}" -echo -e " Email alerts : ${CYAN}${MAIL_ENABLED}${NC}" -blank -ask_yn PROCEED "Proceed with this configuration?" "yes" -[[ "$PROCEED" == "no" ]] && die "Setup cancelled by user." - -# ═════════════════════════════════════════════════════════════════════════════ -# 8. GENERATE HMAC SECRET (locally) -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 7 – Security" - -step "Generating HMAC-SHA256 secret..." -HMAC_SECRET=$(openssl rand -hex 32) -ok "HMAC secret generated (${#HMAC_SECRET} hex characters)." - -# ═════════════════════════════════════════════════════════════════════════════ -# 9. CREATE DIRECTORY STRUCTURE (on target) -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 8 – Directory Structure" - -for _dir in \ - "$AGENT_DIR" "$AGENT_DIR/config" "$AGENT_DIR/bin" "$AGENT_DIR/log" \ - "$WEB_WWW" "$WEB_CONF" "$WEB_LOG" \ - "$DB_DATA" "$DB_CONF" "$DB_LOG" "$DB_INIT"; do - target_exec "mkdir -p '$_dir'" || warn_continue "Failed to create directory: $_dir" - ok "Created: $_dir" -done - -# The web log and conf directories are read/written by the nobody user inside the container -step "Setting ownership of web log and conf directories..." -target_exec "chown nobody:nogroup '${WEB_LOG}' '${WEB_CONF}'" \ - || warn_continue "Failed to set ownership on web directories." -ok "Ownership set: ${WEB_LOG}, ${WEB_CONF} → nobody:nogroup" - - -# ═════════════════════════════════════════════════════════════════════════════ -# 10. DEPLOY AGENT FILES -# ═════════════════════════════════════════════════════════════════════════════ -header "Step 9 – Deploy Host Agent" - -step "Copying agent files to ${AGENT_DIR} on target..." -target_copy "$CLONE_DIR/agent/" "$AGENT_DIR/" \ - || warn_continue "Failed to copy agent files to ${AGENT_DIR}." -ok "Agent files deployed." - -step "Patching hardcoded paths in all agent files..." -target_exec "find '${AGENT_DIR}' \( -name '*.php' -o -name '*.sh' -o -name '*.sql' \) \ - -exec sed -i \ - -e 's|/opt/phpscripts/cronmanager/agent|${AGENT_DIR}|g' \ - -e 's|/opt/phplib|${PHPLIB_DIR}|g' \ - {} \;" || warn_continue "Failed to patch hardcoded paths in agent files." -target_exec "chmod +x '${AGENT_DIR}/bin/'*.sh 2>/dev/null || true" -ok "Paths patched." - -# Write agent config.json – generated locally via Python, written to target -step "Writing agent config.json..." - -# Build Python booleans from shell vars before the heredoc -_mail_py=$( [[ "$MAIL_ENABLED" == "yes" ]] && echo "True" || echo "False" ) -_mail_enc_py="${MAIL_ENC}" - -_agent_db_host="127.0.0.1" -_agent_log_path="${AGENT_DIR}/log/cronmanager-agent.log" -_agent_wrapper="${AGENT_DIR}/bin/cron-wrapper.sh" - -_agent_conf_tmp=$(mktemp /tmp/cronmanager-agent-conf-XXXXXX.json) -python3 - > "$_agent_conf_tmp" << PYEOF -import json - -config = { - "agent": { - "bind_address": "${AGENT_BIND}", - "port": int("${AGENT_PORT}"), - "hmac_secret": "${HMAC_SECRET}" - }, - "database": { - "host": "${_agent_db_host}", - "port": 3306, - "name": "${DB_NAME}", - "user": "${DB_USER}", - "password": "${DB_PASSWORD}" - }, - "logging": { - "path": "${_agent_log_path}", - "level": "${AGENT_LOG_LEVEL}", - "max_days": 30 - }, - "mail": { - "enabled": ${_mail_py}, - "host": "${MAIL_HOST}", - "port": int("${MAIL_PORT}") if "${MAIL_PORT}" else 587, - "username": "${MAIL_USER}", - "password": "${MAIL_PASS}", - "from": "${MAIL_FROM}", - "from_name": "${MAIL_FROM_NAME}", - "to": "${MAIL_TO}", - "encryption": "" if "${_mail_enc_py}" == "none" else "${_mail_enc_py}", - "smtp_timeout": int("${MAIL_TIMEOUT}") if "${MAIL_TIMEOUT}" else 15 - }, - "cron": { - "wrapper_script": "${_agent_wrapper}" - } -} -print(json.dumps(config, indent=4, ensure_ascii=False)) -PYEOF -[[ $? -eq 0 ]] || warn_continue "Failed to generate agent config.json." -target_write "${AGENT_DIR}/config/config.json" < "$_agent_conf_tmp" \ - || warn_continue "Failed to write agent config.json to target." -rm -f "$_agent_conf_tmp" - -target_exec "chmod 600 '${AGENT_DIR}/config/config.json'" 2>/dev/null || true -ok "Agent config.json written." - -# ═════════════════════════════════════════════════════════════════════════════ -# 11. SYSTEMD SERVICE (on target – host-agent mode only) -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 10 – Systemd Service" - -# Ship the service unit: patch paths from the clone's template, send to target -_tmp_svc=$(mktemp /tmp/cronmanager-svc-XXXXXX.service) - -if [[ -f "$CLONE_DIR/agent/systemd/cronmanager-agent.service" ]]; then - sed \ - -e "s|/opt/phpscripts/cronmanager/agent|${AGENT_DIR}|g" \ - -e "s|/opt/phplib|${PHPLIB_DIR}|g" \ - "$CLONE_DIR/agent/systemd/cronmanager-agent.service" > "$_tmp_svc" -else - cat > "$_tmp_svc" << SVCEOF -[Unit] -Description=Cronmanager Host Agent -After=network.target - -[Service] -Type=simple -User=root -ExecStart=${AGENT_DIR}/bin/start-agent.sh -WorkingDirectory=${AGENT_DIR} -Restart=always -RestartSec=5 -StandardOutput=journal -StandardError=journal -SyslogIdentifier=cronmanager-agent - -[Install] -WantedBy=multi-user.target -SVCEOF -fi - -target_write "/etc/systemd/system/cronmanager-agent.service" < "$_tmp_svc" \ - || warn_continue "Failed to write systemd service file." -rm -f "$_tmp_svc" - -target_exec "systemctl daemon-reload && systemctl enable cronmanager-agent.service" \ - || warn_continue "Failed to enable cronmanager-agent service." -ok "Service installed and enabled." - -# ═════════════════════════════════════════════════════════════════════════════ -# 12. DEPLOY WEB APPLICATION (on target) -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 11 – Deploy Web Application" - -step "Copying web files to ${WEB_WWW} on target..." -target_copy "$CLONE_DIR/web/" "$WEB_WWW/" \ - || warn_continue "Failed to copy web application files to ${WEB_WWW}." - -step "Patching PHP library path in web application..." -target_exec "find '${WEB_WWW}' -name '*.php' -exec sed -i 's|/opt/phplib|${PHPLIB_DIR}|g' {} \\;" \ - || warn_continue "Failed to patch PHP library paths in web application." -ok "Web files deployed." - -# Download frontend assets on the target -# Tailwind: since v4.7.0 the pre-built, purged stylesheet (assets/css/tailwind.css) -# ships with the copied web files – no CDN download needed. Remove a leftover -# Play-CDN runtime from older installs. -step "Removing obsolete tailwind.min.js (replaced by pre-built tailwind.css)..." -target_exec "rm -f '${WEB_WWW}/assets/js/tailwind.min.js'" \ - && ok "tailwind.min.js removed (or was not present)." || true - -step "Downloading Chart.js v4..." -target_exec " - test -f '${WEB_WWW}/assets/js/chart.min.js' && exit 0 - curl -fsSL 'https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js' \ - -o '${WEB_WWW}/assets/js/chart.min.js' -" && ok "chart.min.js – OK." || warn "Chart.js download failed – install manually." - -# Write web config.json – generated locally, written to target -step "Writing web application config.json..." - -_oidc_py=$( [[ "$OIDC_ENABLED" == "yes" ]] && echo "True" || echo "False" ) -_ssl_raw="$OIDC_SSL_VERIFY" - -_web_conf_tmp=$(mktemp /tmp/cronmanager-web-conf-XXXXXX.json) -python3 - > "$_web_conf_tmp" << PYEOF -import json - -ssl_raw = "${_ssl_raw}" -if ssl_raw == "true": - ssl_verify = True -elif ssl_raw == "false": - ssl_verify = False -else: - ssl_verify = ssl_raw # path to CA bundle - -config = { - "database": { - "host": "cronmanager-db", - "port": 3306, - "name": "${DB_NAME}", - "user": "${DB_USER}", - "password": "${DB_PASSWORD}" - }, - "agent": { - "url": "${AGENT_URL}", - "hmac_secret": "${HMAC_SECRET}", - "timeout": 10 - }, - "logging": { - "path": "/var/www/log/cronmanager-web.log", - "level": "${WEB_LOG_LEVEL}", - "max_days": 30 - }, - "session": { - "lifetime": 3600, - "name": "cronmanager_sess" - }, - "i18n": { - "default_language": "${WEB_LANG}", - "available": ["en", "de"] - }, - "auth": { - "oidc_enabled": ${_oidc_py}, - "oidc_provider_url": "${OIDC_PROVIDER_URL}", - "oidc_client_id": "${OIDC_CLIENT_ID}", - "oidc_client_secret":"${OIDC_CLIENT_SECRET}", - "oidc_redirect_uri": "${OIDC_REDIRECT_URI}", - "oidc_ssl_verify": ssl_verify, - "oidc_ssl_ca_bundle":"${OIDC_CA_BUNDLE}" - } -} -print(json.dumps(config, indent=4, ensure_ascii=False)) -PYEOF -[[ $? -eq 0 ]] || warn_continue "Failed to generate web config.json." -target_write "${WEB_CONF}/config.json" < "$_web_conf_tmp" \ - || warn_continue "Failed to write web config.json to target." -rm -f "$_web_conf_tmp" - -target_exec "chmod 640 '${WEB_CONF}/config.json'" 2>/dev/null || true -target_exec "chown nobody:nogroup '${WEB_CONF}/config.json'" 2>/dev/null || true -ok "Web config.json written." - -# ═════════════════════════════════════════════════════════════════════════════ -# 13. CREDENTIAL AND COMPOSE FILES (on target) -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 12 – Credential and Compose Files" - -# db.credentials -cat << CREDEOF | target_write "${COMPOSE_DIR}/db.credentials" \ - || warn_continue "Failed to write db.credentials." -# Cronmanager – Database Credentials -# KEEP SECURE – do not commit to version control -DB_NAME=${DB_NAME} -DB_USER=${DB_USER} -DB_PASSWORD=${DB_PASSWORD} -DB_ROOT_USER=root -DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} -CREDEOF -target_exec "chmod 600 '${COMPOSE_DIR}/db.credentials'" 2>/dev/null || true -ok "db.credentials written." - -# .env (read automatically by docker compose) -cat << ENVEOF | target_write "${COMPOSE_DIR}/.env" \ - || warn_continue "Failed to write .env file." -DB_NAME=${DB_NAME} -DB_USER=${DB_USER} -DB_PASSWORD=${DB_PASSWORD} -DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} -ENVEOF -target_exec "chmod 600 '${COMPOSE_DIR}/.env'" 2>/dev/null || true -ok ".env written." - -# deploy.env (informational) -_deploy_type_label="LOCAL" -[[ "$TARGET_TYPE" == "remote" ]] && _deploy_type_label="REMOTE" -cat << DEPLOYEOF | target_write "${COMPOSE_DIR}/deploy.env" \ - || warn_continue "Failed to write deploy.env." -DEPLOY_TYPE=${_deploy_type_label} -DEPLOY_COMPOSER=${PHPLIB_DIR}/ -DEPLOY_COMPOSER_VENDOR=${PHPLIB_DIR}/vendor/ -DEPLOYEOF -ok "deploy.env written." - -# ── Generate docker-compose.yml ─────────────────────────────────────────── -# Shell variables for paths are expanded now (intentional). -# \${DB_*} is escaped so docker compose substitutes them from .env at runtime. - -cat << COMPOSEEOF | target_write "${COMPOSE_DIR}/docker-compose.yml" \ - || warn_continue "Failed to write docker-compose.yml." -# Cronmanager – docker-compose.yml -# Generated by simple_debian_setup.sh -# Deployment mode: host-agent - -services: - - cronmanager-web: - image: cs1711/cs_php-nginx-fpm:latest-alpine - container_name: cronmanager-web - restart: unless-stopped - extra_hosts: - - "host.docker.internal:host-gateway" - volumes: - - /etc/localtime:/etc/localtime:ro - - ${WEB_CONF}:/var/www/conf - - ${WEB_WWW}:/var/www/html - - ${WEB_LOG}:/var/www/log - - ${PHPLIB_DIR}/vendor:/var/www/libs/vendor - ports: - - "${WEB_PORT}:8080" - depends_on: - cronmanager-db: - condition: service_healthy - - cronmanager-db: - image: mariadb:lts - container_name: cronmanager-db - restart: unless-stopped - volumes: - - ${DB_DATA}:/var/lib/mysql - - ${DB_CONF}:/etc/mysql - - ${DB_LOG}:/var/log/mysql - - ${DB_INIT}:/docker-entrypoint-initdb.d - ports: - - "127.0.0.1:3306:3306" - environment: - MARIADB_DATABASE: \${DB_NAME} - MARIADB_USER: \${DB_USER} - MARIADB_PASSWORD: \${DB_PASSWORD} - MARIADB_ROOT_PASSWORD: \${DB_ROOT_PASSWORD} - healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 30s -COMPOSEEOF -ok "docker-compose.yml written." - -# Display the generated credential files and docker-compose.yml -blank; sep -echo -e " ${BOLD}Generated db.credentials${NC} ${YELLOW}(keep secure)${NC}:" -blank -target_exec "cat '${COMPOSE_DIR}/db.credentials'" | sed 's/^/ /' -blank; sep -echo -e " ${BOLD}Generated .env${NC} ${YELLOW}(keep secure)${NC}:" -blank -target_exec "cat '${COMPOSE_DIR}/.env'" | sed 's/^/ /' -blank; sep -echo -e " ${BOLD}Generated docker-compose.yml:${NC}" -blank -target_exec "cat '${COMPOSE_DIR}/docker-compose.yml'" | sed 's/^/ /' -blank; sep - -# ═════════════════════════════════════════════════════════════════════════════ -# 14. DOCKER STACK (on target) -# Must run before the agent so MariaDB is already up when the agent starts. -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 13 – Docker Stack" - -DOCKER_DEPLOYED=false -ask_yn AUTO_DEPLOY "Start the Docker stack on target now?" "yes" - -if [[ "$AUTO_DEPLOY" == "yes" ]]; then - step "Starting Docker stack..." - target_exec "cd '${COMPOSE_DIR}' && docker compose up -d" \ - || warn_continue "Docker stack failed to start. Check logs with: docker compose -f '${COMPOSE_DIR}/docker-compose.yml' logs" - ok "Docker stack started." - DOCKER_DEPLOYED=true -else - info "Docker stack was NOT started automatically." - ask_yn MANUAL_DEPLOYED "Has the Docker stack already been deployed manually?" "no" - [[ "$MANUAL_DEPLOYED" == "yes" ]] && DOCKER_DEPLOYED=true -fi - -# ═════════════════════════════════════════════════════════════════════════════ -# 15. DATABASE SCHEMA (on target via docker exec) -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 14 – Database Schema" - -SCHEMA_FILE="${AGENT_DIR}/sql/schema.sql" -MIGRATIONS_DIR="${AGENT_DIR}/sql/migrations" - -if [[ "$DOCKER_DEPLOYED" == true ]]; then - ask_yn APPLY_SCHEMA "Apply database schema and migrations now?" "yes" - - if [[ "$APPLY_SCHEMA" == "yes" ]]; then - step "Waiting for MariaDB to be ready..." - READY=false - for _i in $(seq 1 15); do - if target_exec \ - "docker exec cronmanager-db healthcheck.sh --connect --innodb_initialized >/dev/null 2>&1"; then - READY=true; break - fi - echo -ne " Attempt ${_i}/15...\r" - sleep 4 - done - echo - - if [[ "$READY" == false ]]; then - warn "MariaDB did not become healthy in time." - warn "Apply schema manually once the DB is ready." - else - ok "MariaDB is ready." - - step "Applying schema.sql (if not already initialised)..." - # Connect as root: docker exec always resolves to @'localhost' in - # MariaDB (even with -h 127.0.0.1 due to reverse DNS), so the - # app user cronmanager@'%' created by the Docker image would be - # denied. Root has unconditional @'localhost' access. - target_script << 'SHELLSCRIPT' -TABLE_EXISTS=$(docker exec cronmanager-db \ - mariadb -u root -p"${DB_ROOT_PASSWORD}" "${DB_NAME}" \ - -sNe "SHOW TABLES LIKE 'cronjobs'" 2>/dev/null) - -FRESH_INSTALL=false -if [[ -z "$TABLE_EXISTS" ]]; then - docker exec -i cronmanager-db \ - mariadb -u root -p"${DB_ROOT_PASSWORD}" "${DB_NAME}" \ - < "${SCHEMA_FILE}" \ - && echo " Schema applied." \ - || { echo " Schema failed."; exit 1; } - FRESH_INSTALL=true -else - echo " Schema already present – skipping." -fi -SHELLSCRIPT - ok "Schema step complete." - - step "Ensuring schema_migrations table exists..." - target_exec \ - "docker exec cronmanager-db \ - mariadb -u root -p'${DB_ROOT_PASSWORD}' '${DB_NAME}' \ - -e \"CREATE TABLE IF NOT EXISTS schema_migrations ( - filename VARCHAR(255) NOT NULL, - applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (filename) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci\"" \ - && ok "schema_migrations table ready." \ - || warn "Could not ensure schema_migrations table." - - step "Applying pending migrations..." - target_script << SHELLSCRIPT -# Seed schema_migrations on a fresh install so that bundled migrations -# (already included in schema.sql) are never re-executed. -if [[ "\${FRESH_INSTALL:-false}" == true ]]; then - for mig in \$(ls -1v '${MIGRATIONS_DIR}'/*.sql 2>/dev/null); do - fname=\$(basename "\$mig") - docker exec cronmanager-db \ - mariadb -u root -p'${DB_ROOT_PASSWORD}' '${DB_NAME}' \ - -e "INSERT IGNORE INTO schema_migrations (filename) VALUES ('\$fname')" 2>/dev/null - echo " seeded: \${fname}" - done -fi - -for mig in \$(ls -1v '${MIGRATIONS_DIR}'/*.sql 2>/dev/null); do - fname=\$(basename "\$mig") - already=\$(docker exec cronmanager-db \ - mariadb -u root -p'${DB_ROOT_PASSWORD}' '${DB_NAME}' \ - -sNe "SELECT COUNT(*) FROM schema_migrations WHERE filename='\$fname'" 2>/dev/null || echo "0") - if [[ "\$already" -gt 0 ]]; then - echo " already applied: \${fname}" - continue - fi - docker exec -i cronmanager-db \ - mariadb -u root -p'${DB_ROOT_PASSWORD}' '${DB_NAME}' < "\$mig" \ - && docker exec cronmanager-db \ - mariadb -u root -p'${DB_ROOT_PASSWORD}' '${DB_NAME}' \ - -e "INSERT IGNORE INTO schema_migrations (filename) VALUES ('\$fname')" \ - && echo " applied: \${fname}" \ - || echo " FAILED: \${fname} – apply manually and insert into schema_migrations" -done -SHELLSCRIPT - fi - fi -else - info "Docker stack not deployed – skipping schema setup." - info "Apply manually: docker exec -i cronmanager-db mariadb -u root -p'' ${DB_NAME} < ${SCHEMA_FILE}" -fi - -# ═════════════════════════════════════════════════════════════════════════════ -# 16. START / VERIFY AGENT (on target) -# Runs last so MariaDB is already up and the schema is applied. -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 15 – Start Agent" - -step "Starting cronmanager-agent service..." -if target_exec "systemctl start cronmanager-agent.service" 2>&1; then - sleep 2 - step "Health check: http://127.0.0.1:${AGENT_PORT}/health" - HTTP_CODE=$(target_exec \ - "curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ - http://127.0.0.1:${AGENT_PORT}/health 2>/dev/null || echo 000") - if [[ "$HTTP_CODE" == "200" ]]; then - ok "Agent health check passed (HTTP ${HTTP_CODE})." - else - warn "Agent health check returned HTTP ${HTTP_CODE}." - warn "Check logs with: journalctl -u cronmanager-agent -n 50" - fi -else - warn "Agent service could not be started." - warn "Check logs with: journalctl -u cronmanager-agent -n 50" -fi - -# ═════════════════════════════════════════════════════════════════════════════ -# 16. EXECUTION LIMIT CHECKER (system cron entry on target) -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 16 – Execution Limit Checker" - -LIMITS_CRON_FILE="/etc/cron.d/cronmanager-limits" -LIMITS_CRON_ENTRY="* * * * * root /usr/bin/php ${AGENT_DIR}/bin/check-limits.php >> /dev/null 2>&1" - -step "Installing execution-limit checker cron entry..." -if target_exec "echo '${LIMITS_CRON_ENTRY}' > ${LIMITS_CRON_FILE} && chmod 0644 ${LIMITS_CRON_FILE}" 2>&1; then - ok "Cron entry written to ${LIMITS_CRON_FILE}." -else - warn "Could not write cron entry to ${LIMITS_CRON_FILE}." - warn "Add manually: ${LIMITS_CRON_ENTRY}" -fi - -# ═════════════════════════════════════════════════════════════════════════════ -# 17. LOG RETENTION PRUNER -# ═════════════════════════════════════════════════════════════════════════════ - -header "Step 17 – Log Retention Pruner" - -PRUNE_CRON_FILE="/etc/cron.d/cronmanager-prune-logs" -PRUNE_CRON_ENTRY="0 2 * * * root /usr/bin/php ${AGENT_DIR}/bin/prune-logs.php >> /dev/null 2>&1" - -step "Installing log-retention pruner cron entry..." -if target_exec "echo '${PRUNE_CRON_ENTRY}' > ${PRUNE_CRON_FILE} && chmod 0644 ${PRUNE_CRON_FILE}" 2>&1; then - ok "Cron entry written to ${PRUNE_CRON_FILE}." -else - warn "Could not write cron entry to ${PRUNE_CRON_FILE}." - warn "Add manually: ${PRUNE_CRON_ENTRY}" -fi - -# ═════════════════════════════════════════════════════════════════════════════ -# 18. SUMMARY -# ═════════════════════════════════════════════════════════════════════════════ - -header "Setup Complete" - -if [[ "$TARGET_TYPE" == "remote" ]]; then - _label="$TARGET_SSH" - _ssh_pfx="ssh ${TARGET_SSH} " - _host_ip=$(target_exec "hostname -I | awk '{print \$1}'" 2>/dev/null || echo "") -else - _label="localhost" - _ssh_pfx="" - _host_ip=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "127.0.0.1") -fi - -echo -e " ${GREEN}${BOLD}Cronmanager has been installed on ${_label}.${NC}" -blank -echo -e " ${BOLD}Installed paths:${NC}" -echo -e " Host agent : ${CYAN}${AGENT_DIR}${NC}" -echo -e " Web app : ${CYAN}${WEB_WWW}${NC}" -echo -e " Web config : ${CYAN}${WEB_CONF}/config.json${NC}" -echo -e " Agent config : ${CYAN}${AGENT_DIR}/config/config.json${NC}" -echo -e " Docker Compose: ${CYAN}${COMPOSE_DIR}/${NC}" -echo -e " DB data : ${CYAN}${DB_DATA}${NC}" -blank -echo -e " ${BOLD}Agent service management:${NC}" -echo -e " ${CYAN}${_ssh_pfx}systemctl status cronmanager-agent${NC}" -echo -e " ${CYAN}${_ssh_pfx}systemctl restart cronmanager-agent${NC}" -echo -e " ${CYAN}${_ssh_pfx}journalctl -u cronmanager-agent -f${NC}" -blank -echo -e " ${BOLD}Docker stack (from ${COMPOSE_DIR}):${NC}" -echo -e " ${CYAN}${_ssh_pfx}sh -c 'cd ${COMPOSE_DIR} && docker compose up -d'${NC}" -echo -e " ${CYAN}${_ssh_pfx}sh -c 'cd ${COMPOSE_DIR} && docker compose logs -f'${NC}" -blank -echo -e " ${BOLD}Agent health check:${NC}" -echo -e " ${CYAN}${_ssh_pfx}curl http://127.0.0.1:${AGENT_PORT}/health${NC}" -blank -echo -e " ${BOLD}Web UI:${NC}" -echo -e " ${CYAN}http://${_host_ip}:${WEB_PORT}/${NC}" -echo -e " Open this URL to complete setup and create the admin account." -blank -echo -e " ${BOLD}HMAC secret${NC} (store securely – needed if you reinstall):" -echo -e " ${YELLOW}${HMAC_SECRET}${NC}" -blank -echo -e " ${BOLD}Next steps:${NC}" -echo -e " 1. Open the web UI and create your admin account." -[[ "$OIDC_ENABLED" == "yes" ]] && \ - echo -e " 2. Verify OIDC login at ${CYAN}${OIDC_REDIRECT_URI}${NC}." -[[ "$MAIL_ENABLED" == "yes" ]] && \ - echo -e " 3. Test email by creating a job with 'Notify on failure' enabled." -echo -e " 4. Add your first cron job via the web UI." -blank diff --git a/web/VERSION b/web/VERSION index 831446c..7cbea07 100644 --- a/web/VERSION +++ b/web/VERSION @@ -1 +1 @@ -5.1.0 +5.2.0 \ No newline at end of file diff --git a/web/assets/css/tailwind.css b/web/assets/css/tailwind.css index 1457e0a..805cd98 100644 --- a/web/assets/css/tailwind.css +++ b/web/assets/css/tailwind.css @@ -1,2 +1,2 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{bottom:0;top:0}.left-0{left:0}.right-0{right:0}.right-2{right:.5rem}.top-1\/2{top:50%}.z-50{z-index:50}.col-span-full{grid-column:1/-1}.-mx-6{margin-left:-1.5rem;margin-right:-1.5rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-6{margin-bottom:1.5rem;margin-top:1.5rem}.-mt-0\.5{margin-top:-.125rem}.mb-0\.5{margin-bottom:.125rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-6{margin-left:1.5rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.min-h-\[1\.25rem\]{min-height:1.25rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-20{width:5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-40{width:10rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-full{width:100%}.w-px{width:1px}.min-w-28{min-width:7rem}.min-w-32{min-width:8rem}.min-w-36{min-width:9rem}.min-w-40{min-width:10rem}.min-w-48{min-width:12rem}.min-w-\[210px\]{min-width:210px}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-7xl{max-width:80rem}.max-w-\[280px\]{max-width:280px}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-1\/2,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize-y{resize:vertical}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-x-8{-moz-column-gap:2rem;column-gap:2rem}.gap-y-2{row-gap:.5rem}.gap-y-4{row-gap:1rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.125rem*var(--tw-space-y-reverse));margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.25rem*var(--tw-space-y-reverse));margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(243 244 246/var(--tw-divide-opacity,1))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity,1))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity,1))}.border-amber-300{--tw-border-opacity:1;border-color:rgb(252 211 77/var(--tw-border-opacity,1))}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-blue-600{--tw-border-opacity:1;border-color:rgb(37 99 235/var(--tw-border-opacity,1))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.border-green-200{--tw-border-opacity:1;border-color:rgb(187 247 208/var(--tw-border-opacity,1))}.border-green-300{--tw-border-opacity:1;border-color:rgb(134 239 172/var(--tw-border-opacity,1))}.border-indigo-200{--tw-border-opacity:1;border-color:rgb(199 210 254/var(--tw-border-opacity,1))}.border-purple-100{--tw-border-opacity:1;border-color:rgb(243 232 255/var(--tw-border-opacity,1))}.border-purple-200{--tw-border-opacity:1;border-color:rgb(233 213 255/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-yellow-200{--tw-border-opacity:1;border-color:rgb(254 240 138/var(--tw-border-opacity,1))}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-indigo-100{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity,1))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.bg-orange-100{--tw-bg-opacity:1;background-color:rgb(255 237 213/var(--tw-bg-opacity,1))}.bg-purple-100{--tw-bg-opacity:1;background-color:rgb(243 232 255/var(--tw-bg-opacity,1))}.bg-purple-50{--tw-bg-opacity:1;background-color:rgb(250 245 255/var(--tw-bg-opacity,1))}.bg-purple-50\/50{background-color:rgba(250,245,255,.5)}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.p-10{padding:2.5rem}.p-12{padding:3rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-10{padding-bottom:2.5rem;padding-top:2.5rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-16{padding-bottom:4rem;padding-top:4rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-24{padding-bottom:6rem;padding-top:6rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pl-3{padding-left:.75rem}.pl-9{padding-left:2.25rem}.pr-10{padding-right:2.5rem}.pr-3{padding-right:.75rem}.pt-2{padding-top:.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tracking-wider{letter-spacing:.05em}.text-amber-500{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-indigo-700{--tw-text-opacity:1;color:rgb(67 56 202/var(--tw-text-opacity,1))}.text-indigo-800{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity,1))}.text-orange-500{--tw-text-opacity:1;color:rgb(249 115 22/var(--tw-text-opacity,1))}.text-orange-700{--tw-text-opacity:1;color:rgb(194 65 12/var(--tw-text-opacity,1))}.text-orange-800{--tw-text-opacity:1;color:rgb(154 52 18/var(--tw-text-opacity,1))}.text-purple-600{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-purple-700{--tw-text-opacity:1;color:rgb(126 34 206/var(--tw-text-opacity,1))}.text-purple-800{--tw-text-opacity:1;color:rgb(107 33 168/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-teal-600{--tw-text-opacity:1;color:rgb(13 148 136/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-yellow-700{--tw-text-opacity:1;color:rgb(161 98 7/var(--tw-text-opacity,1))}.text-yellow-800{--tw-text-opacity:1;color:rgb(133 77 14/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.placeholder-gray-400::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.placeholder-gray-400::placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.accent-blue-600{accent-color:#2563eb}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-70{opacity:.7}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:border-blue-400:hover{--tw-border-opacity:1;border-color:rgb(96 165 250/var(--tw-border-opacity,1))}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-green-100:hover{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.hover\:bg-green-200:hover{--tw-bg-opacity:1;background-color:rgb(187 247 208/var(--tw-bg-opacity,1))}.hover\:bg-indigo-100:hover{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity,1))}.hover\:bg-indigo-200:hover{--tw-bg-opacity:1;background-color:rgb(199 210 254/var(--tw-bg-opacity,1))}.hover\:bg-indigo-700:hover{--tw-bg-opacity:1;background-color:rgb(67 56 202/var(--tw-bg-opacity,1))}.hover\:bg-purple-200:hover{--tw-bg-opacity:1;background-color:rgb(233 213 255/var(--tw-bg-opacity,1))}.hover\:bg-red-100:hover{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.hover\:bg-red-200:hover{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity,1))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:bg-yellow-100:hover{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.hover\:text-gray-800:hover{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.hover\:text-indigo-800:hover{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity,1))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.hover\:text-red-800:hover{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.hover\:text-teal-800:hover{--tw-text-opacity:1;color:rgb(17 94 89/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.focus\:border-amber-500:focus{--tw-border-opacity:1;border-color:rgb(245 158 11/var(--tw-border-opacity,1))}.focus\:border-blue-500:focus{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-1:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-amber-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(245 158 11/var(--tw-ring-opacity,1))}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.focus\:ring-gray-400:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(156 163 175/var(--tw-ring-opacity,1))}.focus\:ring-gray-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(107 114 128/var(--tw-ring-opacity,1))}.focus\:ring-green-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(34 197 94/var(--tw-ring-opacity,1))}.focus\:ring-indigo-400:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(129 140 248/var(--tw-ring-opacity,1))}.focus\:ring-indigo-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(99 102 241/var(--tw-ring-opacity,1))}.focus\:ring-orange-400:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(251 146 60/var(--tw-ring-opacity,1))}.focus\:ring-purple-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(168 85 247/var(--tw-ring-opacity,1))}.focus\:ring-red-400:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.focus\:ring-red-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(239 68 68/var(--tw-ring-opacity,1))}.focus\:ring-yellow-400:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.focus\:ring-yellow-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(234 179 8/var(--tw-ring-opacity,1))}.focus\:ring-offset-0:focus{--tw-ring-offset-width:0px}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.has-\[\:checked\]\:border-blue-500:has(:checked){--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.has-\[\:checked\]\:bg-blue-50:has(:checked){--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}@media (min-width:640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:block{display:block}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.lg\:col-span-4{grid-column:span 4/span 4}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:px-8{padding-left:2rem;padding-right:2rem}}@media (prefers-color-scheme:dark){.dark\:divide-gray-700>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(55 65 81/var(--tw-divide-opacity,1))}.dark\:border-amber-700{--tw-border-opacity:1;border-color:rgb(180 83 9/var(--tw-border-opacity,1))}.dark\:border-blue-400{--tw-border-opacity:1;border-color:rgb(96 165 250/var(--tw-border-opacity,1))}.dark\:border-blue-700{--tw-border-opacity:1;border-color:rgb(29 78 216/var(--tw-border-opacity,1))}.dark\:border-blue-800{--tw-border-opacity:1;border-color:rgb(30 64 175/var(--tw-border-opacity,1))}.dark\:border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.dark\:border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.dark\:border-green-700{--tw-border-opacity:1;border-color:rgb(21 128 61/var(--tw-border-opacity,1))}.dark\:border-green-800{--tw-border-opacity:1;border-color:rgb(22 101 52/var(--tw-border-opacity,1))}.dark\:border-purple-700{--tw-border-opacity:1;border-color:rgb(126 34 206/var(--tw-border-opacity,1))}.dark\:border-purple-800{--tw-border-opacity:1;border-color:rgb(107 33 168/var(--tw-border-opacity,1))}.dark\:border-red-700{--tw-border-opacity:1;border-color:rgb(185 28 28/var(--tw-border-opacity,1))}.dark\:border-red-800{--tw-border-opacity:1;border-color:rgb(153 27 27/var(--tw-border-opacity,1))}.dark\:bg-amber-900{--tw-bg-opacity:1;background-color:rgb(120 53 15/var(--tw-bg-opacity,1))}.dark\:bg-amber-900\/20{background-color:rgba(120,53,15,.2)}.dark\:bg-amber-900\/30{background-color:rgba(120,53,15,.3)}.dark\:bg-amber-900\/40{background-color:rgba(120,53,15,.4)}.dark\:bg-black\/60{background-color:rgba(0,0,0,.6)}.dark\:bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.dark\:bg-blue-900\/20{background-color:rgba(30,58,138,.2)}.dark\:bg-blue-900\/30{background-color:rgba(30,58,138,.3)}.dark\:bg-blue-900\/40{background-color:rgba(30,58,138,.4)}.dark\:bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.dark\:bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.dark\:bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.dark\:bg-gray-900\/30{background-color:rgba(17,24,39,.3)}.dark\:bg-gray-950{--tw-bg-opacity:1;background-color:rgb(3 7 18/var(--tw-bg-opacity,1))}.dark\:bg-green-900{--tw-bg-opacity:1;background-color:rgb(20 83 45/var(--tw-bg-opacity,1))}.dark\:bg-green-900\/20{background-color:rgba(20,83,45,.2)}.dark\:bg-green-900\/40{background-color:rgba(20,83,45,.4)}.dark\:bg-indigo-900{--tw-bg-opacity:1;background-color:rgb(49 46 129/var(--tw-bg-opacity,1))}.dark\:bg-indigo-900\/40{background-color:rgba(49,46,129,.4)}.dark\:bg-orange-900\/30{background-color:rgba(124,45,18,.3)}.dark\:bg-orange-900\/40{background-color:rgba(124,45,18,.4)}.dark\:bg-purple-900{--tw-bg-opacity:1;background-color:rgb(88 28 135/var(--tw-bg-opacity,1))}.dark\:bg-purple-900\/10{background-color:rgba(88,28,135,.1)}.dark\:bg-purple-900\/20{background-color:rgba(88,28,135,.2)}.dark\:bg-purple-900\/30{background-color:rgba(88,28,135,.3)}.dark\:bg-purple-900\/40{background-color:rgba(88,28,135,.4)}.dark\:bg-red-900\/20{background-color:rgba(127,29,29,.2)}.dark\:bg-red-900\/30{background-color:rgba(127,29,29,.3)}.dark\:bg-red-900\/40{background-color:rgba(127,29,29,.4)}.dark\:bg-red-950{--tw-bg-opacity:1;background-color:rgb(69 10 10/var(--tw-bg-opacity,1))}.dark\:bg-yellow-900{--tw-bg-opacity:1;background-color:rgb(113 63 18/var(--tw-bg-opacity,1))}.dark\:text-amber-200{--tw-text-opacity:1;color:rgb(253 230 138/var(--tw-text-opacity,1))}.dark\:text-amber-300{--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.dark\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-blue-200{--tw-text-opacity:1;color:rgb(191 219 254/var(--tw-text-opacity,1))}.dark\:text-blue-300{--tw-text-opacity:1;color:rgb(147 197 253/var(--tw-text-opacity,1))}.dark\:text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.dark\:text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.dark\:text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.dark\:text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.dark\:text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.dark\:text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.dark\:text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.dark\:text-green-200{--tw-text-opacity:1;color:rgb(187 247 208/var(--tw-text-opacity,1))}.dark\:text-green-300{--tw-text-opacity:1;color:rgb(134 239 172/var(--tw-text-opacity,1))}.dark\:text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.dark\:text-indigo-200{--tw-text-opacity:1;color:rgb(199 210 254/var(--tw-text-opacity,1))}.dark\:text-indigo-300{--tw-text-opacity:1;color:rgb(165 180 252/var(--tw-text-opacity,1))}.dark\:text-indigo-400{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity,1))}.dark\:text-orange-300{--tw-text-opacity:1;color:rgb(253 186 116/var(--tw-text-opacity,1))}.dark\:text-purple-200{--tw-text-opacity:1;color:rgb(233 213 255/var(--tw-text-opacity,1))}.dark\:text-purple-300{--tw-text-opacity:1;color:rgb(216 180 254/var(--tw-text-opacity,1))}.dark\:text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.dark\:text-teal-400{--tw-text-opacity:1;color:rgb(45 212 191/var(--tw-text-opacity,1))}.dark\:text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.dark\:text-yellow-200{--tw-text-opacity:1;color:rgb(254 240 138/var(--tw-text-opacity,1))}.dark\:placeholder-gray-500::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.dark\:placeholder-gray-500::placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.dark\:hover\:border-blue-500:hover{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.dark\:hover\:border-gray-600:hover{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.dark\:hover\:bg-blue-900\/20:hover{background-color:rgba(30,58,138,.2)}.dark\:hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-700\/30:hover{background-color:rgba(55,65,81,.3)}.dark\:hover\:bg-gray-700\/40:hover{background-color:rgba(55,65,81,.4)}.dark\:hover\:bg-gray-700\/50:hover{background-color:rgba(55,65,81,.5)}.dark\:hover\:bg-green-900\/70:hover{background-color:rgba(20,83,45,.7)}.dark\:hover\:bg-indigo-900\/70:hover{background-color:rgba(49,46,129,.7)}.dark\:hover\:bg-purple-900\/70:hover{background-color:rgba(88,28,135,.7)}.dark\:hover\:bg-red-900\/70:hover{background-color:rgba(127,29,29,.7)}.dark\:hover\:text-blue-200:hover{--tw-text-opacity:1;color:rgb(191 219 254/var(--tw-text-opacity,1))}.dark\:hover\:text-gray-200:hover{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.dark\:hover\:text-gray-300:hover{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.dark\:hover\:text-indigo-200:hover{--tw-text-opacity:1;color:rgb(199 210 254/var(--tw-text-opacity,1))}.dark\:hover\:text-red-200:hover{--tw-text-opacity:1;color:rgb(254 202 202/var(--tw-text-opacity,1))}.dark\:hover\:text-red-400:hover{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.dark\:hover\:text-teal-200:hover{--tw-text-opacity:1;color:rgb(153 246 228/var(--tw-text-opacity,1))}.dark\:hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.dark\:focus\:ring-indigo-400:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(129 140 248/var(--tw-ring-opacity,1))}.dark\:has-\[\:checked\]\:bg-gray-700:has(:checked){--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}} \ No newline at end of file +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{bottom:0;top:0}.left-0{left:0}.right-0{right:0}.right-2{right:.5rem}.top-1\/2{top:50%}.z-50{z-index:50}.col-span-full{grid-column:1/-1}.-mx-6{margin-left:-1.5rem;margin-right:-1.5rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-6{margin-bottom:1.5rem;margin-top:1.5rem}.-mt-0\.5{margin-top:-.125rem}.mb-0\.5{margin-bottom:.125rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-6{margin-left:1.5rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.min-h-\[1\.25rem\]{min-height:1.25rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-20{width:5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-40{width:10rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-full{width:100%}.w-px{width:1px}.min-w-28{min-width:7rem}.min-w-32{min-width:8rem}.min-w-36{min-width:9rem}.min-w-40{min-width:10rem}.min-w-48{min-width:12rem}.min-w-\[210px\]{min-width:210px}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-7xl{max-width:80rem}.max-w-\[280px\]{max-width:280px}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-1\/2,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize-y{resize:vertical}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-x-8{-moz-column-gap:2rem;column-gap:2rem}.gap-y-2{row-gap:.5rem}.gap-y-4{row-gap:1rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.125rem*var(--tw-space-y-reverse));margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.25rem*var(--tw-space-y-reverse));margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(243 244 246/var(--tw-divide-opacity,1))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity,1))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity,1))}.border-amber-300{--tw-border-opacity:1;border-color:rgb(252 211 77/var(--tw-border-opacity,1))}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-blue-600{--tw-border-opacity:1;border-color:rgb(37 99 235/var(--tw-border-opacity,1))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.border-green-200{--tw-border-opacity:1;border-color:rgb(187 247 208/var(--tw-border-opacity,1))}.border-green-300{--tw-border-opacity:1;border-color:rgb(134 239 172/var(--tw-border-opacity,1))}.border-indigo-200{--tw-border-opacity:1;border-color:rgb(199 210 254/var(--tw-border-opacity,1))}.border-purple-100{--tw-border-opacity:1;border-color:rgb(243 232 255/var(--tw-border-opacity,1))}.border-purple-200{--tw-border-opacity:1;border-color:rgb(233 213 255/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-yellow-200{--tw-border-opacity:1;border-color:rgb(254 240 138/var(--tw-border-opacity,1))}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-indigo-100{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity,1))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.bg-orange-100{--tw-bg-opacity:1;background-color:rgb(255 237 213/var(--tw-bg-opacity,1))}.bg-purple-100{--tw-bg-opacity:1;background-color:rgb(243 232 255/var(--tw-bg-opacity,1))}.bg-purple-50{--tw-bg-opacity:1;background-color:rgb(250 245 255/var(--tw-bg-opacity,1))}.bg-purple-50\/50{background-color:rgba(250,245,255,.5)}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.p-10{padding:2.5rem}.p-12{padding:3rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-10{padding-bottom:2.5rem;padding-top:2.5rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-16{padding-bottom:4rem;padding-top:4rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-24{padding-bottom:6rem;padding-top:6rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pl-3{padding-left:.75rem}.pl-9{padding-left:2.25rem}.pr-10{padding-right:2.5rem}.pr-3{padding-right:.75rem}.pt-2{padding-top:.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tracking-wider{letter-spacing:.05em}.text-amber-500{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-indigo-700{--tw-text-opacity:1;color:rgb(67 56 202/var(--tw-text-opacity,1))}.text-indigo-800{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity,1))}.text-orange-500{--tw-text-opacity:1;color:rgb(249 115 22/var(--tw-text-opacity,1))}.text-orange-700{--tw-text-opacity:1;color:rgb(194 65 12/var(--tw-text-opacity,1))}.text-orange-800{--tw-text-opacity:1;color:rgb(154 52 18/var(--tw-text-opacity,1))}.text-purple-600{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-purple-700{--tw-text-opacity:1;color:rgb(126 34 206/var(--tw-text-opacity,1))}.text-purple-800{--tw-text-opacity:1;color:rgb(107 33 168/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-teal-600{--tw-text-opacity:1;color:rgb(13 148 136/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-yellow-700{--tw-text-opacity:1;color:rgb(161 98 7/var(--tw-text-opacity,1))}.text-yellow-800{--tw-text-opacity:1;color:rgb(133 77 14/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.placeholder-gray-400::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.placeholder-gray-400::placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.accent-blue-600{accent-color:#2563eb}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-70{opacity:.7}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:border-blue-400:hover{--tw-border-opacity:1;border-color:rgb(96 165 250/var(--tw-border-opacity,1))}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-green-100:hover{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.hover\:bg-green-200:hover{--tw-bg-opacity:1;background-color:rgb(187 247 208/var(--tw-bg-opacity,1))}.hover\:bg-indigo-100:hover{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity,1))}.hover\:bg-indigo-200:hover{--tw-bg-opacity:1;background-color:rgb(199 210 254/var(--tw-bg-opacity,1))}.hover\:bg-indigo-700:hover{--tw-bg-opacity:1;background-color:rgb(67 56 202/var(--tw-bg-opacity,1))}.hover\:bg-purple-200:hover{--tw-bg-opacity:1;background-color:rgb(233 213 255/var(--tw-bg-opacity,1))}.hover\:bg-red-100:hover{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.hover\:bg-red-200:hover{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity,1))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:bg-yellow-100:hover{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.hover\:text-gray-800:hover{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.hover\:text-indigo-800:hover{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity,1))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.hover\:text-red-800:hover{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.hover\:text-teal-800:hover{--tw-text-opacity:1;color:rgb(17 94 89/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.focus\:border-amber-500:focus{--tw-border-opacity:1;border-color:rgb(245 158 11/var(--tw-border-opacity,1))}.focus\:border-blue-500:focus{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-1:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-amber-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(245 158 11/var(--tw-ring-opacity,1))}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.focus\:ring-gray-400:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(156 163 175/var(--tw-ring-opacity,1))}.focus\:ring-gray-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(107 114 128/var(--tw-ring-opacity,1))}.focus\:ring-green-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(34 197 94/var(--tw-ring-opacity,1))}.focus\:ring-indigo-400:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(129 140 248/var(--tw-ring-opacity,1))}.focus\:ring-indigo-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(99 102 241/var(--tw-ring-opacity,1))}.focus\:ring-orange-400:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(251 146 60/var(--tw-ring-opacity,1))}.focus\:ring-purple-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(168 85 247/var(--tw-ring-opacity,1))}.focus\:ring-red-400:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.focus\:ring-red-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(239 68 68/var(--tw-ring-opacity,1))}.focus\:ring-yellow-400:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.focus\:ring-yellow-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(234 179 8/var(--tw-ring-opacity,1))}.focus\:ring-offset-0:focus{--tw-ring-offset-width:0px}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.has-\[\:checked\]\:border-blue-500:has(:checked){--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.has-\[\:checked\]\:bg-blue-50:has(:checked){--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}@media (min-width:640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:block{display:block}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.lg\:col-span-4{grid-column:span 4/span 4}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:px-8{padding-left:2rem;padding-right:2rem}}@media (prefers-color-scheme:dark){.dark\:divide-gray-700>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(55 65 81/var(--tw-divide-opacity,1))}.dark\:border-amber-700{--tw-border-opacity:1;border-color:rgb(180 83 9/var(--tw-border-opacity,1))}.dark\:border-blue-400{--tw-border-opacity:1;border-color:rgb(96 165 250/var(--tw-border-opacity,1))}.dark\:border-blue-700{--tw-border-opacity:1;border-color:rgb(29 78 216/var(--tw-border-opacity,1))}.dark\:border-blue-800{--tw-border-opacity:1;border-color:rgb(30 64 175/var(--tw-border-opacity,1))}.dark\:border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.dark\:border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.dark\:border-green-700{--tw-border-opacity:1;border-color:rgb(21 128 61/var(--tw-border-opacity,1))}.dark\:border-green-800{--tw-border-opacity:1;border-color:rgb(22 101 52/var(--tw-border-opacity,1))}.dark\:border-purple-700{--tw-border-opacity:1;border-color:rgb(126 34 206/var(--tw-border-opacity,1))}.dark\:border-purple-800{--tw-border-opacity:1;border-color:rgb(107 33 168/var(--tw-border-opacity,1))}.dark\:border-red-700{--tw-border-opacity:1;border-color:rgb(185 28 28/var(--tw-border-opacity,1))}.dark\:border-red-800{--tw-border-opacity:1;border-color:rgb(153 27 27/var(--tw-border-opacity,1))}.dark\:bg-amber-900{--tw-bg-opacity:1;background-color:rgb(120 53 15/var(--tw-bg-opacity,1))}.dark\:bg-amber-900\/20{background-color:rgba(120,53,15,.2)}.dark\:bg-amber-900\/30{background-color:rgba(120,53,15,.3)}.dark\:bg-amber-900\/40{background-color:rgba(120,53,15,.4)}.dark\:bg-black\/60{background-color:rgba(0,0,0,.6)}.dark\:bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.dark\:bg-blue-900\/20{background-color:rgba(30,58,138,.2)}.dark\:bg-blue-900\/30{background-color:rgba(30,58,138,.3)}.dark\:bg-blue-900\/40{background-color:rgba(30,58,138,.4)}.dark\:bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.dark\:bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.dark\:bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.dark\:bg-gray-900\/30{background-color:rgba(17,24,39,.3)}.dark\:bg-gray-950{--tw-bg-opacity:1;background-color:rgb(3 7 18/var(--tw-bg-opacity,1))}.dark\:bg-green-900{--tw-bg-opacity:1;background-color:rgb(20 83 45/var(--tw-bg-opacity,1))}.dark\:bg-green-900\/20{background-color:rgba(20,83,45,.2)}.dark\:bg-green-900\/40{background-color:rgba(20,83,45,.4)}.dark\:bg-indigo-900{--tw-bg-opacity:1;background-color:rgb(49 46 129/var(--tw-bg-opacity,1))}.dark\:bg-indigo-900\/40{background-color:rgba(49,46,129,.4)}.dark\:bg-orange-900\/30{background-color:rgba(124,45,18,.3)}.dark\:bg-orange-900\/40{background-color:rgba(124,45,18,.4)}.dark\:bg-purple-900{--tw-bg-opacity:1;background-color:rgb(88 28 135/var(--tw-bg-opacity,1))}.dark\:bg-purple-900\/10{background-color:rgba(88,28,135,.1)}.dark\:bg-purple-900\/20{background-color:rgba(88,28,135,.2)}.dark\:bg-purple-900\/30{background-color:rgba(88,28,135,.3)}.dark\:bg-purple-900\/40{background-color:rgba(88,28,135,.4)}.dark\:bg-red-900\/20{background-color:rgba(127,29,29,.2)}.dark\:bg-red-900\/30{background-color:rgba(127,29,29,.3)}.dark\:bg-red-900\/40{background-color:rgba(127,29,29,.4)}.dark\:bg-red-950{--tw-bg-opacity:1;background-color:rgb(69 10 10/var(--tw-bg-opacity,1))}.dark\:bg-yellow-900{--tw-bg-opacity:1;background-color:rgb(113 63 18/var(--tw-bg-opacity,1))}.dark\:text-amber-200{--tw-text-opacity:1;color:rgb(253 230 138/var(--tw-text-opacity,1))}.dark\:text-amber-300{--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.dark\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-blue-200{--tw-text-opacity:1;color:rgb(191 219 254/var(--tw-text-opacity,1))}.dark\:text-blue-300{--tw-text-opacity:1;color:rgb(147 197 253/var(--tw-text-opacity,1))}.dark\:text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.dark\:text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.dark\:text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.dark\:text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.dark\:text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.dark\:text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.dark\:text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.dark\:text-green-200{--tw-text-opacity:1;color:rgb(187 247 208/var(--tw-text-opacity,1))}.dark\:text-green-300{--tw-text-opacity:1;color:rgb(134 239 172/var(--tw-text-opacity,1))}.dark\:text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.dark\:text-indigo-200{--tw-text-opacity:1;color:rgb(199 210 254/var(--tw-text-opacity,1))}.dark\:text-indigo-300{--tw-text-opacity:1;color:rgb(165 180 252/var(--tw-text-opacity,1))}.dark\:text-indigo-400{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity,1))}.dark\:text-orange-300{--tw-text-opacity:1;color:rgb(253 186 116/var(--tw-text-opacity,1))}.dark\:text-purple-200{--tw-text-opacity:1;color:rgb(233 213 255/var(--tw-text-opacity,1))}.dark\:text-purple-300{--tw-text-opacity:1;color:rgb(216 180 254/var(--tw-text-opacity,1))}.dark\:text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.dark\:text-teal-400{--tw-text-opacity:1;color:rgb(45 212 191/var(--tw-text-opacity,1))}.dark\:text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.dark\:text-yellow-200{--tw-text-opacity:1;color:rgb(254 240 138/var(--tw-text-opacity,1))}.dark\:placeholder-gray-500::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.dark\:placeholder-gray-500::placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.dark\:hover\:border-blue-500:hover{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.dark\:hover\:border-gray-600:hover{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.dark\:hover\:bg-blue-900\/20:hover{background-color:rgba(30,58,138,.2)}.dark\:hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.dark\:hover\:bg-gray-700\/30:hover{background-color:rgba(55,65,81,.3)}.dark\:hover\:bg-gray-700\/40:hover{background-color:rgba(55,65,81,.4)}.dark\:hover\:bg-gray-700\/50:hover{background-color:rgba(55,65,81,.5)}.dark\:hover\:bg-green-900\/70:hover{background-color:rgba(20,83,45,.7)}.dark\:hover\:bg-indigo-900\/70:hover{background-color:rgba(49,46,129,.7)}.dark\:hover\:bg-purple-900\/70:hover{background-color:rgba(88,28,135,.7)}.dark\:hover\:bg-red-900\/70:hover{background-color:rgba(127,29,29,.7)}.dark\:hover\:text-blue-200:hover{--tw-text-opacity:1;color:rgb(191 219 254/var(--tw-text-opacity,1))}.dark\:hover\:text-gray-200:hover{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.dark\:hover\:text-gray-300:hover{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.dark\:hover\:text-indigo-200:hover{--tw-text-opacity:1;color:rgb(199 210 254/var(--tw-text-opacity,1))}.dark\:hover\:text-red-200:hover{--tw-text-opacity:1;color:rgb(254 202 202/var(--tw-text-opacity,1))}.dark\:hover\:text-red-400:hover{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.dark\:hover\:text-teal-200:hover{--tw-text-opacity:1;color:rgb(153 246 228/var(--tw-text-opacity,1))}.dark\:hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.dark\:focus\:ring-indigo-400:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(129 140 248/var(--tw-ring-opacity,1))}.dark\:has-\[\:checked\]\:bg-gray-700:has(:checked){--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}}@media (min-width:1024px){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:col-span-1{grid-column:span 1/span 1}.lg\:col-span-3{grid-column:span 3/span 3}} diff --git a/web/lang/de.php b/web/lang/de.php index 966de8d..4776088 100644 --- a/web/lang/de.php +++ b/web/lang/de.php @@ -152,6 +152,12 @@ 'dashboard_inactive' => 'Inaktiv', 'dashboard_recent_failures' => 'Aktuelle Fehler', 'dashboard_jobs_by_user' => 'Jobs nach Benutzer', + 'dashboard_exec_stats' => 'Ausführungsstatistik', + 'dashboard_exec_today' => 'Heute', + 'dashboard_exec_last_24h' => 'Letzte 24 Stunden', + 'dashboard_exec_executed' => 'Ausgeführt', + 'dashboard_exec_failed' => 'Fehlerhaft', + 'dashboard_output_preview' => 'Ausgabe', // ------------------------------------------------------------------------- // Cron-Jobs diff --git a/web/lang/en.php b/web/lang/en.php index 34050bb..12dd98b 100644 --- a/web/lang/en.php +++ b/web/lang/en.php @@ -152,6 +152,12 @@ 'dashboard_inactive' => 'Inactive', 'dashboard_recent_failures' => 'Recent Failures', 'dashboard_jobs_by_user' => 'Jobs by User', + 'dashboard_exec_stats' => 'Execution Stats', + 'dashboard_exec_today' => 'Today', + 'dashboard_exec_last_24h' => 'Last 24 Hours', + 'dashboard_exec_executed' => 'Executed', + 'dashboard_exec_failed' => 'Failed', + 'dashboard_output_preview' => 'Output', // ------------------------------------------------------------------------- // Cron jobs diff --git a/web/src/Controller/CronController.php b/web/src/Controller/CronController.php index 902eff1..6ce87ac 100644 --- a/web/src/Controller/CronController.php +++ b/web/src/Controller/CronController.php @@ -244,6 +244,7 @@ public function index(array $params): void 'filterResult' => $filterResult, 'filterActive' => $filterActive, 'users' => $users, + 'multiUser' => count($users) > 1, 'allTargets' => $allTargets, 'isAdmin' => SessionManager::hasRole('admin'), 'pageSize' => $pageSize, diff --git a/web/src/Controller/DashboardController.php b/web/src/Controller/DashboardController.php index bd3cd8d..72d44d9 100644 --- a/web/src/Controller/DashboardController.php +++ b/web/src/Controller/DashboardController.php @@ -29,6 +29,12 @@ */ class DashboardController extends BaseController { + /** + * Set to false to disable the execution-statistics widget and skip the + * GET /stats agent call entirely (instant rollback if performance issues arise). + */ + private const SHOW_EXECUTION_STATS = true; + // ------------------------------------------------------------------------- // Actions // ------------------------------------------------------------------------- @@ -66,18 +72,23 @@ public function index(array $params): void // Fetch data from the host agent (three requests in parallel) // ------------------------------------------------------------------ try { - // Dispatch all three GET requests concurrently via Guzzle promises, + // Dispatch all GET requests concurrently via Guzzle promises, // reducing wall-clock time from ~sum(latencies) to ~max(latency). - $results = $agent->getMultiple([ + $batch = [ 'crons' => ['path' => '/crons'], // Fetch more than needed so that filtering maintenance skips // still leaves enough entries after the -4 exit-code filter. 'history' => ['path' => '/history', 'query' => ['limit' => 50, 'status' => 'failed']], 'tags' => ['path' => '/tags'], - ]); + ]; + if (self::SHOW_EXECUTION_STATS) { + $batch['execstats'] = ['path' => '/stats']; + } + $results = $agent->getMultiple($batch); $jobs = $results['crons']['data'] ?? []; $recentFailures = $results['history']['data'] ?? []; $tags = $results['tags']['data'] ?? []; + $executionStats = self::SHOW_EXECUTION_STATS ? ($results['execstats'] ?? []) : []; } catch (\RuntimeException $e) { $this->logger->error('DashboardController: agent request failed', [ 'message' => $e->getMessage(), @@ -146,10 +157,13 @@ public function index(array $params): void // Render // ------------------------------------------------------------------ $this->render('dashboard.php', $this->translator()->t('dashboard_title'), [ - 'jobs' => $jobs, - 'recentFailures' => $recentFailures, - 'tags' => $tags, - 'stats' => $stats, + 'jobs' => $jobs, + 'recentFailures' => $recentFailures, + 'tags' => $tags, + 'stats' => $stats, + 'multiUser' => count($byUser) > 1, + 'executionStats' => $executionStats, + 'showExecutionStats' => self::SHOW_EXECUTION_STATS, ], '/dashboard'); } } diff --git a/web/src/Controller/ExportController.php b/web/src/Controller/ExportController.php index 7b608d7..4e0c3ba 100644 --- a/web/src/Controller/ExportController.php +++ b/web/src/Controller/ExportController.php @@ -79,8 +79,9 @@ public function index(array $params): void )); $this->render('export.php', $this->translator()->t('export_title'), [ - 'tags' => $tags, - 'users' => $users, + 'tags' => $tags, + 'users' => $users, + 'multiUser' => count($users) > 1, ], '/export'); } @@ -128,7 +129,13 @@ public function download(array $params): void $secret = (string) ($agent['hmac_secret'] ?? ''); $path = '/export'; $queryString = '?' . http_build_query($query); - $signature = hash_hmac('sha256', 'GET' . $path . '', $secret); + $userId = \Cronmanager\Web\Session\SessionManager::getUserId() ?? 0; + $userName = \Cronmanager\Web\Session\SessionManager::getUsername() ?? ''; + $signature = hash_hmac( + 'sha256', + 'GET' . $path . '' . "\0" . $userId . "\0" . $userName, + $secret + ); $sslVerify = (bool) ($agent['ssl_verify'] ?? true); $caBundle = (string) ($agent['ssl_ca_bundle'] ?? ''); diff --git a/web/src/Controller/TimelineController.php b/web/src/Controller/TimelineController.php index 5e168ef..a6e06ef 100644 --- a/web/src/Controller/TimelineController.php +++ b/web/src/Controller/TimelineController.php @@ -179,6 +179,7 @@ public function index(array $params): void 'history' => $history, 'tags' => $tags, 'users' => $users, + 'multiUser' => count($users) > 1, 'allTargets' => $allTargets, 'total' => $total, 'limit' => $limit, diff --git a/web/templates/cron/detail.php b/web/templates/cron/detail.php index 1a9e157..12fce94 100644 --- a/web/templates/cron/detail.php +++ b/web/templates/cron/detail.php @@ -492,7 +492,7 @@ class="hidden flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 // Exit code badge if ($isRunning) { - $exitBadge = '' + $exitBadge = '' . htmlspecialchars($t('status_running'), ENT_QUOTES, 'UTF-8') . ''; } elseif ($exitCode !== null && (int) $exitCode === -5) { diff --git a/web/templates/cron/list.php b/web/templates/cron/list.php index 5e488f6..10c2df7 100644 --- a/web/templates/cron/list.php +++ b/web/templates/cron/list.php @@ -32,6 +32,7 @@ $filterSearch = isset($filterSearch) ? (string) $filterSearch : ''; $filterResult = isset($filterResult) ? (string) $filterResult : ''; $filterActive = isset($filterActive) ? (string) $filterActive : ''; +$multiUser = isset($multiUser) ? (bool) $multiUser : true; $isAdmin = isset($isAdmin) && (bool) $isAdmin; $targetsInMaintenance = isset($targetsInMaintenance) && is_array($targetsInMaintenance) ? $targetsInMaintenance : []; @@ -181,7 +182,8 @@ class="w-full border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 t - + +
+ 1): ?> @@ -440,9 +443,11 @@ class="rounded border-gray-300 dark:border-gray-600 text-blue-600 + + @@ -476,6 +481,7 @@ class="rounded border-gray-300 dark:border-gray-600 text-blue-600 $crontabOk = !isset($job['crontab_ok']) || (bool) $job['crontab_ok']; $lastRun = (string) ($job['last_run'] ?? ''); $exitCode = isset($job['last_exit_code']) ? (int) $job['last_exit_code'] : null; + $isRunning = !empty($job['is_running']); $limitSeconds = isset($job['execution_limit_seconds']) && $job['execution_limit_seconds'] !== null ? (int) $job['execution_limit_seconds'] : null; @@ -483,7 +489,10 @@ class="rounded border-gray-300 dark:border-gray-600 text-blue-600 $runInMaint = !empty($job['run_in_maintenance']); // Exit code badge style - if ($exitCode === null) { + if ($isRunning) { + $exitBadge = '' + . htmlspecialchars($t('status_running'), ENT_QUOTES, 'UTF-8') . ''; + } elseif ($exitCode === null) { $exitBadge = ''; } elseif ($exitCode === -4) { $exitBadge = '' @@ -533,10 +542,12 @@ class="text-blue-600 hover:underline font-medium"> - + + + diff --git a/web/templates/cron/monitor.php b/web/templates/cron/monitor.php index 33aa629..e900311 100644 --- a/web/templates/cron/monitor.php +++ b/web/templates/cron/monitor.php @@ -624,7 +624,7 @@ function renderRecent(recent) { ? '' + esc(L.local) + '' : '' + esc(tgt) + ''; var exitBadge = code === null - ? '' + esc(L.running) + '' + ? '' + esc(L.running) + '' : code === 0 ? '0' : '' + esc(String(code)) + ''; diff --git a/web/templates/dashboard.php b/web/templates/dashboard.php index a094db5..25b66ff 100644 --- a/web/templates/dashboard.php +++ b/web/templates/dashboard.php @@ -24,10 +24,16 @@ /** @var \Cronmanager\Web\I18n\Translator $translator */ $t = fn(string $k, array $r = []): string => $translator->t($k, $r); -$jobs = isset($jobs) && is_array($jobs) ? $jobs : []; -$recentFailures = isset($recentFailures) && is_array($recentFailures) ? $recentFailures : []; -$tags = isset($tags) && is_array($tags) ? $tags : []; -$stats = isset($stats) && is_array($stats) ? $stats : []; +// ── Feature toggles ────────────────────────────────────────────────────────── +// Set to false to instantly hide a feature without touching the controller. +$showOutputPreview = true; // Ausgabe-Vorschau in der Fehler-Tabelle + +$jobs = isset($jobs) && is_array($jobs) ? $jobs : []; +$recentFailures = isset($recentFailures) && is_array($recentFailures) ? $recentFailures : []; +$tags = isset($tags) && is_array($tags) ? $tags : []; +$stats = isset($stats) && is_array($stats) ? $stats : []; +$executionStats = isset($executionStats) && is_array($executionStats) ? $executionStats : []; +$showExecutionStats = isset($showExecutionStats) ? (bool) $showExecutionStats : false; $total = (int) ($stats['total'] ?? 0); $active = (int) ($stats['active'] ?? 0); @@ -35,6 +41,7 @@ $tagsCount = (int) ($stats['tagsCount'] ?? 0); $failedLast24h = (int) ($stats['failedLast24h'] ?? 0); $byUser = (array) ($stats['byUser'] ?? []); +$multiUser = isset($multiUser) ? (bool) $multiUser : true; ?> -
+
- -
+ +

@@ -154,9 +161,11 @@ + + @@ -169,6 +178,11 @@ + + + + + @@ -184,6 +198,14 @@ $duration = isset($entry['duration_seconds']) ? round((float) $entry['duration_seconds'], 1) . 's' : '–'; + // Truncate output to last 120 chars for the preview column + $outputRaw = isset($entry['output']) ? trim((string) $entry['output']) : ''; + $outputPreview = ''; + if ($outputRaw !== '') { + $outputPreview = mb_strlen($outputRaw) > 120 + ? '…' . mb_substr($outputRaw, -120) + : $outputRaw; + } // Deep-link to Timeline pre-filtered for this specific job/target/status. // _direct=1 prevents saved date-range cookies from hiding the entry. $timelineParams = array_filter([ @@ -206,9 +228,11 @@ class="text-blue-600 hover:underline font-medium"> + + @@ -223,12 +247,23 @@ class="text-blue-600 hover:underline font-medium"> - + - + + + + + + + + + + + + @@ -237,8 +272,74 @@ class="text-blue-600 hover:underline font-medium">

- -
+ + +
+
+

+ +

+
+
+ + +
+

+ +

+
+ + + + +
+
+ + + + + + +
+
+
+ +
+

+ +

+
+ + + + +
+
+ + + + + + +
+
+
+
+ + +
+ + + +
+

@@ -264,7 +365,6 @@ class="text-blue-600 hover:underline font-medium"> $count): ?> @@ -285,8 +385,8 @@ class="text-blue-600 hover:underline">

-
+