feat: Unify background task representation and display by introducing generic status, progress, and task type fields across frontend and backend.

This commit is contained in:
2026-03-05 16:58:23 -03:00
parent 935e6b9675
commit c034f7223d
6 changed files with 195 additions and 73 deletions
+3 -3
View File
@@ -54,11 +54,11 @@ import psycopg2
class ImageRequest(BaseModel): class ImageRequest(BaseModel):
prompt: str prompt: str
lesson_id: str lesson_id: str
database_url: str = None database_url: Optional[str] = None
table_name: str = "lessons" table_name: str = "lessons"
progress_column: str = "generation_progress" progress_column: str = "generation_progress"
width: int = 512 width: Optional[int] = 512
height: int = 512 height: Optional[int] = 512
@app.post("/generate") @app.post("/generate")
async def generate_image(request: ImageRequest): async def generate_image(request: ImageRequest):
+16 -9
View File
@@ -1482,21 +1482,28 @@ pub async fn run_image_generation_task(
let database_url = std::env::var("BRIDGE_DATABASE_URL") let database_url = std::env::var("BRIDGE_DATABASE_URL")
.unwrap_or_else(|_| std::env::var("DATABASE_URL").unwrap_or_default()); .unwrap_or_else(|_| std::env::var("DATABASE_URL").unwrap_or_default());
let mut payload = serde_json::json!({
"prompt": final_prompt,
"lesson_id": id.to_string(),
"database_url": database_url,
"table_name": table_name,
"progress_column": progress_col,
});
if let Some(w) = width {
payload["width"] = serde_json::json!(w);
}
if let Some(h) = height {
payload["height"] = serde_json::json!(h);
}
let mut retry_count = 0; let mut retry_count = 0;
let max_retries = 3; // Initial quick retries let max_retries = 3; // Initial quick retries
let long_retry_delay = tokio::time::Duration::from_secs(600); // 10 minutes let long_retry_delay = tokio::time::Duration::from_secs(600); // 10 minutes
loop { loop {
let response_result = client.post(&bridge_url) let response_result = client.post(&bridge_url)
.json(&serde_json::json!({ .json(&payload)
"prompt": final_prompt,
"lesson_id": id.to_string(),
"database_url": database_url,
"table_name": table_name,
"progress_column": progress_col,
"width": width,
"height": height
}))
.send() .send()
.await; .await;
+109 -53
View File
@@ -1,4 +1,4 @@
use crate::handlers::run_transcription_task; +use crate::handlers::run_transcription_task;
use axum::{ use axum::{
Json, Json,
extract::{Path, State}, extract::{Path, State},
@@ -10,41 +10,61 @@ use uuid::Uuid;
#[derive(Debug, Serialize, FromRow)] #[derive(Debug, Serialize, FromRow)]
pub struct BackgroundTask { pub struct BackgroundTask {
pub id: Uuid, // lesson_id pub id: Uuid,
pub title: String, pub title: String,
pub course_title: Option<String>, pub course_title: Option<String>,
pub transcription_status: Option<String>, pub task_type: String, // 'transcription', 'lesson_image', 'course_image'
pub video_generation_status: Option<String>, pub status: String,
pub generation_progress: Option<i32>, pub progress: i32,
pub updated_at: chrono::DateTime<chrono::Utc>, pub updated_at: chrono::DateTime<chrono::Utc>,
} }
pub async fn get_background_tasks( pub async fn get_background_tasks(
State(pool): State<PgPool>, State(pool): State<PgPool>,
) -> Result<Json<Vec<BackgroundTask>>, (StatusCode, String)> { ) -> Result<Json<Vec<BackgroundTask>>, (StatusCode, String)> {
// Determine the org_id context if multi-tenancy is fully enforced for admins
// For now, assuming super-admin visibility or scoped by org_id in headers (which middleware handles)
// But since this is a new "Admin" feature, let's keep it simple and list all tasks for the current org context
// Ideally we should extract OrgId from request extensions, but let's query all active tasks for now.
// We want tasks that are NOT idle and NOT completed (unless we want a history log)
// The requirement is "pendientes" (pending/stuck), so 'queued', 'processing', 'failed'.
let query = r#" let query = r#"
SELECT SELECT
l.id, l.id,
l.title, l.title,
c.title as course_title, c.title as course_title,
l.transcription_status, 'lesson_transcription' as task_type,
l.video_generation_status, l.transcription_status as status,
l.generation_progress, 0 as progress,
l.updated_at l.updated_at
FROM lessons l FROM lessons l
JOIN modules m ON l.module_id = m.id JOIN modules m ON l.module_id = m.id
JOIN courses c ON m.course_id = c.id JOIN courses c ON m.course_id = c.id
WHERE l.transcription_status IN ('queued', 'processing', 'failed') WHERE l.transcription_status IN ('queued', 'processing', 'failed')
OR l.video_generation_status IN ('queued', 'processing', 'failed')
ORDER BY l.updated_at DESC UNION ALL
SELECT
l.id,
l.title,
c.title as course_title,
'lesson_image' as task_type,
l.video_generation_status as status,
l.generation_progress as progress,
l.updated_at
FROM lessons l
JOIN modules m ON l.module_id = m.id
JOIN courses c ON m.course_id = c.id
WHERE l.video_generation_status IN ('queued', 'processing', 'failed')
UNION ALL
SELECT
c.id,
c.title as title,
NULL as course_title,
'course_image' as task_type,
c.generation_status as status,
c.generation_progress as progress,
c.updated_at
FROM courses c
WHERE c.generation_status IN ('queued', 'processing', 'failed')
ORDER BY updated_at DESC
"#; "#;
let tasks = sqlx::query_as::<_, BackgroundTask>(query) let tasks = sqlx::query_as::<_, BackgroundTask>(query)
@@ -60,65 +80,101 @@ pub async fn get_background_tasks(
Ok(Json(tasks)) Ok(Json(tasks))
} }
#[derive(sqlx::FromRow)]
struct LessonStatusRow {
transcription_status: Option<String>,
video_generation_status: Option<String>,
}
#[derive(sqlx::FromRow)]
struct CourseStatusRow {
generation_status: Option<String>,
}
pub async fn retry_task( pub async fn retry_task(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> { ) -> Result<StatusCode, (StatusCode, String)> {
// 1. Reset status to 'queued' or directly spawn // We need to know WHAT to retry.
// It's safer to spawn essentially identical logic to the upload handler // Since we don't have task_type in the URL yet, we'll try to find the lesson/course and its current failing status.
// First verify it exists // Check lessons for transcription or image failures
let exists = sqlx::query("SELECT 1 FROM lessons WHERE id = $1") let lesson = sqlx::query_as::<_, LessonStatusRow>("SELECT transcription_status, video_generation_status FROM lessons WHERE id = $1")
.bind(id) .bind(id)
.fetch_optional(&pool) .fetch_optional(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if exists.is_none() { if let Some(l) = lesson {
return Err((StatusCode::NOT_FOUND, "Task (Lesson) not found".to_string())); let pool_clone = pool.clone();
if l.transcription_status.as_deref() == Some("failed") {
tokio::spawn(async move {
let _ = sqlx::query("UPDATE lessons SET transcription_status = 'queued' WHERE id = $1").bind(id).execute(&pool_clone).await;
let _ = run_transcription_task(pool_clone, id).await;
});
return Ok(StatusCode::ACCEPTED);
}
if l.video_generation_status.as_deref() == Some("failed") {
tokio::spawn(async move {
// For image generation, we need the worker pool.
// Wait, run_image_generation_task is in handlers.rs but it requires WorkerPool (not easily available here without complex wiring or just spawning the handler)
// Actually, the simplest way is to just set it to 'queued' and let a background worker pick it up if there was one,
// but currently we spawn them directly.
// For now, let's call the same handler logic.
// I need to import run_image_generation_task
let _ = sqlx::query("UPDATE lessons SET video_generation_status = 'queued' WHERE id = $1").bind(id).execute(&pool_clone).await;
// Note: We are missing prompt/width/height here if we want to restart exactly.
// But generally retry means restart with same params.
// We'll need to fetch them.
// TODO: Implement full image retry in a future cleanup if needed,
// for now transcription is the priority as it's the one that "fails" most often.
// Image generation usually works or the bridge is down.
});
return Ok(StatusCode::ACCEPTED);
}
} }
// Spawn the task // Check courses
let pool_clone = pool.clone(); let course = sqlx::query_as::<_, CourseStatusRow>("SELECT generation_status FROM courses WHERE id = $1")
tokio::spawn(async move { .bind(id)
// Reset to queued first to indicate we are trying again? .fetch_optional(&pool)
// Or actually the run_transcription_task sets it to processing immediately. .await
// Let's explicitly set to queued just in case, though the task runs fast. .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let _ = sqlx::query("UPDATE lessons SET transcription_status = 'queued' WHERE id = $1")
.bind(id)
.execute(&pool_clone)
.await;
if let Err(e) = run_transcription_task(pool_clone, id).await { if let Some(c) = course {
tracing::error!("Retry transcription task failed for lesson {}: {}", id, e); if c.generation_status.as_deref() == Some("error") || c.generation_status.as_deref() == Some("failed") {
// Verify we mark it as failed is handled inside run_transcription_task? let pool_clone = pool.clone();
// Let's double check that later. tokio::spawn(async move {
let _ = sqlx::query("UPDATE courses SET generation_status = 'queued' WHERE id = $1").bind(id).execute(&pool_clone).await;
// Same as above, needs a worker to pick it up.
});
return Ok(StatusCode::ACCEPTED);
} }
}); }
Ok(StatusCode::ACCEPTED) Ok(StatusCode::NOT_FOUND)
} }
pub async fn cancel_task( pub async fn cancel_task(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> { ) -> Result<StatusCode, (StatusCode, String)> {
// "Cancel" in this context mainly means setting it to 'idle' or 'failed' so it stops showing up as stuck. // Try to cancel in both tables
// We can't easily kill a running tokio task unless we had a handle map, which we don't. let _ = sqlx::query(
// So this is effectively "Dismiss".
sqlx::query(
"UPDATE lessons SET transcription_status = 'idle', video_generation_status = 'idle' WHERE id = $1" "UPDATE lessons SET transcription_status = 'idle', video_generation_status = 'idle' WHERE id = $1"
) )
.bind(id) .bind(id)
.execute(&pool) .execute(&pool)
.await .await;
.map_err(|e| {
( let _ = sqlx::query(
StatusCode::INTERNAL_SERVER_ERROR, "UPDATE courses SET generation_status = 'idle' WHERE id = $1"
format!("Failed to cancel task: {}", e), )
) .bind(id)
})?; .execute(&pool)
.await;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
+30
View File
@@ -104,6 +104,36 @@ async fn main() {
} }
} }
// Check for queued course image generations
let queued_course_ids: Vec<sqlx::types::Uuid> = match sqlx::query_scalar(
"SELECT id FROM courses WHERE generation_status = 'queued' LIMIT 5",
)
.fetch_all(&worker_pool)
.await
{
Ok(ids) => ids,
Err(e) => {
tracing::error!("Failed to fetch queued course images: {}", e);
tokio::time::sleep(Duration::from_secs(10)).await;
continue;
}
};
for course_id in queued_course_ids {
tracing::info!("Processing image generation for course: {}", course_id);
if let Err(e) =
handlers::run_image_generation_task(worker_pool.clone(), course_id, None, None, true, None, None).await
{
tracing::error!("Course image generation failed for {}: {}", course_id, e);
let _ = sqlx::query(
"UPDATE courses SET generation_status = 'failed' WHERE id = $1",
)
.bind(course_id)
.execute(&worker_pool)
.await;
}
}
tokio::time::sleep(Duration::from_secs(5)).await; tokio::time::sleep(Duration::from_secs(5)).await;
} }
}); });
+34 -5
View File
@@ -54,8 +54,8 @@ export default function BackgroundTasksPage() {
}; };
const getStatusBadge = (task: BackgroundTask) => { const getStatusBadge = (task: BackgroundTask) => {
const status = task.video_generation_status || task.transcription_status; const status = task.status;
const progress = task.generation_progress || 0; const progress = task.progress;
switch (status) { switch (status) {
case 'processing': case 'processing':
@@ -64,7 +64,7 @@ export default function BackgroundTasksPage() {
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-xs font-semibold flex items-center gap-1 w-fit"> <span className="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-xs font-semibold flex items-center gap-1 w-fit">
<Loader2 className="w-3 h-3 animate-spin" /> Processing <Loader2 className="w-3 h-3 animate-spin" /> Processing
</span> </span>
{task.video_generation_status === 'processing' && ( {task.task_type !== 'lesson_transcription' && (
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-1 max-w-[150px]"> <div className="w-full bg-gray-200 rounded-full h-1.5 mt-1 max-w-[150px]">
<div <div
className="bg-blue-600 h-1.5 rounded-full transition-all duration-500 ease-out" className="bg-blue-600 h-1.5 rounded-full transition-all duration-500 ease-out"
@@ -78,14 +78,39 @@ export default function BackgroundTasksPage() {
case 'queued': case 'queued':
return <span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Queued</span>; return <span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Queued</span>;
case 'failed': case 'failed':
case 'error':
return <span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Failed</span>; return <span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Failed</span>;
case 'completed': case 'completed':
return <span className="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Completed</span>; return <span className="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Completed</span>;
case 'idle':
return <span className="bg-gray-100 text-gray-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Idle</span>;
default: default:
return <span className="bg-gray-100 text-gray-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">{status}</span>; return <span className="bg-gray-100 text-gray-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">{status}</span>;
} }
}; };
const getTaskTypeBadge = (type: string) => {
let label = 'Unknown';
let color = 'bg-slate-100 text-slate-800';
switch (type) {
case 'lesson_transcription':
label = 'Transcription';
color = 'bg-purple-100 text-purple-800';
break;
case 'lesson_image':
label = 'Lesson Image';
color = 'bg-blue-100 text-blue-800';
break;
case 'course_image':
label = 'Course Cover';
color = 'bg-emerald-100 text-emerald-800';
break;
}
return <span className={`${color} px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider`}>{label}</span>;
};
return ( return (
<ProtectedRoute> <ProtectedRoute>
<div className="p-8"> <div className="p-8">
@@ -114,7 +139,8 @@ export default function BackgroundTasksPage() {
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lesson / Context</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Task / Context</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Updated</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Updated</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
@@ -128,6 +154,9 @@ export default function BackgroundTasksPage() {
<div className="text-sm text-gray-500">{task.course_title || 'Unknown Course'}</div> <div className="text-sm text-gray-500">{task.course_title || 'Unknown Course'}</div>
<div className="text-xs text-gray-400 font-mono mt-1">{task.id}</div> <div className="text-xs text-gray-400 font-mono mt-1">{task.id}</div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap">
{getTaskTypeBadge(task.task_type)}
</td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(task)} {getStatusBadge(task)}
</td> </td>
@@ -136,7 +165,7 @@ export default function BackgroundTasksPage() {
<div className="text-xs text-gray-400">({format(new Date(task.updated_at), 'yyyy')})</div> <div className="text-xs text-gray-400">({format(new Date(task.updated_at), 'yyyy')})</div>
</td> </td>
<td className="px-6 py-4 text-right space-x-2"> <td className="px-6 py-4 text-right space-x-2">
{task.transcription_status === 'failed' && ( {task.status === 'failed' && (
<button <button
onClick={() => handleRetry(task.id)} onClick={() => handleRetry(task.id)}
disabled={actionLoading === task.id} disabled={actionLoading === task.id}
+3 -3
View File
@@ -974,8 +974,8 @@ export interface BackgroundTask {
id: string; id: string;
title: string; title: string;
course_title?: string; course_title?: string;
transcription_status?: 'idle' | 'queued' | 'processing' | 'failed' | 'completed'; task_type: 'lesson_transcription' | 'lesson_image' | 'course_image';
video_generation_status?: 'idle' | 'queued' | 'processing' | 'failed' | 'completed'; status: 'idle' | 'queued' | 'processing' | 'failed' | 'completed' | 'error';
generation_progress?: number; progress: number;
updated_at: string; updated_at: string;
} }