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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions book/src/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,36 @@ back at the blocks section.
> landing. It's admin-only input — the intentional escape hatch — so
> only paste HTML you trust.

### Schedules

Cron-scheduled, run-to-completion jobs (Admin-only) — nightly ETL,
report generation, cache warm-ups. A schedule picks one of your
containerized apps and runs **that app's image** with the same
environment, volumes, resource limits and registry credentials a normal
replica gets, optionally overriding the command (one argv element per
line; leave it empty to run the app's own `container-cmd`, or the
image's baked `CMD`). External apps can't be scheduled — there is
nothing to run.

Semantics worth knowing:

- **No run on creation.** A new schedule waits for its next cron
occurrence (times are UTC).
- **Downtime collapses.** If the server was down across several
occurrences, the schedule fires **once** on the next tick — ETL
semantics, not a message queue.
- **Leader-only in HA.** With several active-active instances, only the
scaler leader fires schedules, and a database claim backstops a split
brain so an occurrence never double-fires.
- **Timeout.** Each run is capped — 1 hour by default, or the
per-schedule *Timeout (minutes)* when set. A run over the cap is
killed and recorded as an error.
- **History.** The *Latest runs* table shows each run's status (`ok` /
`failed` = non-zero exit / `error` = couldn't run), exit code,
duration and an expandable log tail.
- **Alerts.** A failed run raises a `job-failed` alert through the
webhook configured in the System tab (see below).

### Groups
Groups (`/admin/groups`, admin-only) gate which apps a user sees. They're
**derived**, not a separate table: a group exists as long as a user belongs
Expand Down
34 changes: 34 additions & 0 deletions crates/ruscker-admin/assets/i18n/en/landing.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -936,3 +936,37 @@ admin-disk-reclaim = Reclaim space
admin-disk-reclaim-hint = Prune dangling images + build cache (host-safe — never a tagged image or any container).
admin-disk-reclaim-confirm = Reclaim space? Prunes dangling images and the build cache (no tagged image or container is removed).
admin-disk-flash-reclaimed = Space reclaimed (dangling images + build cache).

# Schedules — cron jobs (#986 slice C)
admin-nav-schedules = Schedules
admin-schedules-title = Schedules
admin-schedules-subtitle = Run an app's image to completion on a cron schedule (ETL, reports).
admin-schedules-create = New schedule
admin-schedules-spec = App
admin-schedules-cron = Cron
admin-schedules-cron-help = Standard 5-field cron, UTC. Examples: "0 3 * * *" = every day at 03:00; "*/15 * * * *" = every 15 minutes.
admin-schedules-cmd = Command
admin-schedules-cmd-help = One line per argument (argv). Empty = the app's own command (its container-cmd, else the image's CMD).
admin-schedules-timeout = Timeout (minutes)
admin-schedules-timeout-help = Wall-clock cap for one run. Empty = 1 hour.
admin-schedules-next-run = Next run
admin-schedules-last-run = Last run
admin-schedules-enabled = Enabled
admin-schedules-disabled = Disabled
admin-schedules-toggle = Enable/disable
admin-schedules-delete = Delete
admin-schedules-confirm-delete = Delete this schedule? Its run history goes with it.
admin-schedules-empty = No schedules yet — create one above.
admin-schedules-runs-title = Latest runs
admin-schedules-runs-empty = No runs yet.
admin-schedules-runs-started = Started
admin-schedules-runs-status = Status
admin-schedules-runs-exit = Exit code
admin-schedules-runs-duration = Duration
admin-schedules-log = Log
admin-schedules-flash-created = Schedule created. It fires at the next cron occurrence (no run on creation).
admin-schedules-flash-deleted = Schedule deleted.
admin-schedules-flash-toggled = Schedule updated.
admin-schedules-flash-bad-cron = Invalid cron expression — use the 5-field form, e.g. "0 3 * * *".
admin-schedules-flash-bad-spec = Unknown app, or the app has no container image to run.
admin-schedules-flash-error = The operation failed — check the server logs.
34 changes: 34 additions & 0 deletions crates/ruscker-admin/assets/i18n/es/landing.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -936,3 +936,37 @@ admin-disk-reclaim = Recuperar espacio
admin-disk-reclaim-hint = Limpia imágenes dangling + caché de compilación (seguro — nunca una imagen con tag ni un contenedor).
admin-disk-reclaim-confirm = ¿Recuperar espacio? Limpia imágenes dangling y la caché de compilación (no se elimina ninguna imagen con tag ni contenedor).
admin-disk-flash-reclaimed = Espacio recuperado (imágenes dangling + caché de compilación).

# Programaciones — cron jobs (#986 parte C)
admin-nav-schedules = Programaciones
admin-schedules-title = Programaciones
admin-schedules-subtitle = Ejecuta la imagen de una app hasta terminar según un horario cron (ETL, informes).
admin-schedules-create = Nueva programación
admin-schedules-spec = App
admin-schedules-cron = Cron
admin-schedules-cron-help = Cron estándar de 5 campos, en UTC. Ejemplos: "0 3 * * *" = cada día a las 03:00; "*/15 * * * *" = cada 15 minutos.
admin-schedules-cmd = Comando
admin-schedules-cmd-help = Un argumento por línea (argv). Vacío = el comando de la propia app (su container-cmd, si no el CMD de la imagen).
admin-schedules-timeout = Timeout (minutos)
admin-schedules-timeout-help = Límite de duración de una ejecución. Vacío = 1 hora.
admin-schedules-next-run = Próxima ejecución
admin-schedules-last-run = Última ejecución
admin-schedules-enabled = Activa
admin-schedules-disabled = Inactiva
admin-schedules-toggle = Activar/desactivar
admin-schedules-delete = Eliminar
admin-schedules-confirm-delete = ¿Eliminar esta programación? Su historial de ejecuciones se va con ella.
admin-schedules-empty = Aún no hay programaciones — crea una arriba.
admin-schedules-runs-title = Últimas ejecuciones
admin-schedules-runs-empty = Aún no hay ejecuciones.
admin-schedules-runs-started = Inicio
admin-schedules-runs-status = Estado
admin-schedules-runs-exit = Código de salida
admin-schedules-runs-duration = Duración
admin-schedules-log = Log
admin-schedules-flash-created = Programación creada. Se dispara en la próxima ocurrencia del cron (no se ejecuta al crearla).
admin-schedules-flash-deleted = Programación eliminada.
admin-schedules-flash-toggled = Programación actualizada.
admin-schedules-flash-bad-cron = Expresión cron no válida — usa la forma de 5 campos, p. ej. "0 3 * * *".
admin-schedules-flash-bad-spec = App desconocida, o la app no tiene imagen de contenedor que ejecutar.
admin-schedules-flash-error = La operación falló — revisa los logs del servidor.
34 changes: 34 additions & 0 deletions crates/ruscker-admin/assets/i18n/fr/landing.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -936,3 +936,37 @@ admin-disk-reclaim = Récupérer de l'espace
admin-disk-reclaim-hint = Purge les images dangling + le cache de build (sûr — jamais une image taguée ni un conteneur).
admin-disk-reclaim-confirm = Récupérer de l'espace ? Purge les images dangling et le cache de build (aucune image taguée ni conteneur supprimé).
admin-disk-flash-reclaimed = Espace récupéré (images dangling + cache de build).

# Planifications — cron jobs (#986 tranche C)
admin-nav-schedules = Planifications
admin-schedules-title = Planifications
admin-schedules-subtitle = Exécute l'image d'une app jusqu'au bout selon un horaire cron (ETL, rapports).
admin-schedules-create = Nouvelle planification
admin-schedules-spec = App
admin-schedules-cron = Cron
admin-schedules-cron-help = Cron standard à 5 champs, en UTC. Exemples : "0 3 * * *" = chaque jour à 03:00 ; "*/15 * * * *" = toutes les 15 minutes.
admin-schedules-cmd = Commande
admin-schedules-cmd-help = Un argument par ligne (argv). Vide = la commande de l'app elle-même (son container-cmd, sinon le CMD de l'image).
admin-schedules-timeout = Timeout (minutes)
admin-schedules-timeout-help = Durée maximale d'une exécution. Vide = 1 heure.
admin-schedules-next-run = Prochaine exécution
admin-schedules-last-run = Dernière exécution
admin-schedules-enabled = Active
admin-schedules-disabled = Inactive
admin-schedules-toggle = Activer/désactiver
admin-schedules-delete = Supprimer
admin-schedules-confirm-delete = Supprimer cette planification ? Son historique d'exécutions part avec elle.
admin-schedules-empty = Aucune planification pour l'instant — créez-en une ci-dessus.
admin-schedules-runs-title = Dernières exécutions
admin-schedules-runs-empty = Aucune exécution pour l'instant.
admin-schedules-runs-started = Début
admin-schedules-runs-status = Statut
admin-schedules-runs-exit = Code de sortie
admin-schedules-runs-duration = Durée
admin-schedules-log = Log
admin-schedules-flash-created = Planification créée. Elle se déclenche à la prochaine occurrence du cron (pas d'exécution à la création).
admin-schedules-flash-deleted = Planification supprimée.
admin-schedules-flash-toggled = Planification mise à jour.
admin-schedules-flash-bad-cron = Expression cron invalide — utilisez la forme à 5 champs, p. ex. "0 3 * * *".
admin-schedules-flash-bad-spec = App inconnue, ou l'app n'a pas d'image de conteneur à exécuter.
admin-schedules-flash-error = L'opération a échoué — consultez les logs du serveur.
34 changes: 34 additions & 0 deletions crates/ruscker-admin/assets/i18n/pt/landing.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -940,3 +940,37 @@ admin-disk-reclaim = Recuperar espaço
admin-disk-reclaim-hint = Limpa imagens dangling + cache de build (seguro — nunca remove imagem nomeada nem container).
admin-disk-reclaim-confirm = Recuperar espaço? Limpa imagens dangling e o cache de build (nenhuma imagem nomeada ou container é removido).
admin-disk-flash-reclaimed = Espaço recuperado (imagens dangling + cache de build).

# Agendamentos — cron jobs (#986 fatia C)
admin-nav-schedules = Agendamentos
admin-schedules-title = Agendamentos
admin-schedules-subtitle = Executa a imagem de um app até o fim em um horário cron (ETL, relatórios).
admin-schedules-create = Novo agendamento
admin-schedules-spec = App
admin-schedules-cron = Cron
admin-schedules-cron-help = Cron padrão de 5 campos, em UTC. Exemplos: "0 3 * * *" = todo dia às 03:00; "*/15 * * * *" = a cada 15 minutos.
admin-schedules-cmd = Comando
admin-schedules-cmd-help = Um argumento por linha (argv). Vazio = o comando do próprio app (o container-cmd, senão o CMD da imagem).
admin-schedules-timeout = Timeout (minutos)
admin-schedules-timeout-help = Limite de duração de uma execução. Vazio = 1 hora.
admin-schedules-next-run = Próxima execução
admin-schedules-last-run = Última execução
admin-schedules-enabled = Ativo
admin-schedules-disabled = Inativo
admin-schedules-toggle = Ativar/desativar
admin-schedules-delete = Excluir
admin-schedules-confirm-delete = Excluir este agendamento? O histórico de execuções vai junto.
admin-schedules-empty = Nenhum agendamento ainda — crie um acima.
admin-schedules-runs-title = Últimas execuções
admin-schedules-runs-empty = Nenhuma execução ainda.
admin-schedules-runs-started = Início
admin-schedules-runs-status = Status
admin-schedules-runs-exit = Código de saída
admin-schedules-runs-duration = Duração
admin-schedules-log = Log
admin-schedules-flash-created = Agendamento criado. Dispara na próxima ocorrência do cron (não executa ao criar).
admin-schedules-flash-deleted = Agendamento excluído.
admin-schedules-flash-toggled = Agendamento atualizado.
admin-schedules-flash-bad-cron = Expressão cron inválida — use a forma de 5 campos, ex.: "0 3 * * *".
admin-schedules-flash-bad-spec = App desconhecido, ou o app não tem imagem de container para executar.
admin-schedules-flash-error = A operação falhou — verifique os logs do servidor.
5 changes: 3 additions & 2 deletions crates/ruscker-admin/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ impl Role {
// authenticated-end-user role (group-based card visibility,
// #155), so it reaches NO admin section.
"dashboard" | "specs" | "images" => *self >= Role::Editor,
// Everything else is admin-only.
// Everything else — credentials, landing, users, groups,
// logs, disk, audit, system, schedules — is admin-only.
_ => *self == Role::Admin,
}
}
Expand Down Expand Up @@ -752,7 +753,7 @@ mod tests {
assert_eq!(Role::Editor.home(), "/admin/specs");
assert_eq!(Role::Admin.home(), "/admin/specs");
// Admin-only sections.
for sec in ["credentials", "landing", "blocks", "audit"] {
for sec in ["credentials", "landing", "blocks", "audit", "schedules"] {
assert!(!Role::Viewer.can_access_section(sec));
assert!(!Role::Editor.can_access_section(sec));
assert!(Role::Admin.can_access_section(sec));
Expand Down
86 changes: 86 additions & 0 deletions crates/ruscker-admin/src/db/schedules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ impl Schedule {
}
}

/// One row of run history, joined with its schedule so the admin UI
/// (#986 slice C) can show which SPEC ran without a second query. A
/// run whose schedule was deleted disappears with it (`ON DELETE
/// CASCADE` on `schedule_runs.schedule_id`).
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct RunRow {
pub schedule_id: i64,
pub spec_id: String,
pub started_at: DateTime<Utc>,
/// `ok` | `failed` | `error` (see [`RunStatus`]).
pub status: String,
pub exit_code: Option<i64>,
pub log_tail: Option<String>,
pub duration_ms: Option<i64>,
}

/// Outcome bucket for a run row. `Error` = the job could not run at
/// all; `Failed` = ran and exited non-zero.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -69,6 +85,41 @@ pub async fn list_all(db: &ConfigDb) -> Result<Vec<Schedule>> {
Ok(rows)
}

/// The most recent runs across ALL schedules, newest first — the
/// "Latest runs" table on `/admin/schedules`.
pub async fn recent_runs(db: &ConfigDb, limit: i64) -> Result<Vec<RunRow>> {
// The JOIN resolves spec_id; the index on (schedule_id, started_at)
// doesn't help a global ORDER BY, but the table is small and the
// LIMIT keeps the scan bounded in practice.
let rows = match db {
ConfigDb::Sqlite(pool) => sqlx::query_as::<_, RunRow>(
"SELECT r.schedule_id, s.spec_id, r.started_at, r.status, r.exit_code,
r.log_tail, r.duration_ms
FROM schedule_runs r
JOIN schedules s ON s.id = r.schedule_id
ORDER BY r.started_at DESC
LIMIT ?",
)
.bind(limit)
.fetch_all(pool)
.await
.context("recent schedule runs (sqlite)")?,
ConfigDb::Postgres(pool) => sqlx::query_as::<_, RunRow>(
"SELECT r.schedule_id, s.spec_id, r.started_at, r.status, r.exit_code,
r.log_tail, r.duration_ms
FROM schedule_runs r
JOIN schedules s ON s.id = r.schedule_id
ORDER BY r.started_at DESC
LIMIT $1",
)
.bind(limit)
.fetch_all(pool)
.await
.context("recent schedule runs (postgres)")?,
};
Ok(rows)
}

pub async fn insert(
db: &ConfigDb,
spec_id: &str,
Expand Down Expand Up @@ -312,6 +363,37 @@ mod tests {
assert!(list_all(&db).await.unwrap().is_empty());
}

/// #986 slice C: `recent_runs` joins the spec back in, orders
/// newest-first, and honours the limit.
#[tokio::test]
async fn recent_runs_joins_spec_and_orders_newest_first() {
let db = mem_db().await;
insert(&db, "etl-a", "0 3 * * *", None, None, None).await.unwrap();
insert(&db, "etl-b", "0 4 * * *", None, None, None).await.unwrap();
let all = list_all(&db).await.unwrap();
let (a, b) = (all[0].id, all[1].id);
assert_eq!(all[0].spec_id, "etl-a");

let t0: DateTime<Utc> = "2026-07-13T03:00:00Z".parse().unwrap();
let t1: DateTime<Utc> = "2026-07-13T04:00:00Z".parse().unwrap();
record_run(&db, a, t0, RunStatus::Ok, Some(0), "done", Some(1500)).await.unwrap();
record_run(&db, b, t1, RunStatus::Failed, Some(2), "boom", Some(80)).await.unwrap();

let runs = recent_runs(&db, 20).await.unwrap();
assert_eq!(runs.len(), 2);
// Newest first, each carrying its schedule's spec_id.
assert_eq!(runs[0].spec_id, "etl-b");
assert_eq!(runs[0].status, "failed");
assert_eq!(runs[0].exit_code, Some(2));
assert_eq!(runs[0].log_tail.as_deref(), Some("boom"));
assert_eq!(runs[0].duration_ms, Some(80));
assert_eq!(runs[1].spec_id, "etl-a");
assert_eq!(runs[1].status, "ok");

// The limit bounds the page.
assert_eq!(recent_runs(&db, 1).await.unwrap().len(), 1);
}

// Dual-dialect check (the `IS NOT DISTINCT FROM` claim + BOOLEAN
// columns). Gated on `postgres-it`.
#[cfg(feature = "postgres-it")]
Expand All @@ -331,6 +413,10 @@ mod tests {
assert!(mark_fired(&db, s.id, None, now).await.unwrap());
assert!(!mark_fired(&db, s.id, None, now).await.unwrap());
record_run(&db, s.id, now, RunStatus::Ok, Some(0), "", Some(10)).await.unwrap();
// The $n-placeholder JOIN query parses under real Postgres too.
let runs = recent_runs(&db, 5).await.unwrap();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].spec_id, "etl-app");
delete(&db, s.id, None).await.unwrap();
}
}
13 changes: 11 additions & 2 deletions crates/ruscker-admin/src/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ async fn job_request(
if let Some(c) = creds {
req = req.with_creds(c);
}
// Per-schedule wall-clock cap (#986 slice C). Zero/negative values
// (nothing can produce them through the UI, but the column is plain
// i64) fall back to the backend's default rather than a 0s timeout
// that would kill every job instantly.
if let Some(t) = schedule.timeout_secs {
if t > 0 {
req = req.with_job_timeout(t as u64);
}
}
Ok(req)
}

Expand Down Expand Up @@ -158,8 +167,8 @@ async fn tick(state: &AppState) {
};

// Detached: a long ETL must not block the next tick. The run
// itself is bounded by run_job's cap (per-schedule timeout is
// slice C, the column already exists).
// itself is bounded by run_job's cap (the schedule's own
// timeout when set, else the backend default).
let state = state.clone();
let backend = backend.clone();
tokio::spawn(async move {
Expand Down
4 changes: 4 additions & 0 deletions crates/ruscker-admin/src/routes/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub mod groups;
pub mod images;
pub mod landing;
pub mod logs;
pub mod schedules_ui;
pub mod spec_form;
pub mod specs;
pub mod system;
Expand Down Expand Up @@ -63,6 +64,7 @@ pub fn routes() -> Router<AppState> {
.merge(audit::routes())
.merge(groups::routes())
.merge(logs::routes())
.merge(schedules_ui::routes())
.merge(users::routes())
.merge(system::routes())
}
Expand Down Expand Up @@ -739,6 +741,8 @@ fn section_for_admin_path(path: &str) -> &'static str {
"audit"
} else if path.starts_with("/admin/logs") {
"logs"
} else if path.starts_with("/admin/schedules") {
"schedules"
} else {
// /admin root and anything unrecognised → dashboard (every
// role can reach it).
Expand Down
Loading