polish(projects,jobs,bins): row menus, real status bars, bulk retry
Projects: - Per-row 3-dot menu in list view: Open / Rename / Delete (PATCH + DELETE) - ProjectCard's bottom bar now shows real ready/in-flight/error counts for the project's assets instead of fake 70/20 segments - After mutations, project list refreshes from /projects + recomputes asset counts client-side Bins: - GET /api/v1/bins now returns every bin across every project when no project_id is supplied; result rows include project_name + asset_count - Asset right-click 'Move to bin' filters to bins in the same project as the asset and surfaces project_name as a tooltip Jobs: - 'Retry all failed' button in the header appears when there are failed jobs and POSTs /retry for each one in parallel - Failed-row error message now clips with title= tooltip so 3KB ffmpeg stderr doesn't blow out the row layout window.PROJECT_COLORS exposed for cross-screen access.
This commit is contained in:
parent
f474a77bcb
commit
47ad01d0b2
5 changed files with 128 additions and 26 deletions
|
|
@ -7,18 +7,28 @@ const router = express.Router();
|
|||
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET / - List bins for a project_id
|
||||
// GET / - List bins. Filter by project_id when supplied; otherwise return
|
||||
// every bin across every project so the Library / asset-context-menu can
|
||||
// present a global "move to bin" picker.
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { project_id } = req.query;
|
||||
|
||||
if (!project_id) {
|
||||
return res.status(400).json({ error: 'project_id is required' });
|
||||
const params = [];
|
||||
let where = '';
|
||||
if (project_id) {
|
||||
where = 'WHERE b.project_id = $1';
|
||||
params.push(project_id);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM bins WHERE project_id = $1 ORDER BY created_at DESC`,
|
||||
[project_id]
|
||||
`SELECT b.*, p.name AS project_name,
|
||||
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
|
||||
FROM bins b
|
||||
LEFT JOIN projects p ON p.id = b.project_id
|
||||
${where}
|
||||
ORDER BY b.created_at DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json(result.rows);
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ function fmtRelative(iso) {
|
|||
}
|
||||
|
||||
const PROJECT_COLORS = ['#5B7CFA', '#2DD4A8', '#FF5B5B', '#F5A623', '#B57CFA', '#6B7280'];
|
||||
window.PROJECT_COLORS = PROJECT_COLORS;
|
||||
|
||||
function normalizeAsset(a, projectMap) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,17 @@ function Jobs({ navigate }) {
|
|||
.catch(e => alert('Delete failed: ' + e.message));
|
||||
}, []);
|
||||
|
||||
// Retry every failed job at once. Useful after a transient infra issue
|
||||
// (S3 outage, hung worker) — one click per job is painful with 20+ failures.
|
||||
const handleRetryAll = React.useCallback(() => {
|
||||
const failedJobs = jobs.filter(j => j.status === 'failed');
|
||||
if (failedJobs.length === 0) return;
|
||||
if (!window.confirm(`Re-queue all ${failedJobs.length} failed jobs?`)) return;
|
||||
Promise.allSettled(
|
||||
failedJobs.map(j => window.ZAMPP_API.fetch('/jobs/' + j.id + '/retry', { method: 'POST' }))
|
||||
).then(refresh);
|
||||
}, [jobs, refresh]);
|
||||
|
||||
const counts = {
|
||||
all: jobs.length,
|
||||
running: jobs.filter(j => j.status === 'running').length,
|
||||
|
|
@ -66,6 +77,11 @@ function Jobs({ navigate }) {
|
|||
<h1>Jobs</h1>
|
||||
<span className="subtitle">Proxy generation, transcoding, and processing queue</span>
|
||||
<div className="spacer" />
|
||||
{counts.failed > 0 && (
|
||||
<button className="btn ghost sm" onClick={handleRetryAll} title={`Retry all ${counts.failed} failed jobs`}>
|
||||
<Icon name="refresh" />Retry all failed
|
||||
</button>
|
||||
)}
|
||||
<button className="btn ghost sm" onClick={refresh}>
|
||||
<Icon name="refresh" />Refresh
|
||||
</button>
|
||||
|
|
@ -142,7 +158,15 @@ function JobRow({ job, onRetry, onDelete }) {
|
|||
)}
|
||||
{job.status === 'done' && <span className="badge success" style={{ background: 'transparent', padding: 0 }}><Icon name="check" size={12} /> Complete</span>}
|
||||
{job.status === 'queued' && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>Waiting…</span>}
|
||||
{job.status === 'failed' && <span style={{ fontSize: 12, color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: 4 }}><Icon name="alert" size={12} />{job.error || 'Failed'}</span>}
|
||||
{job.status === 'failed' && (
|
||||
<span title={job.error || 'Failed'}
|
||||
style={{ fontSize: 12, color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
||||
<Icon name="alert" size={12} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
||||
{(job.error || 'Failed').slice(0, 120)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-3)' }}>{job.eta}</div>
|
||||
<div><span className={'badge ' + (job.priority === 'high' ? 'warning' : 'outline')}>{job.priority}</span></div>
|
||||
|
|
|
|||
|
|
@ -240,14 +240,18 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen }) {
|
|||
{(bins && bins.length > 0) ? (
|
||||
<>
|
||||
<div className="ctx-section-label">Move to bin</div>
|
||||
{bins.slice(0, 10).map(function(b) {
|
||||
const isCurrent = asset.bin_id === b.id;
|
||||
return (
|
||||
<button key={b.id} onClick={function() { moveToBin(b.id); }} disabled={isCurrent}>
|
||||
<Icon name="folder" size={11} />{b.name}{isCurrent && <span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--text-3)' }}>current</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{bins
|
||||
.filter(function(b) { return !asset.project_id || b.project_id === asset.project_id; })
|
||||
.slice(0, 10)
|
||||
.map(function(b) {
|
||||
const isCurrent = asset.bin_id === b.id;
|
||||
return (
|
||||
<button key={b.id} onClick={function() { moveToBin(b.id); }} disabled={isCurrent}
|
||||
title={b.project_name ? 'in ' + b.project_name : ''}>
|
||||
<Icon name="folder" size={11} />{b.name}{isCurrent && <span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--text-3)' }}>current</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{asset.bin_id && (
|
||||
<button onClick={function() { moveToBin(null); }}>
|
||||
<Icon name="x" size={11} />Remove from bin
|
||||
|
|
@ -255,7 +259,7 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen }) {
|
|||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="ctx-empty">No bins — create one in a project</div>
|
||||
<div className="ctx-empty">No bins — create one inside a project</div>
|
||||
)}
|
||||
<div className="ctx-divider" />
|
||||
<button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
|
||||
|
|
|
|||
|
|
@ -47,13 +47,51 @@ function Projects({ onOpenProject, navigate }) {
|
|||
const [search, setSearch] = React.useState('');
|
||||
const [view, setView] = React.useState('grid');
|
||||
const [showNew, setShowNew] = React.useState(false);
|
||||
const [menuFor, setMenuFor] = React.useState(null);
|
||||
|
||||
const onCreated = (p) => {
|
||||
const updated = [p, ...window.ZAMPP_DATA.PROJECTS];
|
||||
window.ZAMPP_DATA.PROJECTS = updated;
|
||||
setProjects(updated);
|
||||
const refresh = React.useCallback(() => {
|
||||
window.ZAMPP_API.fetch('/projects')
|
||||
.then(list => {
|
||||
const updated = (list || []).map((p, i) => ({
|
||||
...p,
|
||||
color: (window.ZAMPP_DATA.PROJECTS.find(x => x.id === p.id) || {}).color
|
||||
|| window.PROJECT_COLORS?.[i % (window.PROJECT_COLORS?.length || 1)]
|
||||
|| 'var(--accent)',
|
||||
assets: (ASSETS || []).filter(a => a.project_id === p.id).length,
|
||||
updated: window.ZAMPP_API.fmtRelative(p.updated_at),
|
||||
}));
|
||||
window.ZAMPP_DATA.PROJECTS = updated;
|
||||
setProjects(updated);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [ASSETS]);
|
||||
|
||||
const onCreated = (p) => { refresh(); };
|
||||
|
||||
const renameProject = (p) => {
|
||||
setMenuFor(null);
|
||||
const next = prompt('Rename project', p.name);
|
||||
if (!next || !next.trim() || next.trim() === p.name) return;
|
||||
window.ZAMPP_API.fetch('/projects/' + p.id, { method: 'PATCH', body: JSON.stringify({ name: next.trim() }) })
|
||||
.then(refresh)
|
||||
.catch(e => alert('Rename failed: ' + e.message));
|
||||
};
|
||||
|
||||
const deleteProject = (p) => {
|
||||
setMenuFor(null);
|
||||
if (!confirm('Delete project "' + p.name + '"?\nThis fails if there are still assets attached.')) return;
|
||||
window.ZAMPP_API.fetch('/projects/' + p.id, { method: 'DELETE' })
|
||||
.then(refresh)
|
||||
.catch(e => alert('Delete failed: ' + e.message));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!menuFor) return;
|
||||
const close = () => setMenuFor(null);
|
||||
window.addEventListener('click', close);
|
||||
return () => window.removeEventListener('click', close);
|
||||
}, [menuFor]);
|
||||
|
||||
let filtered = projects;
|
||||
if (search) filtered = filtered.filter(p => p.name.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
|
|
@ -101,7 +139,16 @@ function Projects({ onOpenProject, navigate }) {
|
|||
<div className="col-sub">{p.assets || 0}</div>
|
||||
<div className="col-sub">—</div>
|
||||
<div className="col-sub">{p.updated || '—'}</div>
|
||||
<button className="icon-btn" onClick={e => e.stopPropagation()}><Icon name="more" /></button>
|
||||
<div style={{ position: 'relative' }} onClick={e => e.stopPropagation()}>
|
||||
<button className="icon-btn" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === p.id ? null : p.id); }}><Icon name="more" /></button>
|
||||
{menuFor === p.id && (
|
||||
<div className="row-menu" onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => { setMenuFor(null); onOpenProject(p); }}><Icon name="library" size={11} />Open</button>
|
||||
<button onClick={() => renameProject(p)}><Icon name="edit" size={11} />Rename…</button>
|
||||
<button className="danger" onClick={() => deleteProject(p)}><Icon name="trash" size={11} />Delete</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -113,7 +160,18 @@ function Projects({ onOpenProject, navigate }) {
|
|||
}
|
||||
|
||||
function ProjectCard({ project, assets, onOpen }) {
|
||||
const thumbAssets = assets.filter(a => a.project_id === project.id).slice(0, 4);
|
||||
const ofProject = assets.filter(a => a.project_id === project.id);
|
||||
const thumbAssets = ofProject.slice(0, 4);
|
||||
|
||||
// Real status distribution — ready vs processing/live vs error.
|
||||
const total = ofProject.length || 1;
|
||||
const ready = ofProject.filter(a => a.status === 'ready').length;
|
||||
const inFlight = ofProject.filter(a => a.status === 'processing' || a.status === 'live').length;
|
||||
const errored = ofProject.filter(a => a.status === 'error').length;
|
||||
const readyPct = (ready / total) * 100;
|
||||
const inFlightPct = (inFlight / total) * 100;
|
||||
const errPct = (errored / total) * 100;
|
||||
|
||||
return (
|
||||
<div className="project-card" onClick={onOpen}>
|
||||
<div className="project-thumb-grid">
|
||||
|
|
@ -131,14 +189,19 @@ function ProjectCard({ project, assets, onOpen }) {
|
|||
<span style={{ fontWeight: 600, fontSize: 14 }}>{project.name}</span>
|
||||
</div>
|
||||
<div className="project-meta">
|
||||
<span>{project.assets || 0} assets</span>
|
||||
<span>{ofProject.length} asset{ofProject.length === 1 ? '' : 's'}</span>
|
||||
<span>·</span>
|
||||
<span>updated {project.updated || '—'}</span>
|
||||
</div>
|
||||
<div className="project-bar">
|
||||
<div className="project-segment" style={{ width: '70%', background: project.color || 'var(--accent)' }} />
|
||||
<div className="project-segment" style={{ width: '20%', background: 'var(--bg-4)' }} />
|
||||
</div>
|
||||
{ofProject.length > 0 ? (
|
||||
<div className="project-bar" title={`ready ${ready} · in-flight ${inFlight} · errored ${errored}`}>
|
||||
{ready > 0 && <div className="project-segment" style={{ width: readyPct + '%', background: 'var(--success)' }} />}
|
||||
{inFlight > 0 && <div className="project-segment" style={{ width: inFlightPct + '%', background: 'var(--accent)' }} />}
|
||||
{errored > 0 && <div className="project-segment" style={{ width: errPct + '%', background: 'var(--danger)' }} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="project-bar"><div className="project-segment" style={{ width: '100%', background: 'var(--bg-3)' }} /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue