2ca7ae538f
Co-authored-by: Copilot <copilot@github.com>
280 lines
9.2 KiB
HTML
280 lines
9.2 KiB
HTML
{% extends "base.html" %} {% block title %}Admin — All You Can GET AI{% endblock
|
||
%} {% block content %}
|
||
<div class="card">
|
||
<h1>Admin Dashboard</h1>
|
||
|
||
{% if stats %}
|
||
<div class="stats-grid">
|
||
<div class="stat-box">
|
||
<div class="stat-label">Total users</div>
|
||
<div class="stat-value">{{ stats.get('total_users', 0) }}</div>
|
||
</div>
|
||
<div class="stat-box">
|
||
<div class="stat-label">Active tokens</div>
|
||
<div class="stat-value">{{ stats.get('active_refresh_tokens', 0) }}</div>
|
||
</div>
|
||
<div class="stat-box">
|
||
<div class="stat-label">Admins</div>
|
||
<div class="stat-value">{{ stats.get('admin_users', 0) }}</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<h2 class="section-title">Users</h2>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Email</th>
|
||
<th>Role</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for u in users %}
|
||
<tr>
|
||
<td>{{ u.email }}</td>
|
||
<td>
|
||
<span class="role-badge role-{{ u.role }}">{{ u.role }}</span>
|
||
</td>
|
||
<td>
|
||
<div class="table-actions">
|
||
<!-- Role toggle -->
|
||
<form
|
||
method="post"
|
||
action="{{ url_for('admin_set_role', user_id=u.id) }}"
|
||
>
|
||
<input
|
||
type="hidden"
|
||
name="role"
|
||
value="{{ 'user' if u.role == 'admin' else 'admin' }}"
|
||
/>
|
||
<button type="submit" class="btn btn-sm">
|
||
Make {{ 'user' if u.role == 'admin' else 'admin' }}
|
||
</button>
|
||
</form>
|
||
<!-- Delete -->
|
||
{% if u.id != session.get('user_id') %}
|
||
<form
|
||
method="post"
|
||
action="{{ url_for('admin_delete_user', user_id=u.id) }}"
|
||
onsubmit="return confirm('Delete {{ u.email }}?')"
|
||
>
|
||
<button type="submit" class="btn btn-sm btn-danger">
|
||
Delete
|
||
</button>
|
||
</form>
|
||
{% endif %}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr>
|
||
<td colspan="3" class="text-muted">No users found.</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- ── Video Jobs ──────────────────────────────────────────────── -->
|
||
<h2 class="section-title" style="margin-top: 2rem">Video Jobs</h2>
|
||
|
||
<div
|
||
style="
|
||
display: flex;
|
||
gap: 1rem;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 1rem;
|
||
"
|
||
>
|
||
<label for="vj-status-filter" style="font-weight: 600"
|
||
>Filter by status:</label
|
||
>
|
||
<select id="vj-status-filter" class="form-control" style="width: auto">
|
||
<option value="">All</option>
|
||
<option value="queued">Queued</option>
|
||
<option value="processing">Processing</option>
|
||
<option value="completed">Completed</option>
|
||
<option value="failed">Failed</option>
|
||
<option value="cancelled">Cancelled</option>
|
||
</select>
|
||
<label for="vj-sort" style="font-weight: 600">Sort:</label>
|
||
<select id="vj-sort" class="form-control" style="width: auto">
|
||
<option value="created_desc">Created (newest first)</option>
|
||
<option value="created_asc">Created (oldest first)</option>
|
||
<option value="updated_desc">Updated (newest first)</option>
|
||
<option value="status_asc">Status (A–Z)</option>
|
||
<option value="model_asc">Model (A–Z)</option>
|
||
</select>
|
||
<button id="vj-refresh" class="btn btn-sm">Refresh</button>
|
||
<span
|
||
id="vj-count"
|
||
style="color: var(--text-muted, #888); font-size: 0.9em"
|
||
></span>
|
||
</div>
|
||
|
||
<div class="table-wrap">
|
||
<table id="vj-table">
|
||
<thead>
|
||
<tr>
|
||
<th>User</th>
|
||
<th>Status</th>
|
||
<th>Model</th>
|
||
<th>Prompt</th>
|
||
<th>Created</th>
|
||
<th>Updated</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="vj-tbody">
|
||
<tr>
|
||
<td colspan="7" class="text-muted">Loading…</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
let allJobs = [];
|
||
|
||
async function loadJobs() {
|
||
document.getElementById("vj-tbody").innerHTML =
|
||
'<tr><td colspan="7" class="text-muted">Loading…</td></tr>';
|
||
try {
|
||
const r = await fetch("/api/admin/videos");
|
||
if (!r.ok) throw new Error(await r.text());
|
||
allJobs = await r.json();
|
||
renderJobs();
|
||
} catch (e) {
|
||
document.getElementById("vj-tbody").innerHTML =
|
||
`<tr><td colspan="7" style="color:red;">Error: ${e.message}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
function renderJobs() {
|
||
const statusFilter = document.getElementById("vj-status-filter").value;
|
||
const sort = document.getElementById("vj-sort").value;
|
||
|
||
let jobs = statusFilter
|
||
? allJobs.filter((j) => j.status === statusFilter)
|
||
: [...allJobs];
|
||
|
||
jobs.sort((a, b) => {
|
||
if (sort === "created_asc")
|
||
return new Date(a.created_at) - new Date(b.created_at);
|
||
if (sort === "updated_desc")
|
||
return new Date(b.updated_at) - new Date(a.updated_at);
|
||
if (sort === "status_asc") return a.status.localeCompare(b.status);
|
||
if (sort === "model_asc") return a.model_id.localeCompare(b.model_id);
|
||
return new Date(b.created_at) - new Date(a.created_at); // created_desc default
|
||
});
|
||
|
||
document.getElementById("vj-count").textContent =
|
||
`${jobs.length} job${jobs.length !== 1 ? "s" : ""}`;
|
||
|
||
const tbody = document.getElementById("vj-tbody");
|
||
if (jobs.length === 0) {
|
||
tbody.innerHTML =
|
||
'<tr><td colspan="7" class="text-muted">No jobs found.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
const statusColor = {
|
||
completed: "color:var(--success-color,#4caf50)",
|
||
failed: "color:var(--danger-color,#e53935)",
|
||
cancelled: "color:var(--danger-color,#e53935)",
|
||
processing: "color:var(--warning-color,#fb8c00)",
|
||
queued: "color:var(--warning-color,#fb8c00)",
|
||
};
|
||
|
||
tbody.innerHTML = jobs
|
||
.map((job) => {
|
||
const sc = statusColor[job.status] || "";
|
||
const canRetry =
|
||
job.status === "failed" || job.status === "cancelled";
|
||
const canCancel =
|
||
job.status === "queued" || job.status === "processing";
|
||
const actions = [
|
||
canRetry
|
||
? `<button class="btn btn-sm vj-retry" data-id="${job.id}">Retry</button>`
|
||
: "",
|
||
canCancel
|
||
? `<button class="btn btn-sm vj-cancel" data-id="${job.id}">Cancel</button>`
|
||
: "",
|
||
`<button class="btn btn-sm btn-danger vj-delete" data-id="${job.id}">Delete</button>`,
|
||
].join(" ");
|
||
const prompt =
|
||
job.prompt.length > 60 ? job.prompt.slice(0, 57) + "…" : job.prompt;
|
||
const created = job.created_at
|
||
? new Date(job.created_at).toLocaleString()
|
||
: "—";
|
||
const updated = job.updated_at
|
||
? new Date(job.updated_at).toLocaleString()
|
||
: "—";
|
||
return `<tr>
|
||
<td>${job.user_email || "—"}</td>
|
||
<td style="${sc};font-weight:600;">${job.status}</td>
|
||
<td style="font-size:.85em;">${job.model_id}</td>
|
||
<td title="${job.prompt.replace(/"/g, """)}">${prompt}</td>
|
||
<td style="white-space:nowrap;">${created}</td>
|
||
<td style="white-space:nowrap;">${updated}</td>
|
||
<td style="white-space:nowrap;">${actions}</td>
|
||
</tr>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
async function apiPost(path) {
|
||
const r = await fetch(path, { method: "POST" });
|
||
if (!r.ok) {
|
||
const d = await r.json().catch(() => ({}));
|
||
throw new Error(d.detail || r.statusText);
|
||
}
|
||
return r.json();
|
||
}
|
||
|
||
async function apiDelete(path) {
|
||
const r = await fetch(path, { method: "DELETE" });
|
||
if (!r.ok) {
|
||
const d = await r.json().catch(() => ({}));
|
||
throw new Error(d.detail || r.statusText);
|
||
}
|
||
return r.json();
|
||
}
|
||
|
||
document
|
||
.getElementById("vj-tbody")
|
||
.addEventListener("click", async function (e) {
|
||
const btn = e.target.closest("button");
|
||
if (!btn) return;
|
||
const id = btn.dataset.id;
|
||
try {
|
||
if (btn.classList.contains("vj-retry"))
|
||
await apiPost(`/api/admin/videos/${id}/retry`);
|
||
if (btn.classList.contains("vj-cancel"))
|
||
await apiPost(`/api/admin/videos/${id}/cancel`);
|
||
if (btn.classList.contains("vj-delete")) {
|
||
if (!confirm("Permanently delete this video job?")) return;
|
||
await apiDelete(`/api/admin/videos/${id}`);
|
||
}
|
||
await loadJobs();
|
||
} catch (err) {
|
||
alert("Error: " + err.message);
|
||
}
|
||
});
|
||
|
||
document
|
||
.getElementById("vj-status-filter")
|
||
.addEventListener("change", renderJobs);
|
||
document.getElementById("vj-sort").addEventListener("change", renderJobs);
|
||
document.getElementById("vj-refresh").addEventListener("click", loadJobs);
|
||
|
||
loadJobs();
|
||
})();
|
||
</script>
|
||
{% endblock %}
|