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."""
|
"""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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+12
-4
@@ -217,10 +217,11 @@ def dashboard():
|
|||||||
images = img_resp.json() if img_resp.status_code == 200 else []
|
images = img_resp.json() if img_resp.status_code == 200 else []
|
||||||
gen_resp = _api("GET", "/generate/images", token=token)
|
gen_resp = _api("GET", "/generate/images", token=token)
|
||||||
generated_images = gen_resp.json() if gen_resp.status_code == 200 else []
|
generated_images = gen_resp.json() if gen_resp.status_code == 200 else []
|
||||||
|
|
||||||
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,
|
||||||
@@ -418,7 +419,7 @@ def generate_video():
|
|||||||
duration = int(
|
duration = int(
|
||||||
duration_raw) if duration_raw.strip().isdigit() else None
|
duration_raw) if duration_raw.strip().isdigit() else None
|
||||||
resolution = request.form.get("resolution", "").strip() or None
|
resolution = request.form.get("resolution", "").strip() or None
|
||||||
|
|
||||||
if mode == "image":
|
if mode == "image":
|
||||||
resp = _api("POST", "/generate/video/from-image", token=token, json={
|
resp = _api("POST", "/generate/video/from-image", token=token, json={
|
||||||
"model": request.form.get("model", "").strip(),
|
"model": request.form.get("model", "").strip(),
|
||||||
@@ -436,7 +437,7 @@ def generate_video():
|
|||||||
"duration_seconds": duration,
|
"duration_seconds": duration,
|
||||||
"resolution": resolution,
|
"resolution": resolution,
|
||||||
})
|
})
|
||||||
|
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
# On success, redirect to the detail page to monitor progress
|
# On success, redirect to the detail page to monitor progress
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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>
|
<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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user