feat: add admin video jobs management endpoints and UI for listing, cancelling, and purging video jobs

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-29 16:49:08 +02:00
parent cc96d26b08
commit bd77d4c43e
5 changed files with 295 additions and 5 deletions
+82 -1
View File
@@ -1,5 +1,5 @@
"""Admin router: operational endpoints for application management.""" """Admin router: operational endpoints for application management."""
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
from ..db import get_conn, get_write_lock from ..db import get_conn, get_write_lock
from ..dependencies import require_admin from ..dependencies import require_admin
from ..services import models as models_service from ..services import models as models_service
from ..services.models import mark_timed_out_video_jobs
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -104,3 +105,83 @@ async def refresh_models(
"total_models": status.get("model_count"), "total_models": status.get("model_count"),
"last_updated": status.get("last_updated"), "last_updated": status.get("last_updated"),
} }
@router.get("/videos")
async def admin_list_video_jobs(_: dict = Depends(require_admin)) -> list[dict[str, Any]]:
"""Return all video generation jobs across all users."""
conn = get_conn()
rows = conn.execute(
"""
SELECT
v.id, v.job_id, v.user_id, u.email, v.model_id, v.prompt,
v.status, v.video_url, v.created_at, v.updated_at
FROM generated_videos v
LEFT JOIN users u ON v.user_id = u.id
ORDER BY v.created_at DESC
"""
).fetchall()
return [
{
"id": str(row[0]),
"job_id": row[1],
"user_id": str(row[2]),
"user_email": row[3],
"model_id": row[4],
"prompt": row[5],
"status": row[6],
"video_url": row[7],
"created_at": row[8].isoformat() if row[8] else None,
"updated_at": row[9].isoformat() if row[9] else None,
}
for row in rows
]
@router.post("/videos/{job_id}/cancel", status_code=200)
async def admin_cancel_video_job(job_id: str, _: dict = Depends(require_admin)) -> dict[str, str]:
"""Mark a video job as 'cancelled'. Does not stop the provider job."""
conn = get_conn()
lock = get_write_lock()
now = datetime.now(timezone.utc)
async with lock:
conn.execute(
"UPDATE generated_videos SET status = 'cancelled', updated_at = ? WHERE id = ?", [
now, job_id]
)
return {"status": "ok", "job_id": job_id}
@router.post("/videos/purge", status_code=200)
async def admin_purge_video_jobs(_: dict = Depends(require_admin)) -> dict[str, Any]:
"""Delete all completed, failed, or cancelled jobs older than 30 days."""
conn = get_conn()
lock = get_write_lock()
thirty_days_ago = datetime.now(
timezone.utc) - timedelta(days=30)
sql_count = "SELECT COUNT(*) FROM generated_videos"
sql_delete = """
DELETE FROM generated_videos
WHERE status IN ('completed', 'failed', 'cancelled')
AND updated_at < ?
"""
async with lock:
before_row = conn.execute(sql_count).fetchone()
before = before_row[0] if before_row else 0
conn.execute(sql_delete, [thirty_days_ago])
after_row = conn.execute(sql_count).fetchone()
after = after_row[0] if after_row else 0
return {"deleted": before - after, "remaining": after}
@router.post("/videos/timed-out", status_code=200)
async def admin_mark_timed_out(_: dict = Depends(require_admin)) -> dict[str, int]:
"""Mark video jobs that have been in 'queued' or 'processing' status for too long as 'failed'."""
conn = get_conn()
count = mark_timed_out_video_jobs(conn, timeout_minutes=120)
return {"timed_out": count}
+37
View File
@@ -207,3 +207,40 @@ def get_cache_status(conn: duckdb.DuckDBPyConnection) -> dict[str, Any]:
).fetchone() ).fetchone()
last_updated, model_count = (row[0], row[1]) if row else (None, 0) last_updated, model_count = (row[0], row[1]) if row else (None, 0)
return {"last_updated": last_updated, "model_count": model_count} return {"last_updated": last_updated, "model_count": model_count}
def mark_timed_out_video_jobs(conn: duckdb.DuckDBPyConnection, timeout_minutes: int = 120) -> int:
"""Mark video jobs that have been in 'queued' or 'processing' status for too long as 'failed'.
Returns the number of jobs marked as timed out.
"""
timeout_threshold = datetime.now(
timezone.utc) - timedelta(minutes=timeout_minutes)
# Find timed out jobs
timed_out_rows = conn.execute(
"""
SELECT id FROM generated_videos
WHERE status IN ('queued', 'processing')
AND updated_at < ?
""",
[timeout_threshold]
).fetchall()
if not timed_out_rows:
return 0
job_ids = [row[0] for row in timed_out_rows]
placeholders = ",".join(["?"] * len(job_ids))
# Update them to failed
conn.execute(
f"""
UPDATE generated_videos
SET status = 'failed', updated_at = ?
WHERE id IN ({placeholders})
""",
[datetime.now(timezone.utc)] + job_ids
)
return len(job_ids)
+9 -1
View File
@@ -220,7 +220,8 @@ def dashboard():
vid_resp = _api("GET", "/generate/videos", token=token) vid_resp = _api("GET", "/generate/videos", token=token)
videos = vid_resp.json() if vid_resp.status_code == 200 else [] videos = vid_resp.json() if vid_resp.status_code == 200 else []
pending_videos = [v for v in videos if v.get("status") not in ("completed", "failed")] pending_videos = [v for v in videos if v.get(
"status") not in ("completed", "failed")]
completed_videos = [v for v in videos if v.get("status") == "completed"] completed_videos = [v for v in videos if v.get("status") == "completed"]
return render_template("dashboard.html", user=user, images=images, return render_template("dashboard.html", user=user, images=images,
@@ -506,6 +507,13 @@ def admin_models():
return render_template("admin/models.html") return render_template("admin/models.html")
@app.get("/admin/videos")
@admin_required
def admin_videos():
"""Show all video generation jobs across all users."""
return render_template("admin/videos.html")
# ── Profile ─────────────────────────────────────────────────────────────── # ── Profile ───────────────────────────────────────────────────────────────
@app.route("/users/profile", methods=["GET", "POST"]) @app.route("/users/profile", methods=["GET", "POST"])
+163
View File
@@ -0,0 +1,163 @@
{% extends "base.html" %} {% block title %}Admin - Video Jobs{% endblock %} {%
block content %}
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-6">Admin: Video Jobs</h1>
<!-- Purge Old Jobs -->
<div class="bg-gray-800 p-4 rounded-lg shadow-md mb-6">
<h2 class="text-xl font-semibold mb-2">Maintenance</h2>
<p class="text-gray-400 mb-4">
Delete all completed, failed, or cancelled jobs older than 30 days.
</p>
<button
id="purge-button"
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
>
Purge Old Jobs
</button>
<p id="purge-status" class="mt-2 text-sm"></p>
</div>
<!-- Video Jobs Table -->
<div class="bg-gray-800 p-4 rounded-lg shadow-md overflow-x-auto">
<table class="min-w-full divide-y divide-gray-700">
<thead class="bg-gray-700">
<tr>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
>
User
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
>
Status
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
>
Model
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
>
Prompt
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
>
Created
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody id="jobs-table-body" class="bg-gray-800 divide-y divide-gray-700">
<tr>
<td colspan="6" class="text-center py-4">Loading jobs...</td>
</tr>
</tbody>
</table>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
const jobsTableBody = document.getElementById("jobs-table-body");
const purgeButton = document.getElementById("purge-button");
const purgeStatus = document.getElementById("purge-status");
async function fetchJobs() {
try {
const response = await fetch("/api/v1/admin/videos");
if (!response.ok) throw new Error("Failed to fetch jobs");
const jobs = await response.json();
jobsTableBody.innerHTML = "";
if (jobs.length === 0) {
jobsTableBody.innerHTML =
'<tr><td colspan="6" class="text-center py-4">No video jobs found.</td></tr>';
} else {
jobs.forEach((job) => {
const statusClass =
job.status === "completed"
? "text-green-400"
: job.status === "failed" || job.status === "cancelled"
? "text-red-400"
: "text-yellow-400";
const cancelBtn =
job.status === "queued" || job.status === "processing"
? `<button class="cancel-btn text-red-400 hover:text-red-600 text-sm" data-job-id="${job.id}">Cancel</button>`
: "";
const row = `
<tr>
<td class="px-4 py-3 whitespace-nowrap text-sm">${job.user_email || "Unknown"}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm font-semibold ${statusClass}">${job.status}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">${job.model_id}</td>
<td class="px-4 py-3 text-sm truncate max-w-xs">${job.prompt}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">${new Date(job.created_at).toLocaleString()}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">${cancelBtn}</td>
</tr>
`;
jobsTableBody.innerHTML += row;
});
}
} catch (error) {
jobsTableBody.innerHTML =
'<tr><td colspan="6" class="text-center py-4 text-red-500">Error loading jobs.</td></tr>';
console.error("Error fetching jobs:", error);
}
}
async function purgeJobs() {
purgeButton.disabled = true;
purgeStatus.textContent = "Purging...";
purgeStatus.classList.remove("text-red-500", "text-green-500");
try {
const response = await fetch("/api/v1/admin/videos/purge", {
method: "POST",
});
const data = await response.json();
if (!response.ok)
throw new Error(data.detail || "Failed to purge jobs");
purgeStatus.textContent = `Purged ${data.deleted} jobs. ${data.remaining} remaining.`;
purgeStatus.classList.add("text-green-500");
fetchJobs();
} catch (error) {
purgeStatus.textContent = `Error: ${error.message}`;
purgeStatus.classList.add("text-red-500");
} finally {
purgeButton.disabled = false;
}
}
// Cancel button event delegation
jobsTableBody.addEventListener("click", async function (e) {
if (e.target.classList.contains("cancel-btn")) {
const jobId = e.target.dataset.jobId;
try {
const response = await fetch(`/api/v1/admin/videos/${jobId}/cancel`, {
method: "POST",
});
if (!response.ok) throw new Error("Failed to cancel job");
fetchJobs();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
});
purgeButton.addEventListener("click", purgeJobs);
fetchJobs();
});
</script>
{% endblock %}
+1
View File
@@ -30,6 +30,7 @@
<a href="{{ url_for('profile') }}">Profile</a> <a href="{{ url_for('profile') }}">Profile</a>
{% if session.get('user_role') == 'admin' %} {% if session.get('user_role') == 'admin' %}
<a href="{{ url_for('admin') }}">Admin</a> <a href="{{ url_for('admin') }}">Admin</a>
<a href="{{ url_for('admin_videos') }}">Video Jobs</a>
{% endif %} {% endif %}
<a href="{{ url_for('logout') }}">Log out</a> <a href="{{ url_for('logout') }}">Log out</a>
{% else %} {% else %}