From 6035c557e3ff2939eb0923bb1161663106c97223 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 17 Jul 2026 21:25:14 -0500 Subject: [PATCH] fix: add independent logging for jobs Signed-off-by: Trezy --- .../20260718000000_create_job_logs.sql | 10 + .../sqlite/20260718000000_create_job_logs.sql | 10 + src/admin/jobs.rs | 30 +++ src/admin/mod.rs | 1 + src/jobs/logs.rs | 241 ++++++++++++++++++ src/jobs/mod.rs | 1 + src/jobs/worker.rs | 29 +++ src/lua/jobs_api.rs | 124 +++++++++ tests/e2e_jobs.rs | 149 +++++++++++ web/src/app/dashboard/jobs/page.tsx | 77 +++++- web/src/lib/api.ts | 15 +- web/src/types/jobs.ts | 13 + web/tests/e2e/jobs.spec.ts | 54 ++++ 13 files changed, 751 insertions(+), 3 deletions(-) create mode 100644 migrations/postgres/20260718000000_create_job_logs.sql create mode 100644 migrations/sqlite/20260718000000_create_job_logs.sql create mode 100644 src/jobs/logs.rs diff --git a/migrations/postgres/20260718000000_create_job_logs.sql b/migrations/postgres/20260718000000_create_job_logs.sql new file mode 100644 index 0000000..0f63956 --- /dev/null +++ b/migrations/postgres/20260718000000_create_job_logs.sql @@ -0,0 +1,10 @@ +CREATE TABLE happyview_job_logs ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + level TEXT NOT NULL DEFAULT 'info', + message TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_happyview_job_logs_job_id ON happyview_job_logs (job_id); +CREATE INDEX idx_happyview_job_logs_job_id_created_at ON happyview_job_logs (job_id, created_at); diff --git a/migrations/sqlite/20260718000000_create_job_logs.sql b/migrations/sqlite/20260718000000_create_job_logs.sql new file mode 100644 index 0000000..0f63956 --- /dev/null +++ b/migrations/sqlite/20260718000000_create_job_logs.sql @@ -0,0 +1,10 @@ +CREATE TABLE happyview_job_logs ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + level TEXT NOT NULL DEFAULT 'info', + message TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_happyview_job_logs_job_id ON happyview_job_logs (job_id); +CREATE INDEX idx_happyview_job_logs_job_id_created_at ON happyview_job_logs (job_id, created_at); diff --git a/src/admin/jobs.rs b/src/admin/jobs.rs index 46d3b1c..ae06719 100644 --- a/src/admin/jobs.rs +++ b/src/admin/jobs.rs @@ -122,3 +122,33 @@ pub async fn resume_job( jobs::db::set_status(&state, &id, "pending").await?; Ok(Json(serde_json::json!({ "status": "pending" }))) } + +#[derive(Deserialize)] +pub struct ListJobLogsQuery { + pub limit: Option, + pub cursor: Option, +} + +pub async fn list_job_logs( + State(state): State, + auth: UserAuth, + Path(id): Path, + Query(query): Query, +) -> Result, AppError> { + auth.require(Permission::JobsRead).await?; + + let limit = query.limit.unwrap_or(100).clamp(1, 500); + let (logs, cursor) = jobs::logs::list_logs( + &state.db, + state.db_backend, + &id, + limit, + query.cursor.as_deref(), + ) + .await?; + + Ok(Json(serde_json::json!({ + "logs": logs, + "cursor": cursor, + }))) +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs index a2e89bc..210323f 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -68,6 +68,7 @@ pub fn admin_routes(_state: AppState) -> Router { .route("/jobs/{id}/cancel", post(jobs::cancel_job)) .route("/jobs/{id}/pause", post(jobs::pause_job)) .route("/jobs/{id}/resume", post(jobs::resume_job)) + .route("/jobs/{id}/logs", get(jobs::list_job_logs)) .route("/events", get(events::list_events)) .route("/users", post(users::create_user).get(users::list_users)) .route("/users/transfer-super", post(users::transfer_super)) diff --git a/src/jobs/logs.rs b/src/jobs/logs.rs new file mode 100644 index 0000000..ec31a84 --- /dev/null +++ b/src/jobs/logs.rs @@ -0,0 +1,241 @@ +use serde::Serialize; +use uuid::Uuid; + +use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; +use crate::error::AppError; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobLog { + pub id: String, + pub job_id: String, + pub level: String, + pub message: String, + pub created_at: String, +} + +pub async fn insert_log( + db: &sqlx::AnyPool, + backend: DatabaseBackend, + job_id: &str, + level: &str, + message: &str, +) -> Result<(), AppError> { + let id = Uuid::new_v4().to_string(); + let now = now_rfc3339(); + let sql = adapt_sql( + "INSERT INTO happyview_job_logs (id, job_id, level, message, created_at) VALUES (?, ?, ?, ?, ?)", + backend, + ); + crate::db::query(&sql) + .bind(&id) + .bind(job_id) + .bind(level) + .bind(message) + .bind(&now) + .execute(db) + .await + .map_err(|e| AppError::Internal(format!("failed to insert job log: {e}")))?; + Ok(()) +} + +pub async fn list_logs( + db: &sqlx::AnyPool, + backend: DatabaseBackend, + job_id: &str, + limit: i64, + cursor: Option<&str>, +) -> Result<(Vec, Option), AppError> { + let limit = limit.clamp(1, 500); + let fetch_limit = limit + 1; + + let rows: Vec<(String, String, String, String, String)> = if let Some(cursor_id) = cursor { + let sql = adapt_sql( + "SELECT l.id, l.job_id, l.level, l.message, l.created_at FROM happyview_job_logs l INNER JOIN happyview_job_logs c ON c.id = ? WHERE l.job_id = ? AND (l.created_at > c.created_at OR (l.created_at = c.created_at AND l.id > c.id)) ORDER BY l.created_at ASC, l.id ASC LIMIT ?", + backend, + ); + crate::db::query_as(&sql) + .bind(cursor_id) + .bind(job_id) + .bind(fetch_limit) + .fetch_all(db) + .await + } else { + let sql = adapt_sql( + "SELECT id, job_id, level, message, created_at FROM happyview_job_logs WHERE job_id = ? ORDER BY created_at ASC, id ASC LIMIT ?", + backend, + ); + crate::db::query_as(&sql) + .bind(job_id) + .bind(fetch_limit) + .fetch_all(db) + .await + } + .map_err(|e| AppError::Internal(format!("failed to list job logs: {e}")))?; + + let has_more = rows.len() as i64 > limit; + let logs: Vec = rows + .into_iter() + .take(limit as usize) + .map(|(id, job_id, level, message, created_at)| JobLog { + id, + job_id, + level, + message, + created_at, + }) + .collect(); + + let next_cursor = if has_more { + logs.last().map(|l| l.id.clone()) + } else { + None + }; + + Ok((logs, next_cursor)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::DatabaseBackend; + + async fn test_pool() -> sqlx::AnyPool { + sqlx::any::install_default_drivers(); + let pool = sqlx::pool::PoolOptions::::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + crate::db::query( + "CREATE TABLE happyview_job_logs ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + level TEXT NOT NULL DEFAULT 'info', + message TEXT NOT NULL, + created_at TEXT NOT NULL + )", + ) + .execute(&pool) + .await + .unwrap(); + pool + } + + #[tokio::test] + async fn insert_log_creates_row() { + let pool = test_pool().await; + insert_log( + &pool, + DatabaseBackend::Sqlite, + "job-1", + "info", + "hello world", + ) + .await + .unwrap(); + + let row: (String, String, String, String) = crate::db::query_as( + "SELECT job_id, level, message, created_at FROM happyview_job_logs WHERE job_id = 'job-1'", + ) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(row.0, "job-1"); + assert_eq!(row.1, "info"); + assert_eq!(row.2, "hello world"); + assert!(!row.3.is_empty()); + } + + #[tokio::test] + async fn list_logs_returns_chronological_order() { + let pool = test_pool().await; + insert_log(&pool, DatabaseBackend::Sqlite, "job-2", "info", "first") + .await + .unwrap(); + insert_log(&pool, DatabaseBackend::Sqlite, "job-2", "warn", "second") + .await + .unwrap(); + insert_log(&pool, DatabaseBackend::Sqlite, "job-2", "info", "third") + .await + .unwrap(); + + let (logs, cursor) = list_logs(&pool, DatabaseBackend::Sqlite, "job-2", 10, None) + .await + .unwrap(); + + assert_eq!(logs.len(), 3); + assert_eq!(logs[0].message, "first"); + assert_eq!(logs[1].message, "second"); + assert_eq!(logs[1].level, "warn"); + assert_eq!(logs[2].message, "third"); + assert!(cursor.is_none()); + } + + #[tokio::test] + async fn list_logs_pagination() { + let pool = test_pool().await; + for i in 0..5 { + insert_log( + &pool, + DatabaseBackend::Sqlite, + "job-3", + "info", + &format!("msg-{i}"), + ) + .await + .unwrap(); + } + + let (page1, cursor1) = list_logs(&pool, DatabaseBackend::Sqlite, "job-3", 2, None) + .await + .unwrap(); + assert_eq!(page1.len(), 2); + assert_eq!(page1[0].message, "msg-0"); + assert_eq!(page1[1].message, "msg-1"); + assert!(cursor1.is_some()); + + let (page2, cursor2) = list_logs( + &pool, + DatabaseBackend::Sqlite, + "job-3", + 2, + cursor1.as_deref(), + ) + .await + .unwrap(); + assert_eq!(page2.len(), 2); + assert_eq!(page2[0].message, "msg-2"); + assert!(cursor2.is_some()); + + let (page3, _) = list_logs( + &pool, + DatabaseBackend::Sqlite, + "job-3", + 2, + cursor2.as_deref(), + ) + .await + .unwrap(); + assert_eq!(page3.len(), 1); + assert_eq!(page3[0].message, "msg-4"); + } + + #[tokio::test] + async fn list_logs_filters_by_job_id() { + let pool = test_pool().await; + insert_log(&pool, DatabaseBackend::Sqlite, "job-a", "info", "a-msg") + .await + .unwrap(); + insert_log(&pool, DatabaseBackend::Sqlite, "job-b", "info", "b-msg") + .await + .unwrap(); + + let (logs, _) = list_logs(&pool, DatabaseBackend::Sqlite, "job-a", 10, None) + .await + .unwrap(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].message, "a-msg"); + } +} diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs index 7c85fa5..fd2bf24 100644 --- a/src/jobs/mod.rs +++ b/src/jobs/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod db; +pub(crate) mod logs; pub mod worker; use serde::{Deserialize, Serialize}; diff --git a/src/jobs/worker.rs b/src/jobs/worker.rs index 9fddce3..273c69e 100644 --- a/src/jobs/worker.rs +++ b/src/jobs/worker.rs @@ -260,6 +260,35 @@ async fn execute_job(state: &AppState, job: &super::Job) { return; } + { + let existing_log: mlua::Function = lua.globals().get("log").unwrap(); + let state_for_log = state_arc.clone(); + let job_id_for_log = job.id.clone(); + let dual_log = lua + .create_async_function(move |_lua, msg: String| { + let existing = existing_log.clone(); + let state = state_for_log.clone(); + let job_id = job_id_for_log.clone(); + async move { + let _ = existing.call_async::<()>(msg.clone()).await; + if let Err(e) = crate::jobs::logs::insert_log( + &state.db, + state.db_backend, + &job_id, + "info", + &msg, + ) + .await + { + tracing::warn!(job_id = %job_id, error = %e, "dual log insert failed"); + } + Ok(()) + } + }) + .unwrap(); + let _ = lua.globals().set("log", dual_log); + } + if let Err(e) = lua.load(script.body.as_str()).exec() { let error = format!("script load failed: {e}"); let _ = db::set_error(state, &job.id, &error).await; diff --git a/src/lua/jobs_api.rs b/src/lua/jobs_api.rs index ef3c82f..281b734 100644 --- a/src/lua/jobs_api.rs +++ b/src/lua/jobs_api.rs @@ -153,6 +153,56 @@ pub fn register_job_context( job_table.set("wait", wait_fn)?; } + // job.log(message) + { + let state = state.clone(); + let job_id = job_id.clone(); + let log_fn = lua.create_async_function(move |_lua, msg: String| { + let state = state.clone(); + let job_id = job_id.clone(); + async move { + if let Err(e) = crate::jobs::logs::insert_log( + &state.db, + state.db_backend, + &job_id, + "info", + &msg, + ) + .await + { + tracing::warn!(job_id = %job_id, error = %e, "job.log insert failed"); + } + Ok(()) + } + })?; + job_table.set("log", log_fn)?; + } + + // job.warn(message) + { + let state = state.clone(); + let job_id = job_id.clone(); + let warn_fn = lua.create_async_function(move |_lua, msg: String| { + let state = state.clone(); + let job_id = job_id.clone(); + async move { + if let Err(e) = crate::jobs::logs::insert_log( + &state.db, + state.db_backend, + &job_id, + "warn", + &msg, + ) + .await + { + tracing::warn!(job_id = %job_id, error = %e, "job.warn insert failed"); + } + Ok(()) + } + })?; + job_table.set("warn", warn_fn)?; + } + lua.globals().set("job", job_table)?; Ok(()) } @@ -328,6 +378,8 @@ mod tests { return type(job.progress) == 'function' and type(job.should_stop) == 'function' and type(job.wait) == 'function' + and type(job.log) == 'function' + and type(job.warn) == 'function' "#, ) .eval_async() @@ -389,6 +441,18 @@ mod tests { .execute(&pool) .await .expect("create happyview_jobs table"); + crate::db::query( + "CREATE TABLE happyview_job_logs ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + level TEXT NOT NULL DEFAULT 'info', + message TEXT NOT NULL, + created_at TEXT NOT NULL + )", + ) + .execute(&pool) + .await + .expect("create happyview_job_logs table"); pool } @@ -560,4 +624,64 @@ mod tests { assert!(row.1.is_none()); assert!(row.2.is_none()); } + + #[tokio::test] + async fn job_log_inserts_info_row() { + let pool = migrated_pool().await; + let state = test_state_with_pool(pool); + let lua = crate::lua::sandbox::create_sandbox().unwrap(); + register_job_context( + &lua, + Arc::new(state.clone()), + "log-test-job".into(), + serde_json::json!({}), + ) + .unwrap(); + + lua.load(r#"job.log("hello from lua")"#) + .exec_async() + .await + .unwrap(); + + let row: (String, String, String) = crate::db::query_as( + "SELECT job_id, level, message FROM happyview_job_logs WHERE job_id = 'log-test-job'", + ) + .fetch_one(&state.db) + .await + .expect("log row should exist"); + + assert_eq!(row.0, "log-test-job"); + assert_eq!(row.1, "info"); + assert_eq!(row.2, "hello from lua"); + } + + #[tokio::test] + async fn job_warn_inserts_warn_row() { + let pool = migrated_pool().await; + let state = test_state_with_pool(pool); + let lua = crate::lua::sandbox::create_sandbox().unwrap(); + register_job_context( + &lua, + Arc::new(state.clone()), + "warn-test-job".into(), + serde_json::json!({}), + ) + .unwrap(); + + lua.load(r#"job.warn("something is off")"#) + .exec_async() + .await + .unwrap(); + + let row: (String, String, String) = crate::db::query_as( + "SELECT job_id, level, message FROM happyview_job_logs WHERE job_id = 'warn-test-job'", + ) + .fetch_one(&state.db) + .await + .expect("warn row should exist"); + + assert_eq!(row.0, "warn-test-job"); + assert_eq!(row.1, "warn"); + assert_eq!(row.2, "something is off"); + } } diff --git a/tests/e2e_jobs.rs b/tests/e2e_jobs.rs index 2eabb52..c89199c 100644 --- a/tests/e2e_jobs.rs +++ b/tests/e2e_jobs.rs @@ -504,6 +504,155 @@ async fn full_job_lifecycle() { assert!(job["completed_at"].is_string()); } +// --------------------------------------------------------------------------- +// Job logs +// --------------------------------------------------------------------------- + +async fn seed_job_log(app: &TestApp, job_id: &str, level: &str, message: &str) { + let id = Uuid::new_v4().to_string(); + let now = happyview::db::now_rfc3339(); + let sql = adapt_sql( + "INSERT INTO happyview_job_logs (id, job_id, level, message, created_at) VALUES (?, ?, ?, ?, ?)", + app.state.db_backend, + ); + happyview::db::query(&sql) + .bind(&id) + .bind(job_id) + .bind(level) + .bind(message) + .bind(&now) + .execute(&app.state.db) + .await + .expect("seed_job_log: insert failed"); +} + +#[tokio::test] +#[serial] +async fn list_job_logs_empty() { + common::require_db!(); + let app = TestApp::new().await; + + let id = seed_job(&app, "test.logs", "running").await; + + let resp = app + .router + .clone() + .oneshot(admin_get( + &format!("/admin/jobs/{id}/logs"), + app.admin_cookie(), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["logs"].as_array().unwrap().len(), 0); + assert_eq!(body["cursor"], Value::Null); +} + +#[tokio::test] +#[serial] +async fn list_job_logs_returns_seeded_logs() { + common::require_db!(); + let app = TestApp::new().await; + + let id = seed_job(&app, "test.logs", "running").await; + seed_job_log(&app, &id, "info", "starting migration").await; + seed_job_log(&app, &id, "warn", "rate limited").await; + seed_job_log(&app, &id, "info", "migration complete").await; + + let resp = app + .router + .clone() + .oneshot(admin_get( + &format!("/admin/jobs/{id}/logs"), + app.admin_cookie(), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + let logs = body["logs"].as_array().unwrap(); + assert_eq!(logs.len(), 3); + assert_eq!(logs[0]["message"], "starting migration"); + assert_eq!(logs[0]["level"], "info"); + assert_eq!(logs[1]["message"], "rate limited"); + assert_eq!(logs[1]["level"], "warn"); + assert_eq!(logs[2]["message"], "migration complete"); +} + +#[tokio::test] +#[serial] +async fn list_job_logs_pagination() { + common::require_db!(); + let app = TestApp::new().await; + + let id = seed_job(&app, "test.logs-page", "running").await; + for i in 0..5 { + seed_job_log(&app, &id, "info", &format!("log-{i}")).await; + } + + let resp = app + .router + .clone() + .oneshot(admin_get( + &format!("/admin/jobs/{id}/logs?limit=2"), + app.admin_cookie(), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + let logs = body["logs"].as_array().unwrap(); + assert_eq!(logs.len(), 2); + assert_eq!(logs[0]["message"], "log-0"); + assert_eq!(logs[1]["message"], "log-1"); + assert!(body["cursor"].is_string()); + + let cursor = body["cursor"].as_str().unwrap(); + let resp2 = app + .router + .clone() + .oneshot(admin_get( + &format!("/admin/jobs/{id}/logs?limit=2&cursor={cursor}"), + app.admin_cookie(), + )) + .await + .unwrap(); + + assert_eq!(resp2.status(), StatusCode::OK); + let body2 = json_body(resp2).await; + let logs2 = body2["logs"].as_array().unwrap(); + assert_eq!(logs2.len(), 2); + assert_eq!(logs2[0]["message"], "log-2"); + assert_eq!(logs2[1]["message"], "log-3"); +} + +#[tokio::test] +#[serial] +async fn list_job_logs_without_auth_returns_401() { + common::require_db!(); + let app = TestApp::new().await; + + let id = seed_job(&app, "test.logs-auth", "running").await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri(format!("/admin/jobs/{id}/logs")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + // --------------------------------------------------------------------------- // DPoP context persistence // --------------------------------------------------------------------------- diff --git a/web/src/app/dashboard/jobs/page.tsx b/web/src/app/dashboard/jobs/page.tsx index 43a0026..4de942b 100644 --- a/web/src/app/dashboard/jobs/page.tsx +++ b/web/src/app/dashboard/jobs/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { CheckCircle2, @@ -15,11 +15,12 @@ import { useCurrentUser } from "@/hooks/use-current-user"; import { toastError } from "@/lib/format"; import { cancelJob, + getJobLogs, getJobs, pauseJob, resumeJob, } from "@/lib/api"; -import type { Job } from "@/types/jobs"; +import type { Job, JobLog } from "@/types/jobs"; import { SiteHeader } from "@/components/site-header"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -427,6 +428,8 @@ function JobDetail({ {hasContent(job.result) && } + + {canManage && ( @@ -483,6 +486,76 @@ function JobDetail({ ); } +function JobLogs({ jobId, isActive }: { jobId: string; isActive: boolean }) { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const containerRef = useRef(null); + + const load = useCallback(() => { + getJobLogs(jobId, { limit: 200 }) + .then((resp) => { + setLogs(resp.logs); + setLoading(false); + }) + .catch(() => { + setLoading(false); + }); + }, [jobId]); + + useEffect(() => { + load(); + }, [load]); + + useEffect(() => { + if (!isActive) return; + const interval = setInterval(load, 3000); + return () => clearInterval(interval); + }, [load, isActive]); + + useEffect(() => { + if (containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [logs]); + + if (loading && logs.length === 0) { + return ( +
+ + Loading logs… +
+ ); + } + + if (logs.length === 0) return null; + + return ( +
+ Logs +
+ {logs.map((log) => ( +
+ + {new Date(log.createdAt).toLocaleTimeString()} + + {log.level === "warn" ? ( + + WARN + + ) : ( + INFO + )} + {log.message} +
+ ))} +
+
+ ); +} + function JsonSection({ title, data, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 378e939..4bdb71f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -3,7 +3,7 @@ import type { StatsResponse } from "@/types/stats"; import type { LexiconSummary, LexiconDetail } from "@/types/lexicons"; import type { NetworkLexiconSummary } from "@/types/network-lexicons"; import type { BackfillJob, BackfillReposResponse, PdsSummaryResponse } from "@/types/backfill"; -import type { Job, JobsListResponse } from "@/types/jobs"; +import type { Job, JobLogsResponse, JobsListResponse } from "@/types/jobs"; import type { UserSummary } from "@/types/users"; import type { AdminListRecordsResponse } from "@/types/records"; import type { EventsListResponse } from "@/types/events"; @@ -287,6 +287,19 @@ export function resumeJob(id: string) { }); } +export function getJobLogs( + id: string, + params: { limit?: number; cursor?: string } = {}, +) { + const qs = new URLSearchParams(); + if (params.limit) qs.set("limit", String(params.limit)); + if (params.cursor) qs.set("cursor", params.cursor); + const query = qs.toString(); + return apiFetch( + `/admin/jobs/${id}/logs${query ? `?${query}` : ""}`, + ); +} + // Users export function getUsers() { return apiFetch("/admin/users"); diff --git a/web/src/types/jobs.ts b/web/src/types/jobs.ts index 74dcec1..9074e52 100644 --- a/web/src/types/jobs.ts +++ b/web/src/types/jobs.ts @@ -17,3 +17,16 @@ export interface JobsListResponse { jobs: Job[] cursor: string | null } + +export interface JobLog { + id: string + jobId: string + level: string + message: string + createdAt: string +} + +export interface JobLogsResponse { + logs: JobLog[] + cursor: string | null +} diff --git a/web/tests/e2e/jobs.spec.ts b/web/tests/e2e/jobs.spec.ts index 581ec97..e7a3423 100644 --- a/web/tests/e2e/jobs.spec.ts +++ b/web/tests/e2e/jobs.spec.ts @@ -34,10 +34,37 @@ async function seedJob( } } +async function seedJobLogs( + jobId: string, + logs: Array<{ level: string; message: string }>, +): Promise { + const client = new pg.Client(DB_URL) + await client.connect() + try { + for (const log of logs) { + const id = randomUUID() + const now = new Date().toISOString() + await client.query( + `INSERT INTO happyview_job_logs (id, job_id, level, message, created_at) + VALUES ($1, $2, $3, $4, $5)`, + [id, jobId, log.level, log.message, now], + ) + } + } finally { + await client.end() + } +} + async function cleanupJobs(): Promise { const client = new pg.Client(DB_URL) await client.connect() try { + await client.query( + `DELETE FROM happyview_job_logs WHERE job_id IN ( + SELECT id FROM happyview_jobs WHERE created_by = $1 + )`, + [TEST_DID], + ) await client.query( "DELETE FROM happyview_jobs WHERE created_by = $1", [TEST_DID], @@ -177,6 +204,33 @@ test.describe("Jobs Dashboard", () => { ).toBeVisible() }) + test("shows logs section in job detail sheet", async ({ page }) => { + const id = await seedJob("completed", "test.e2e.logs") + await seedJobLogs(id, [ + { level: "info", message: "starting migration" }, + { level: "warn", message: "rate limited on blob upload" }, + { level: "info", message: "migration complete" }, + ]) + + await page.goto("/dashboard/jobs") + + const row = page.locator("table tbody tr", { + hasText: "test.e2e.logs", + }) + await expect(row).toBeVisible({ timeout: 5000 }) + await row.click() + + const sheet = page.locator("[data-state='open'][role='dialog']") + await expect(sheet).toBeVisible({ timeout: 3000 }) + + await expect(sheet.getByText("Logs", { exact: true })).toBeVisible({ timeout: 5000 }) + await expect(sheet.getByText("starting migration")).toBeVisible() + await expect(sheet.getByText("rate limited on blob upload")).toBeVisible() + await expect(sheet.getByText("migration complete")).toBeVisible() + await expect(sheet.getByText("WARN")).toBeVisible() + await expect(sheet.getByText("INFO").first()).toBeVisible() + }) + test("shows error section for failed job", async ({ page }) => { const client = new pg.Client(DB_URL) await client.connect() -- 2.51.2