File: //tmp/gpuq_out.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GPUQ Dashboard | kiwerkzeuge.de</title>
<style>
:root {
--bg: #0f172a;
--panel: #111827;
--line: #334155;
--text: #e5e7eb;
--muted: #94a3b8;
--ok: #22c55e;
--warn: #f59e0b;
--bad: #ef4444;
}
body {
margin: 0;
font-family: "Inter", "Segoe UI", sans-serif;
background: radial-gradient(circle at 15% 20%, #1e293b 0%, var(--bg) 55%);
color: var(--text);
}
.page {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0.75rem;
}
.card {
background: color-mix(in srgb, var(--panel) 92%, #000 8%);
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.9rem;
}
.title {
margin: 0;
font-size: 1.2rem;
}
.muted {
color: var(--muted);
}
.stat {
font-size: 1.7rem;
margin-top: 0.2rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 0.5rem 0.4rem;
text-align: left;
}
.pill {
border: 1px solid var(--line);
border-radius: 999px;
display: inline-block;
padding: 0.1rem 0.45rem;
font-size: 0.75rem;
}
.pill.high { color: #fb7185; }
.pill.normal { color: #60a5fa; }
.pill.low { color: #a3e635; }
button, select, input {
background: #0b1220;
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.35rem 0.55rem;
}
button { cursor: pointer; }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.bad { color: var(--bad); }
.actions {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.mono { font-family: ui-monospace, Menlo, Consolas, monospace; }
#statusLine {
margin: 0.2rem 0 0.8rem;
}
</style>
</head>
<body>
<main class="page">
<h1 class="title">GPUQ Dashboard</h1>
<p id="statusLine" class="muted">Loading queue state...</p>
<section class="row">
<article class="card">
<div class="muted">Queued</div>
<div id="queuedCount" class="stat">-</div>
</article>
<article class="card">
<div class="muted">Running</div>
<div id="runningCount" class="stat">-</div>
</article>
<article class="card">
<div class="muted">Completed (24h)</div>
<div id="completedCount" class="stat">-</div>
</article>
<article class="card">
<div class="muted">Failed (24h)</div>
<div id="failedCount" class="stat">-</div>
</article>
</section>
<section class="card" style="margin-top: 0.8rem;">
<h2 class="title" style="font-size: 1.05rem;">Live Queue</h2>
<table id="queueTable">
<thead>
<tr>
<th>Job</th>
<th>App</th>
<th>Type</th>
<th>Priority</th>
<th>Status</th>
<th>Pos</th>
<th>ETA</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<section class="card" style="margin-top: 0.8rem;">
<h2 class="title" style="font-size: 1.05rem;">Recent History</h2>
<table id="historyTable">
<thead>
<tr>
<th>Finished At</th>
<th>Job</th>
<th>App</th>
<th>Type</th>
<th>Status</th>
<th>Duration</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
</main>
<script>
const API = '/service/gpuq-proxy.php';
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = String(value ?? '');
return div.innerHTML;
}
function fmtEta(seconds) {
if (seconds == null || Number.isNaN(Number(seconds))) return '-';
const total = Math.max(0, Math.round(Number(seconds)));
const m = Math.floor(total / 60);
const s = String(total % 60).padStart(2, '0');
return `${m}:${s}`;
}
async function postAction(action, payload) {
const res = await fetch(`${API}?action=${encodeURIComponent(action)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload || {}),
});
return res.json();
}
async function loadQueue() {
const res = await fetch(`${API}?action=admin_queue`, { cache: 'no-store' });
const json = await res.json();
renderQueue(json);
}
async function loadHistory() {
const res = await fetch(`${API}?action=admin_history&limit=20`, { cache: 'no-store' });
const json = await res.json();
renderHistory(json);
}
function renderQueue(json) {
const statusLine = document.getElementById('statusLine');
const tbody = document.querySelector('#queueTable tbody');
const jobs = Array.isArray(json?.jobs) ? json.jobs : [];
document.getElementById('queuedCount').textContent = String(json?.stats?.queued ?? 0);
document.getElementById('runningCount').textContent = String(json?.stats?.running ?? 0);
document.getElementById('completedCount').textContent = String(json?.stats?.completed_24h ?? 0);
document.getElementById('failedCount').textContent = String(json?.stats?.failed_24h ?? 0);
statusLine.textContent = json?.message || `Queue refreshed at ${new Date().toLocaleTimeString()}`;
tbody.innerHTML = '';
if (!jobs.length) {
tbody.innerHTML = '<tr><td colspan="8" class="muted">No jobs in queue.</td></tr>';
return;
}
for (const job of jobs) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="mono">${escapeHtml(job.job_id)}</td>
<td>${escapeHtml(job.app_name)}</td>
<td>${escapeHtml(job.job_type)}</td>
<td><span class="pill ${escapeHtml(job.priority)}">${escapeHtml(job.priority)}</span></td>
<td>${escapeHtml(job.status)}</td>
<td>${escapeHtml(job.queue_position ?? '-')}</td>
<td>${escapeHtml(fmtEta(job.eta_seconds))}</td>
<td>
<div class="actions">
<button data-action="cancel" data-job="${escapeHtml(job.job_id)}">Cancel</button>
<button data-action="kill" data-job="${escapeHtml(job.job_id)}">Kill</button>
<select data-action="priority" data-job="${escapeHtml(job.job_id)}">
<option value="">Prio...</option>
<option value="high">high</option>
<option value="normal">normal</option>
<option value="low">low</option>
</select>
</div>
</td>
`;
tbody.appendChild(tr);
}
}
function renderHistory(json) {
const tbody = document.querySelector('#historyTable tbody');
const jobs = Array.isArray(json?.jobs) ? json.jobs : [];
tbody.innerHTML = '';
if (!jobs.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">No history entries yet.</td></tr>';
return;
}
for (const job of jobs) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${escapeHtml(job.finished_at || '-')}</td>
<td class="mono">${escapeHtml(job.job_id)}</td>
<td>${escapeHtml(job.app_name)}</td>
<td>${escapeHtml(job.job_type)}</td>
<td>${escapeHtml(job.status)}</td>
<td>${escapeHtml(fmtEta(job.runtime_seconds))}</td>
`;
tbody.appendChild(tr);
}
}
document.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const jobId = btn.dataset.job;
if (!jobId) return;
if (action === 'kill' && !confirm(`Hard kill for ${jobId}?`)) return;
try {
if (action === 'cancel') await postAction('cancel', { job_id: jobId });
if (action === 'kill') await postAction('kill', { job_id: jobId });
await loadQueue();
} catch (err) {
console.error(err);
alert('Action failed.');
}
});
document.addEventListener('change', async (e) => {
const select = e.target.closest('select[data-action="priority"]');
if (!select) return;
const jobId = select.dataset.job;
const priority = select.value;
if (!jobId || !priority) return;
try {
await postAction('priority', { job_id: jobId, priority });
await loadQueue();
} catch (err) {
console.error(err);
alert('Priority change failed.');
}
});
async function initialLoad() {
await Promise.all([loadQueue(), loadHistory()]);
}
function startQueueStream() {
const source = new EventSource(`${API}?action=stream_queue`);
source.onmessage = () => loadQueue().catch(console.error);
source.onerror = () => {
source.close();
setTimeout(startQueueStream, 3000);
};
}
initialLoad().catch(console.error);
startQueueStream();
setInterval(() => loadHistory().catch(console.error), 10000);
</script>
</body>
</html>