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:
@@ -1,5 +1,5 @@
|
||||
"""Admin router: operational endpoints for application management."""
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
|
||||
from ..db import get_conn, get_write_lock
|
||||
from ..dependencies import require_admin
|
||||
from ..services import models as models_service
|
||||
from ..services.models import mark_timed_out_video_jobs
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
@@ -104,3 +105,83 @@ async def refresh_models(
|
||||
"total_models": status.get("model_count"),
|
||||
"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}
|
||||
|
||||
@@ -207,3 +207,40 @@ def get_cache_status(conn: duckdb.DuckDBPyConnection) -> dict[str, Any]:
|
||||
).fetchone()
|
||||
last_updated, model_count = (row[0], row[1]) if row else (None, 0)
|
||||
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)
|
||||
|
||||
+12
-4
@@ -217,10 +217,11 @@ def dashboard():
|
||||
images = img_resp.json() if img_resp.status_code == 200 else []
|
||||
gen_resp = _api("GET", "/generate/images", token=token)
|
||||
generated_images = gen_resp.json() if gen_resp.status_code == 200 else []
|
||||
|
||||
|
||||
vid_resp = _api("GET", "/generate/videos", token=token)
|
||||
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"]
|
||||
|
||||
return render_template("dashboard.html", user=user, images=images,
|
||||
@@ -418,7 +419,7 @@ def generate_video():
|
||||
duration = int(
|
||||
duration_raw) if duration_raw.strip().isdigit() else None
|
||||
resolution = request.form.get("resolution", "").strip() or None
|
||||
|
||||
|
||||
if mode == "image":
|
||||
resp = _api("POST", "/generate/video/from-image", token=token, json={
|
||||
"model": request.form.get("model", "").strip(),
|
||||
@@ -436,7 +437,7 @@ def generate_video():
|
||||
"duration_seconds": duration,
|
||||
"resolution": resolution,
|
||||
})
|
||||
|
||||
|
||||
if resp.status_code == 200:
|
||||
result = resp.json()
|
||||
# On success, redirect to the detail page to monitor progress
|
||||
@@ -506,6 +507,13 @@ def admin_models():
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/users/profile", methods=["GET", "POST"])
|
||||
|
||||
@@ -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 %}
|
||||
@@ -30,6 +30,7 @@
|
||||
<a href="{{ url_for('profile') }}">Profile</a>
|
||||
{% if session.get('user_role') == 'admin' %}
|
||||
<a href="{{ url_for('admin') }}">Admin</a>
|
||||
<a href="{{ url_for('admin_videos') }}">Video Jobs</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('logout') }}">Log out</a>
|
||||
{% else %}
|
||||
|
||||
Reference in New Issue
Block a user