feat: add video job cancellation functionality and error tracking in generated videos
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -478,6 +478,15 @@ def generate_video_db_status(video_id: str):
|
||||
return jsonify(resp.json()), resp.status_code
|
||||
|
||||
|
||||
@app.post("/generate/video/<video_id>/cancel")
|
||||
@login_required
|
||||
def cancel_video_job(video_id: str):
|
||||
"""Proxy cancel request to backend."""
|
||||
resp = _api(
|
||||
"POST", f"/generate/videos/{video_id}/cancel", token=session["access_token"])
|
||||
return jsonify(resp.json()), resp.status_code
|
||||
|
||||
|
||||
# ── Admin ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/admin")
|
||||
|
||||
@@ -66,9 +66,70 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const videoId = pollDiv.dataset.videoId;
|
||||
const statusText = document.getElementById("poll-status-text");
|
||||
const videoContainer = document.getElementById("poll-video-container");
|
||||
const cancelBtn = document.getElementById("cancel-video-btn");
|
||||
const cancelMsg = document.getElementById("cancel-msg");
|
||||
const MAX_POLLS = 120; // ~10 minutes at 5s interval
|
||||
let pollCount = 0;
|
||||
let interval = null;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
const stopPolling = () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener("click", async () => {
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.textContent = "Cancelling…";
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/generate/video/" + encodeURIComponent(videoId) + "/cancel",
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (resp.ok) {
|
||||
stopPolling();
|
||||
cancelBtn.classList.add("hidden");
|
||||
if (cancelMsg) {
|
||||
cancelMsg.textContent = "Job cancelled.";
|
||||
cancelMsg.classList.remove("hidden", "text-red-500");
|
||||
cancelMsg.classList.add("text-gray-300");
|
||||
}
|
||||
if (statusText) {
|
||||
statusText.innerHTML = "Status: <strong>cancelled</strong>";
|
||||
}
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.textContent = "Cancel Job";
|
||||
if (cancelMsg) {
|
||||
cancelMsg.textContent = data.detail || "Cancel failed.";
|
||||
cancelMsg.classList.remove("hidden");
|
||||
cancelMsg.classList.add("text-red-500");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.textContent = "Cancel Job";
|
||||
if (cancelMsg) {
|
||||
cancelMsg.textContent = "Network error.";
|
||||
cancelMsg.classList.remove("hidden");
|
||||
cancelMsg.classList.add("text-red-500");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interval = setInterval(async () => {
|
||||
try {
|
||||
pollCount++;
|
||||
if (pollCount > MAX_POLLS) {
|
||||
stopPolling();
|
||||
pollDiv.innerHTML =
|
||||
'<div class="alert alert-warning">Polling timed out. Please refresh the page to check status.</div>';
|
||||
return;
|
||||
}
|
||||
const resp = await fetch(
|
||||
"/generate/video/" + encodeURIComponent(videoId) + "/status",
|
||||
);
|
||||
@@ -80,7 +141,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
if (data.status === "completed") {
|
||||
clearInterval(interval);
|
||||
stopPolling();
|
||||
if (data.video_url) {
|
||||
if (videoContainer) {
|
||||
const vid = document.createElement("video");
|
||||
@@ -95,10 +156,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
} else if (data.status === "failed" || data.status === "cancelled") {
|
||||
clearInterval(interval);
|
||||
} else if (data.status === "failed") {
|
||||
stopPolling();
|
||||
pollDiv.innerHTML =
|
||||
'<div class="alert alert-error">Generation failed or was cancelled.</div>';
|
||||
'<div class="alert alert-error">Generation failed.</div>';
|
||||
} else if (data.status === "cancelled") {
|
||||
stopPolling();
|
||||
if (cancelBtn) cancelBtn.classList.add("hidden");
|
||||
pollDiv.innerHTML =
|
||||
'<div class="alert alert-info">Job was cancelled.</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Video polling error:", e);
|
||||
|
||||
@@ -144,6 +144,10 @@ main {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
main:has(.admin-page) {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
/* ─── Alerts ───────────────────────────────────────────── */
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %} {% block title %}Admin — All You Can GET AI{% endblock
|
||||
%} {% block content %}
|
||||
<div class="card">
|
||||
<div class="card admin-page">
|
||||
<h1>Admin Dashboard</h1>
|
||||
|
||||
{% if stats %}
|
||||
|
||||
@@ -18,23 +18,34 @@ content %}
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
>
|
||||
{% for video in pending_videos %}
|
||||
<a
|
||||
href="{{ url_for('video_detail', video_id=video.id) }}"
|
||||
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300"
|
||||
<div
|
||||
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300 relative"
|
||||
data-pending-video-id="{{ video.id }}"
|
||||
>
|
||||
<div class="p-4">
|
||||
<p class="font-bold text-lg truncate">{{ video.prompt }}</p>
|
||||
<p class="text-sm text-gray-400">
|
||||
Video Job Status:
|
||||
<span class="font-semibold text-yellow-400"
|
||||
>{{ video.status }}</span
|
||||
>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
Started: {{ video.created_at | fromisoformat | humantime }}
|
||||
</p>
|
||||
<a href="{{ url_for('video_detail', video_id=video.id) }}">
|
||||
<div class="p-4">
|
||||
<p class="font-bold text-lg truncate">{{ video.prompt }}</p>
|
||||
<p class="text-sm text-gray-400">
|
||||
Video Job Status:
|
||||
<span class="font-semibold text-yellow-400"
|
||||
>{{ video.status }}</span
|
||||
>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
Started: {{ video.created_at | fromisoformat | humantime }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<div class="px-4 pb-4">
|
||||
<button
|
||||
class="cancel-pending-btn px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-xs"
|
||||
data-video-id="{{ video.id }}"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<span class="cancel-pending-msg text-xs ml-2 hidden"></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,6 +241,59 @@ content %}
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
// Cancel pending video buttons
|
||||
document.querySelectorAll(".cancel-pending-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const videoId = btn.dataset.videoId;
|
||||
const msgEl = btn.parentElement.querySelector(".cancel-pending-msg");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Cancelling…";
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/generate/video/" + encodeURIComponent(videoId) + "/cancel",
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (resp.ok) {
|
||||
btn.classList.add("hidden");
|
||||
if (msgEl) {
|
||||
msgEl.textContent = "Cancelled";
|
||||
msgEl.classList.remove("hidden", "text-red-500");
|
||||
msgEl.classList.add("text-gray-300");
|
||||
}
|
||||
const card = document.querySelector(
|
||||
'[data-pending-video-id="' + videoId + '"]',
|
||||
);
|
||||
if (card) {
|
||||
const statusSpan = card.querySelector(".text-yellow-400");
|
||||
if (statusSpan) {
|
||||
statusSpan.textContent = "cancelled";
|
||||
statusSpan.classList.remove("text-yellow-400");
|
||||
statusSpan.classList.add("text-gray-400");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Cancel";
|
||||
if (msgEl) {
|
||||
msgEl.textContent = data.detail || "Failed";
|
||||
msgEl.classList.remove("hidden");
|
||||
msgEl.classList.add("text-red-500");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Cancel";
|
||||
if (msgEl) {
|
||||
msgEl.textContent = "Error";
|
||||
msgEl.classList.remove("hidden");
|
||||
msgEl.classList.add("text-red-500");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -165,6 +165,13 @@ AI{% endblock %} {% block content %}
|
||||
— checking for updates every 5 s…
|
||||
</p>
|
||||
<div id="poll-video-container"></div>
|
||||
<button
|
||||
id="cancel-video-btn"
|
||||
class="mt-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm"
|
||||
>
|
||||
Cancel Job
|
||||
</button>
|
||||
<p id="cancel-msg" class="text-sm mt-2 hidden"></p>
|
||||
</div>
|
||||
{% elif result.video_url %}
|
||||
<video
|
||||
|
||||
@@ -26,6 +26,13 @@ block content %}
|
||||
it's ready.
|
||||
</p>
|
||||
<div class="spinner mt-4"></div>
|
||||
<button
|
||||
id="cancel-video-btn"
|
||||
class="mt-4 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm"
|
||||
>
|
||||
Cancel Job
|
||||
</button>
|
||||
<p id="cancel-msg" class="text-sm mt-2 hidden"></p>
|
||||
</div>
|
||||
{% elif video.status == 'failed' %}
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user