Files
imgforge/public/dashboard.html

1258 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ImgForge — Dashboard</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.26.2/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #08080b;
--surface: #0e0e13;
--surface-2: #161620;
--surface-3: #1e1e2a;
--border: #252533;
--border-active: #3a3a50;
--text: #dcdce5;
--text-2: #9494a8;
--text-3: #5e5e72;
--accent: #e85d2c;
--accent-dim: rgba(232, 93, 44, 0.12);
--green: #2dd4a0;
--green-dim: rgba(45, 212, 160, 0.12);
--blue: #5b9cf5;
--blue-dim: rgba(91, 156, 245, 0.12);
--yellow: #f0b232;
--yellow-dim: rgba(240, 178, 50, 0.12);
--red: #ef5454;
--red-dim: rgba(239, 84, 84, 0.12);
--mono: 'IBM Plex Mono', monospace;
--sans: 'DM Sans', sans-serif;
--radius: 8px;
--radius-lg: 12px;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* Scanline overlay */
body::before {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,0.03) 2px,
rgba(0,0,0,0.03) 4px
);
pointer-events: none;
z-index: 10000;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes barGrow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px;
}
/* --- Header --- */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 36px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
animation: fadeIn 0.4s ease;
}
.header-left { display: flex; align-items: center; gap: 16px; }
.logo-mark {
width: 36px;
height: 36px;
background: var(--accent);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-weight: 700;
font-size: 16px;
color: #fff;
box-shadow: 0 0 20px rgba(232, 93, 44, 0.25);
}
.header-title {
font-family: var(--mono);
font-size: 14px;
font-weight: 600;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--text);
}
.header-sub {
font-size: 12px;
color: var(--text-3);
font-family: var(--mono);
margin-top: 2px;
}
.header-right { display: flex; align-items: center; gap: 12px; }
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 8px var(--green);
animation: pulse 2s infinite;
}
.status-dot.offline { background: var(--red); box-shadow: 0 0 8px var(--red); }
.status-text {
font-family: var(--mono);
font-size: 12px;
color: var(--text-2);
}
.refresh-btn {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 14px;
color: var(--text-2);
font-family: var(--mono);
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 6px;
}
.refresh-btn:hover { border-color: var(--accent); color: var(--text); }
.refresh-btn.spinning svg { animation: spin 0.6s linear; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* --- Metric Cards --- */
.metrics {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 32px;
}
.metric-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
position: relative;
overflow: hidden;
animation: fadeIn 0.5s ease backwards;
}
.metric-card:nth-child(1) { animation-delay: 0.05s; }
.metric-card:nth-child(2) { animation-delay: 0.1s; }
.metric-card:nth-child(3) { animation-delay: 0.15s; }
.metric-card:nth-child(4) { animation-delay: 0.2s; }
.metric-card:nth-child(5) { animation-delay: 0.25s; }
.metric-card::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
}
.metric-card.waiting::after { background: var(--yellow); }
.metric-card.active::after { background: var(--blue); }
.metric-card.completed::after { background: var(--green); }
.metric-card.failed::after { background: var(--red); }
.metric-card.total::after { background: var(--accent); }
.metric-label {
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-3);
margin-bottom: 10px;
}
.metric-value {
font-family: var(--mono);
font-size: 32px;
font-weight: 700;
line-height: 1;
}
.metric-card.waiting .metric-value { color: var(--yellow); }
.metric-card.active .metric-value { color: var(--blue); }
.metric-card.completed .metric-value { color: var(--green); }
.metric-card.failed .metric-value { color: var(--red); }
.metric-card.total .metric-value { color: var(--accent); }
/* --- Tabs --- */
.tabs {
display: flex;
gap: 2px;
margin-bottom: 20px;
background: var(--surface);
border-radius: var(--radius);
padding: 3px;
border: 1px solid var(--border);
width: fit-content;
animation: fadeIn 0.5s ease 0.3s backwards;
}
.tab {
font-family: var(--mono);
font-size: 12px;
font-weight: 500;
padding: 8px 18px;
border: none;
background: transparent;
color: var(--text-3);
cursor: pointer;
border-radius: 6px;
transition: all 0.15s;
}
.tab:hover { color: var(--text-2); }
.tab.active {
background: var(--surface-3);
color: var(--text);
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.tab .count {
display: inline-block;
min-width: 18px;
text-align: center;
padding: 1px 6px;
border-radius: 10px;
font-size: 10px;
margin-left: 6px;
background: var(--surface-2);
color: var(--text-3);
}
.tab.active .count { background: var(--accent-dim); color: var(--accent); }
/* --- Table --- */
.table-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
animation: fadeIn 0.5s ease 0.35s backwards;
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.table-title {
font-family: var(--mono);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-3);
}
.table-actions { display: flex; gap: 8px; }
.action-btn {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 12px;
font-family: var(--mono);
font-size: 11px;
color: var(--text-2);
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover { border-color: var(--border-active); color: var(--text); }
.action-btn.danger:hover { border-color: var(--red); color: var(--red); }
table {
width: 100%;
border-collapse: collapse;
}
thead th {
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-3);
text-align: left;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-weight: 500;
}
tbody tr {
border-bottom: 1px solid var(--border);
transition: background 0.1s;
}
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--surface-2); }
td {
padding: 14px 16px;
font-size: 13px;
vertical-align: middle;
}
.job-id-cell {
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
color: var(--text);
}
.ops-list {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.op-chip {
font-family: var(--mono);
font-size: 10px;
padding: 3px 8px;
border-radius: 4px;
background: var(--surface-3);
color: var(--text-2);
border: 1px solid var(--border);
}
/* Status badges */
.badge {
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 5px;
}
.badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
}
.badge.waiting { background: var(--yellow-dim); color: var(--yellow); }
.badge.waiting::before { background: var(--yellow); }
.badge.active { background: var(--blue-dim); color: var(--blue); }
.badge.active::before { background: var(--blue); animation: pulse 1.5s infinite; }
.badge.completed { background: var(--green-dim); color: var(--green); }
.badge.completed::before { background: var(--green); }
.badge.failed { background: var(--red-dim); color: var(--red); }
.badge.failed::before { background: var(--red); }
.badge.delayed { background: var(--surface-3); color: var(--text-3); }
.badge.delayed::before { background: var(--text-3); }
/* Progress bar in table */
.mini-progress {
width: 80px;
height: 4px;
background: var(--surface-3);
border-radius: 2px;
overflow: hidden;
margin-top: 4px;
}
.mini-progress-bar {
height: 100%;
border-radius: 2px;
background: var(--blue);
transition: width 0.3s ease;
}
.mini-progress-bar.done { background: var(--green); }
.progress-text {
font-family: var(--mono);
font-size: 11px;
color: var(--text-2);
}
.time-cell {
font-family: var(--mono);
font-size: 11px;
color: var(--text-3);
}
.size-cell {
font-family: var(--mono);
font-size: 12px;
}
.savings-positive { color: var(--green); }
.savings-negative { color: var(--red); }
/* Row action buttons */
.row-actions { display: flex; gap: 4px; }
.row-btn {
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-3);
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.row-btn:hover { border-color: var(--border-active); color: var(--text); background: var(--surface-3); }
.row-btn.retry:hover { border-color: var(--blue); color: var(--blue); }
.row-btn.download:hover { border-color: var(--green); color: var(--green); }
.row-btn.remove:hover { border-color: var(--red); color: var(--red); }
.row-btn:disabled { opacity: 0.25; cursor: not-allowed; }
/* Empty state */
.empty-state {
text-align: center;
padding: 60px 20px;
}
.empty-icon { font-size: 40px; margin-bottom: 16px; opacity: 0.3; }
.empty-text {
font-family: var(--mono);
font-size: 13px;
color: var(--text-3);
}
/* --- Detail Drawer --- */
.drawer-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.drawer {
position: fixed;
top: 0;
right: 0;
width: 440px;
max-width: 90vw;
height: 100vh;
background: var(--surface);
border-left: 1px solid var(--border);
z-index: 1001;
overflow-y: auto;
animation: slideDrawer 0.25s ease;
display: flex;
flex-direction: column;
}
@keyframes slideDrawer {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.drawer-header {
padding: 24px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: flex-start;
position: sticky;
top: 0;
background: var(--surface);
z-index: 1;
}
.drawer-title {
font-family: var(--mono);
font-size: 16px;
font-weight: 700;
}
.drawer-close {
background: none;
border: none;
color: var(--text-3);
cursor: pointer;
font-size: 20px;
padding: 4px;
}
.drawer-close:hover { color: var(--text); }
.drawer-body { padding: 24px; flex: 1; }
.detail-section {
margin-bottom: 24px;
}
.detail-label {
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-3);
margin-bottom: 8px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.detail-row:last-child { border-bottom: none; }
.detail-key { color: var(--text-2); font-family: var(--mono); font-size: 12px; }
.detail-val { color: var(--text); font-family: var(--mono); font-size: 12px; font-weight: 500; }
.detail-ops {
display: flex;
flex-direction: column;
gap: 6px;
}
.detail-op-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--surface-2);
border-radius: 6px;
border: 1px solid var(--border);
}
.detail-op-num {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
color: var(--text-3);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: var(--surface-3);
}
.detail-op-name {
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
color: var(--accent);
}
.detail-op-params {
font-family: var(--mono);
font-size: 11px;
color: var(--text-3);
margin-left: auto;
}
.error-block {
background: var(--red-dim);
border: 1px solid rgba(239, 84, 84, 0.25);
border-radius: var(--radius);
padding: 14px;
font-family: var(--mono);
font-size: 12px;
color: var(--red);
line-height: 1.5;
}
.drawer-actions {
padding: 20px 24px;
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
position: sticky;
bottom: 0;
background: var(--surface);
}
.drawer-btn {
flex: 1;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
text-align: center;
text-decoration: none;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.drawer-btn.primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.drawer-btn.primary:hover { filter: brightness(1.15); }
.drawer-btn.secondary {
background: transparent;
color: var(--text-2);
}
.drawer-btn.secondary:hover { border-color: var(--border-active); color: var(--text); }
/* Responsive */
@media (max-width: 768px) {
.metrics { grid-template-columns: repeat(3, 1fr); }
.metrics .metric-card:nth-child(4),
.metrics .metric-card:nth-child(5) { grid-column: span 1; }
td:nth-child(5), th:nth-child(5) { display: none; } /* hide time on mobile */
}
@media (max-width: 560px) {
.metrics { grid-template-columns: repeat(2, 1fr); }
.header { flex-direction: column; align-items: flex-start; gap: 12px; }
}
/* Loading skeleton */
.skeleton {
background: linear-gradient(90deg, var(--surface-2) 25%, var(--surface-3) 50%, var(--surface-2) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useCallback, useRef } = React;
// --- API helpers ---
const API = window.location.origin;
async function fetchHealth() {
const res = await fetch(`${API}/api/health`);
return res.json();
}
async function fetchJob(id) {
const res = await fetch(`${API}/api/jobs/${id}`);
return res.json();
}
async function fetchAllJobs() {
// Bull Board API gives us queue data, but we'll use our health endpoint
// and track jobs client-side via localStorage + polling
const res = await fetch(`${API}/api/health`);
return res.json();
}
// --- Persistent job tracker (survives refresh) ---
function useJobStore() {
const [jobs, setJobs] = useState(() => {
try {
return JSON.parse(sessionStorage.getItem('imgforge_jobs') || '[]');
} catch { return []; }
});
const save = useCallback((updated) => {
setJobs(updated);
sessionStorage.setItem('imgforge_jobs', JSON.stringify(updated));
}, []);
const addJob = useCallback((job) => {
save(prev => [job, ...prev]);
}, [save]);
const updateJob = useCallback((id, patch) => {
save(prev => prev.map(j => j.jobId === id ? { ...j, ...patch } : j));
}, [save]);
const removeJob = useCallback((id) => {
save(prev => prev.filter(j => j.jobId !== id));
}, [save]);
const clearCompleted = useCallback(() => {
save(prev => prev.filter(j => j.status !== 'completed'));
}, [save]);
return { jobs, addJob, updateJob, removeJob, clearCompleted, setJobs: save };
}
// --- SVG Icons ---
const Icons = {
refresh: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></svg>,
retry: '↻',
download: '↓',
trash: '×',
eye: '◉',
close: '×',
};
// --- Components ---
function MetricCard({ label, value, type }) {
return (
<div className={`metric-card ${type}`}>
<div className="metric-label">{label}</div>
<div className="metric-value">{value}</div>
</div>
);
}
function StatusBadge({ status }) {
const labels = {
waiting: 'Queued', queued: 'Queued',
active: 'Processing',
completed: 'Done',
failed: 'Failed',
delayed: 'Delayed',
};
const cls = status === 'queued' ? 'waiting' : status;
return <span className={`badge ${cls}`}>{labels[status] || status}</span>;
}
function JobRow({ job, onRetry, onRemove, onSelect }) {
const isActive = job.status === 'active';
const isDone = job.status === 'completed';
const isFailed = job.status === 'failed';
return (
<tr>
<td>
<div className="job-id-cell" style={{cursor:'pointer'}} onClick={() => onSelect(job)}>
#{job.jobId}
</div>
</td>
<td>
<div className="ops-list">
{(job.operations || []).map((op, i) => (
<span key={i} className="op-chip">{op}</span>
))}
</div>
</td>
<td><StatusBadge status={job.status} /></td>
<td>
{isActive && (
<div>
<div className="progress-text">{job.progress || 0}%</div>
<div className="mini-progress">
<div className="mini-progress-bar" style={{width: `${job.progress || 0}%`}} />
</div>
</div>
)}
{isDone && (
<div>
<div className="progress-text">100%</div>
<div className="mini-progress">
<div className="mini-progress-bar done" style={{width: '100%'}} />
</div>
</div>
)}
{isFailed && <span style={{fontFamily:'var(--mono)',fontSize:11,color:'var(--red)'}}>err</span>}
</td>
<td>
{job.result?.savings ? (
<span className={`size-cell ${job.result.savings.percent > 0 ? 'savings-positive' : 'savings-negative'}`}>
{job.result.savings.percent > 0 ? '' : '+'}{Math.abs(job.result.savings.percent)}%
</span>
) : <span className="size-cell" style={{color:'var(--text-3)'}}></span>}
</td>
<td className="time-cell">{job.createdAt ? timeAgo(job.createdAt) : '—'}</td>
<td>
<div className="row-actions">
<button className="row-btn" title="Details" onClick={() => onSelect(job)}>
{Icons.eye}
</button>
{isDone && (
<a className="row-btn download" title="Download" href={`${API}/api/jobs/${job.jobId}/download`}>
{Icons.download}
</a>
)}
{isFailed && (
<button className="row-btn retry" title="Retry" onClick={() => onRetry(job)}>
{Icons.retry}
</button>
)}
<button className="row-btn remove" title="Remove" onClick={() => onRemove(job.jobId)}>
{Icons.trash}
</button>
</div>
</td>
</tr>
);
}
function JobDetail({ job, onClose, onRetry }) {
if (!job) return null;
return (
<>
<div className="drawer-overlay" onClick={onClose} />
<div className="drawer">
<div className="drawer-header">
<div>
<div className="drawer-title">Job #{job.jobId}</div>
<div style={{marginTop:6}}><StatusBadge status={job.status} /></div>
</div>
<button className="drawer-close" onClick={onClose}>{Icons.close}</button>
</div>
<div className="drawer-body">
<div className="detail-section">
<div className="detail-label">Overview</div>
<div className="detail-row">
<span className="detail-key">Created</span>
<span className="detail-val">{job.createdAt ? new Date(job.createdAt).toLocaleString() : '—'}</span>
</div>
{job.result?.processingTime && (
<div className="detail-row">
<span className="detail-key">Processing Time</span>
<span className="detail-val">{job.result.processingTime}ms</span>
</div>
)}
{job.attempts > 0 && (
<div className="detail-row">
<span className="detail-key">Attempts</span>
<span className="detail-val">{job.attempts}/3</span>
</div>
)}
</div>
<div className="detail-section">
<div className="detail-label">Pipeline ({(job.operations || []).length} ops)</div>
<div className="detail-ops">
{(job.operationsDetail || job.operations || []).map((op, i) => {
const opObj = typeof op === 'string' ? { op } : op;
return (
<div key={i} className="detail-op-item">
<span className="detail-op-num">{i + 1}</span>
<span className="detail-op-name">{opObj.op || op}</span>
{opObj.params && Object.keys(opObj.params).length > 0 && (
<span className="detail-op-params">
{Object.entries(opObj.params).map(([k,v]) => `${k}:${v}`).join(' ')}
</span>
)}
</div>
);
})}
</div>
</div>
{job.result?.output && (
<div className="detail-section">
<div className="detail-label">Output</div>
<div className="detail-row">
<span className="detail-key">Dimensions</span>
<span className="detail-val">{job.result.output.width} × {job.result.output.height}</span>
</div>
<div className="detail-row">
<span className="detail-key">Format</span>
<span className="detail-val">{job.result.output.format}</span>
</div>
<div className="detail-row">
<span className="detail-key">Size</span>
<span className="detail-val">{formatBytes(job.result.output.size)}</span>
</div>
<div className="detail-row">
<span className="detail-key">Savings</span>
<span className="detail-val" style={{color: job.result.savings.percent > 0 ? 'var(--green)' : 'var(--red)'}}>
{job.result.savings.percent > 0 ? '' : '+'}{Math.abs(job.result.savings.percent)}% ({formatBytes(Math.abs(job.result.savings.bytes))})
</span>
</div>
</div>
)}
{job.error && (
<div className="detail-section">
<div className="detail-label">Error</div>
<div className="error-block">{job.error}</div>
</div>
)}
</div>
<div className="drawer-actions">
{job.status === 'completed' && (
<a className="drawer-btn primary" href={`${API}/api/jobs/${job.jobId}/download`}>
{Icons.download} Download
</a>
)}
{job.status === 'failed' && (
<button className="drawer-btn primary" onClick={() => onRetry(job)}>
{Icons.retry} Retry Job
</button>
)}
<button className="drawer-btn secondary" onClick={onClose}>Close</button>
</div>
</div>
</>
);
}
// --- Main App ---
function App() {
const { jobs, addJob, updateJob, removeJob, clearCompleted, setJobs } = useJobStore();
const [health, setHealth] = useState(null);
const [online, setOnline] = useState(true);
const [activeTab, setActiveTab] = useState('all');
const [selectedJob, setSelectedJob] = useState(null);
const [spinning, setSpinning] = useState(false);
const wsRefs = useRef({});
// Poll health
const refreshHealth = useCallback(async () => {
try {
const data = await fetchHealth();
setHealth(data);
setOnline(data.status === 'ok');
} catch {
setOnline(false);
}
}, []);
// Poll active/queued jobs for status updates
const pollJobs = useCallback(async () => {
const pending = jobs.filter(j => j.status === 'active' || j.status === 'waiting' || j.status === 'queued');
for (const j of pending) {
try {
const data = await fetchJob(j.jobId);
if (data.jobId) {
updateJob(j.jobId, {
status: data.status === 'waiting' ? 'queued' : data.status,
progress: data.progress,
result: data.result || j.result,
error: data.error,
attempts: data.attempts,
});
}
} catch {}
}
}, [jobs, updateJob]);
// Hydrate jobs from server on first load
const hydrated = useRef(false);
useEffect(() => {
if (hydrated.current) return;
hydrated.current = true;
fetch(`${API}/api/jobs?limit=100`)
.then(r => r.json())
.then(data => {
if (data.jobs?.length) {
// Merge: server jobs as base, overlay with any local state
const localMap = new Map(jobs.map(j => [String(j.jobId), j]));
const merged = data.jobs.map(sj => {
const local = localMap.get(String(sj.jobId));
return local ? { ...sj, ...local, result: sj.result || local.result } : sj;
});
// Add any local-only jobs not on server
jobs.forEach(j => {
if (!merged.find(m => String(m.jobId) === String(j.jobId))) merged.unshift(j);
});
setJobs(merged);
}
})
.catch(() => {});
}, []);
useEffect(() => {
refreshHealth();
const h = setInterval(refreshHealth, 10000);
const p = setInterval(pollJobs, 3000);
return () => { clearInterval(h); clearInterval(p); };
}, [refreshHealth, pollJobs]);
const handleRefresh = async () => {
setSpinning(true);
await refreshHealth();
await pollJobs();
setTimeout(() => setSpinning(false), 600);
};
// Listen to file upload from the main page
useEffect(() => {
const handler = (e) => {
if (e.data?.type === 'imgforge:job_created') {
const { jobId, operations, createdAt } = e.data;
addJob({
jobId,
operations,
status: 'queued',
progress: 0,
createdAt: createdAt || new Date().toISOString(),
result: null,
error: null,
});
connectWs(jobId);
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, [addJob]);
// WebSocket connection for a job
const connectWs = useCallback((jobId) => {
if (wsRefs.current[jobId]) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws?jobId=${jobId}`);
wsRefs.current[jobId] = ws;
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
switch (msg.type) {
case 'started':
updateJob(jobId, { status: 'active' });
break;
case 'progress':
updateJob(jobId, { progress: msg.progress, status: 'active' });
break;
case 'completed':
updateJob(jobId, { status: 'completed', progress: 100, result: msg });
ws.close();
delete wsRefs.current[jobId];
break;
case 'failed':
updateJob(jobId, { status: 'failed', error: msg.error });
ws.close();
delete wsRefs.current[jobId];
break;
}
};
ws.onerror = () => { delete wsRefs.current[jobId]; };
ws.onclose = () => { delete wsRefs.current[jobId]; };
}, [updateJob]);
// Reconnect WS for any active jobs on load
useEffect(() => {
jobs.filter(j => j.status === 'active' || j.status === 'queued' || j.status === 'waiting')
.forEach(j => connectWs(j.jobId));
}, []);
const handleRetry = async (job) => {
// Re-fetch the job data and resubmit... or just re-poll
// For now, mark as queued and let polling pick up the retry
updateJob(job.jobId, { status: 'queued', progress: 0, error: null });
connectWs(job.jobId);
};
// Filter jobs by tab
const filtered = activeTab === 'all' ? jobs
: jobs.filter(j => {
if (activeTab === 'queued') return j.status === 'queued' || j.status === 'waiting';
return j.status === activeTab;
});
const counts = {
all: jobs.length,
queued: jobs.filter(j => j.status === 'queued' || j.status === 'waiting').length,
active: jobs.filter(j => j.status === 'active').length,
completed: jobs.filter(j => j.status === 'completed').length,
failed: jobs.filter(j => j.status === 'failed').length,
};
const queueCounts = health?.queue || {};
return (
<div className="app">
{/* Header */}
<div className="header">
<div className="header-left">
<div className="logo-mark"></div>
<div>
<div className="header-title">ImgForge</div>
<div className="header-sub">Queue Dashboard</div>
</div>
</div>
<div className="header-right">
<div className={`status-dot ${online ? '' : 'offline'}`} />
<span className="status-text">{online ? 'Connected' : 'Offline'}</span>
<button className={`refresh-btn ${spinning ? 'spinning' : ''}`} onClick={handleRefresh}>
{Icons.refresh} Refresh
</button>
<a className="refresh-btn" href="/" style={{textDecoration:'none'}}> Upload</a>
</div>
</div>
{/* Metrics */}
<div className="metrics">
<MetricCard label="Waiting" value={queueCounts.waiting || 0} type="waiting" />
<MetricCard label="Active" value={queueCounts.active || 0} type="active" />
<MetricCard label="Completed" value={queueCounts.completed || 0} type="completed" />
<MetricCard label="Failed" value={queueCounts.failed || 0} type="failed" />
<MetricCard label="Total" value={
(queueCounts.waiting || 0) + (queueCounts.active || 0) +
(queueCounts.completed || 0) + (queueCounts.failed || 0) +
(queueCounts.delayed || 0)
} type="total" />
</div>
{/* Tabs */}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',flexWrap:'wrap',gap:12}}>
<div className="tabs">
{['all', 'queued', 'active', 'completed', 'failed'].map(tab => (
<button
key={tab}
className={`tab ${activeTab === tab ? 'active' : ''}`}
onClick={() => setActiveTab(tab)}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
<span className="count">{counts[tab]}</span>
</button>
))}
</div>
{counts.completed > 0 && (
<button className="action-btn" onClick={clearCompleted}>Clear completed</button>
)}
</div>
{/* Job Table */}
<div className="table-wrap" style={{marginTop: 12}}>
<table>
<thead>
<tr>
<th>Job</th>
<th>Operations</th>
<th>Status</th>
<th>Progress</th>
<th>Savings</th>
<th>Time</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan="7">
<div className="empty-state">
<div className="empty-icon"></div>
<div className="empty-text">
{activeTab === 'all'
? 'No jobs yet — upload an image to get started'
: `No ${activeTab} jobs`}
</div>
</div>
</td>
</tr>
) : (
filtered.map(job => (
<JobRow
key={job.jobId}
job={job}
onRetry={handleRetry}
onRemove={removeJob}
onSelect={setSelectedJob}
/>
))
)}
</tbody>
</table>
</div>
{/* Detail Drawer */}
{selectedJob && (
<JobDetail
job={selectedJob}
onClose={() => setSelectedJob(null)}
onRetry={(j) => { handleRetry(j); setSelectedJob(null); }}
/>
)}
</div>
);
}
// --- Utils ---
function formatBytes(b) {
if (!b && b !== 0) return '—';
if (b < 1024) return b + ' B';
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
return (b / 1048576).toFixed(1) + ' MB';
}
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const secs = Math.floor(diff / 1000);
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
ReactDOM.render(<App />, document.getElementById('root'));
</script>
</body>
</html>