feat: jobs move to the workbench, with their logs and a way to clear them
Some checks failed
Tests / backend (push) Failing after 11s
Tests / frontend (push) Successful in 33s
Tests / e2e (push) Failing after 31s

**The badge is off the navbar.** It sat in the header of every page, for
everybody, polling every thirty seconds — for a number that means something to
the handful of people who start an extraction and nothing at all to a learner
sitting a session. Extraction is workbench business and it lives there now:
Settings → Tools → Jobs, and a link from the workbench itself, which is where
one is started.

To answer the question it raised: a job ages off the list after a day, and the
steps behind it after an hour. Which is to say it disappears when Redis forgets
it, on its own, with nothing to tell you it had.

**So there is now a way to clear one.** "Forget" takes a job off your list, with
a confirm beside it. It stops nothing that is running — the button is not
offered for a running job — and deletes nothing the job produced; it clears a
line somebody has read and dealt with so the ones they have not are not buried
under it. Only from your own list: the id alone is not authority over anybody
else's, and the keys behind it are shared.

**And a way to read one.** The details panel is called "Log" now, because that
is what it is — every step the job took, in order, and the reason it stopped if
it stopped. That reason has been recorded all along and shown nowhere.

Also, on the deck: the way out is a back link above the title like every other
page rather than a small grey button in the bar of card controls, and the four
buttons that are the whole interaction are full size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-13 03:41:22 +02:00
parent 8461abf5bf
commit 4aa1352da7
8 changed files with 86 additions and 99 deletions

View file

@ -122,6 +122,32 @@ def list_user_jobs(current_user: User = Depends(get_current_user)):
return jobs
@router.delete("/jobs/{job_id}", status_code=204)
def forget_job(job_id: str, current_user: User = Depends(get_current_user)):
"""Take one job off this person's list.
A job is a record of work, not the work itself: removing it stops nothing
that is running and deletes nothing it produced. It exists because the list
is a list of what happened, and one somebody has read and dealt with is
clutter in front of the ones they have not.
Only from your own list the id alone is not authority to touch anybody
else's, and the keys behind it are shared.
"""
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
key = f"extraction:user_jobs:{current_user.id}"
if not r.lrem(key, 0, job_id):
raise HTTPException(404, "That job is not in your list")
# The steps and status go with it. They are keyed by job id and nothing
# else points at them once the list entry is gone, so leaving them would
# simply be rubbish with an hour to live.
for suffix in ("status", "steps", "error", "job_title", "batch_id", "quiz_id", "article"):
r.delete(f"extraction:{suffix}:{job_id}")
@router.get("/job/{job_id}")
def get_extraction_job(job_id: str, current_user: User = Depends(require_moderator)):
"""Poll extraction job progress. Returns the steps, the status, and — when it

View file

@ -93,76 +93,6 @@ function FeedbackBadge() {
}
function JobsBadge({ jobs }) {
const [open, setOpen] = useState(false)
const wrap = useRef(null)
const allJobs = jobs
const activeJobs = jobs.filter(j => j.status === 'running' || j.status === 'pending')
// Anywhere outside closes it, and so does Escape. It only closed when a job
// in it was clicked, so a panel opened to check on something then sat over
// the page until you found the badge again.
useEffect(() => {
if (!open) return undefined
const away = event => { if (!wrap.current?.contains(event.target)) setOpen(false) }
const onKey = event => { if (event.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', away)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', away)
document.removeEventListener('keydown', onKey)
}
}, [open])
if (allJobs.length === 0) return null
const running = activeJobs.length
return (
<div style={{ position: 'relative' }} ref={wrap}>
<button aria-expanded={open} onClick={() => setOpen(v => !v)} style={{
background: running > 0 ? '#f59e0b' : 'rgba(255,255,255,0.12)',
color: 'white', border: 'none', borderRadius: 20,
padding: '3px 10px', fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 5,
}}>
{running > 0 && <div className="spinner" style={{ width: 10, height: 10, borderWidth: 2, borderColor: 'rgba(255,255,255,0.4)', borderTopColor: 'white' }} />}
{running > 0 ? `${running} extracting` : `${allJobs.length} jobs`}
</button>
{open && (
<div style={{
position: 'absolute', right: 0, top: '120%', background: 'var(--card-bg)',
border: '1px solid var(--border)', borderRadius: 10, padding: 12,
minWidth: 260, boxShadow: '0 8px 24px rgba(0,0,0,0.15)', zIndex: 200,
}}>
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
Extractions
</div>
{allJobs.slice(0, 5).map(job => (
<div key={job.job_id} style={{ fontSize: '0.82rem', color: 'var(--text)', marginBottom: 10, paddingBottom: 8, borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 3 }}>
<span style={{ fontWeight: 600 }}>{job.title}</span>
<span style={{
fontSize: '0.7rem', fontWeight: 600, padding: '1px 6px', borderRadius: 10,
background: job.status === 'completed' ? 'var(--correct-bg)' : job.status === 'failed' ? 'var(--wrong-bg)' : '#fef3c7',
color: job.status === 'completed' ? 'var(--correct-fg)' : job.status === 'failed' ? 'var(--wrong-fg)' : '#92400e',
}}>
{job.status === 'running' ? `${job.steps_count} steps` : job.status}
</span>
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.78rem' }}>{job.last_step || 'Waiting…'}</div>
{job.status === 'completed' && job.quiz_id && (
<Link to={`/study/${job.quiz_id}`} style={{ fontSize: '0.75rem', color: 'var(--primary)', textDecoration: 'none', display: 'block', marginTop: 4 }}
onClick={() => setOpen(false)}>Open Quiz </Link>
)}
</div>
))}
{allJobs.length === 0 && <div style={{ color: 'var(--text-muted)', fontSize: '0.82rem' }}>No recent extractions</div>}
</div>
)}
</div>
)
}
/**
* You, rather than the material: dashboard, account, settings, sign out.
*
@ -231,7 +161,6 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
const [peek, setPeek] = useState(false)
// Registered by the quiz player while it is on screen without a rail.
const sessionDrawer = useSessionDrawer()
const [jobs, setJobs] = useState([])
const location = useLocation()
const inSession = useInSession()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
@ -249,24 +178,10 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
.catch(() => setCanManageQuestions(false))
}, [user, isModerator])
// Single job-polling instance for the whole navbar
useEffect(() => {
if (!user) return
let interval
const load = async () => {
try {
const res = await api.get('/quizzes/jobs')
const jobList = res.data || []
setJobs(jobList)
const running = jobList.filter(j => j.status === 'running' || j.status === 'pending')
clearInterval(interval)
interval = setInterval(load, running.length > 0 ? 3000 : 30000)
} catch { }
}
load()
interval = setInterval(load, 30000)
return () => clearInterval(interval)
}, [user])
// No job badge here any more. Extraction is workbench business a handful
// of people ever start one and it sat in the header of every page, for
// everybody, polling every thirty seconds for a number most of them had no
// use for. It lives on the Extraction jobs page, where the logs are.
// Where you go to study. Home is the signed-out landing page, so it is not
@ -333,7 +248,6 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
{user ? (
<div className="navbar-account">
<FeedbackBadge />
<JobsBadge jobs={jobs} />
<AccountMenu user={user} onLogout={logout} />
</div>

View file

@ -38,7 +38,8 @@ body:has(.fc-study) .site-footer { display: none; }
/* The deck's own bar: what you are studying and how far in. Fixed, like the
player's the counter leaving the screen exactly when a long card makes you
want it is the thing this prevents. */
.fc-head { flex: none; padding: 14px 0 10px; border-bottom: 1px solid var(--border); }
.fc-head { flex: none; padding: 10px 0; border-bottom: 1px solid var(--border); }
.fc-head .articles-back { display: inline-block; margin-bottom: 6px; }
.fc-head-row {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap;
@ -72,7 +73,11 @@ body:has(.fc-study) .site-footer { display: none; }
padding: 10px 0 calc(10px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
}
.fc-foot-nav { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; }
/* Full-size, not the small variant. These four are the whole interaction
read, judge, move on and they were the smallest controls on a screen with
nothing else competing for the space. */
.fc-foot-nav { flex: 1; display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap; }
.fc-foot-nav .btn { min-width: 132px; padding: 12px 22px; font-size: .95rem; font-weight: 600; }
.fc-keys { flex: none; margin: 0; font-size: .74rem; color: var(--text-subtle); }
.fc-known { background: #22c55e; border-color: #22c55e; color: #fff; }

View file

@ -154,6 +154,10 @@ export default function FlashcardStudyPage() {
counter that scrolls away with the content is a counter that leaves
exactly when a long card makes you want it. */}
<div className="fc-head">
{/* The way out, above the title, like every other page not a small
grey button in a bar whose other controls are about the card in
front of you. */}
<Link to="/flashcards" className="articles-back"> Cards</Link>
<div className="fc-head-row">
<div>
<h2>{deck.title}</h2>
@ -239,7 +243,6 @@ export default function FlashcardStudyPage() {
No Exit session a deck has no block to hand in, and the
controls that change the run are at the top where they belong. */}
<div className="fc-foot">
<Link to="/flashcards" className="btn btn-secondary btn-sm"> All decks</Link>
<div className="fc-foot-nav">
<button className="btn btn-secondary" onClick={prev} disabled={currentIdx === 0}>&#8592; Prev</button>
{flipped ? (

View file

@ -5,9 +5,10 @@ import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
import BackLink from '../components/BackLink'
function JobDetail({ job }) {
function JobDetail({ job, onForget }) {
const [steps, setSteps] = useState([])
const [expanded, setExpanded] = useState(false)
const [forgetting, setForgetting] = useState(false)
const [quizData, setQuizData] = useState(null)
const { dialogProps, openAlert } = useDialog()
const pollRef = useRef(null)
@ -80,8 +81,22 @@ function JobDetail({ job }) {
}}>Cancel</button>
)}
<button className="btn btn-secondary btn-sm" onClick={() => setExpanded(v => !v)}>
{expanded ? 'Hide' : 'Details'}
{expanded ? 'Hide log' : 'Log'}
</button>
{/* Off the list, not undone. A job is a record of work: forgetting
one stops nothing that is running and deletes nothing it made
it clears a line somebody has already read and dealt with, so the
ones they have not are not buried under it. */}
{job.status !== 'running' && (
forgetting ? (
<>
<button className="btn btn-danger btn-sm" onClick={() => onForget(job.job_id)}>Remove</button>
<button className="btn btn-secondary btn-sm" onClick={() => setForgetting(false)}>Keep</button>
</>
) : (
<button className="btn btn-secondary btn-sm" onClick={() => setForgetting(true)}>Forget</button>
)
)}
</div>
</div>
@ -154,22 +169,34 @@ export default function JobsPage() {
.finally(() => setLoading(false))
}, [])
const forget = async (jobId) => {
try {
await api.delete(`/quizzes/jobs/${jobId}`)
setJobs(rows => rows.filter(row => row.job_id !== jobId))
} catch { /* the row stays, which is the honest signal */ }
}
if (loading) return <div className="loading"><div className="spinner" /></div>
return (
<div style={{ maxWidth: 760, margin: '0 auto' }}>
<BackLink to="/settings?s=tools">Tools</BackLink>
<div className="card" style={{ marginBottom: 16 }}>
<h2>Extraction Jobs</h2>
<h2>Jobs</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginTop: 4 }}>
History of quiz extractions including skipped questions and chunk details.
Everything the machine has been asked to do for you extractions, AI
drafts, card generation. Open the log to see what it did, step by
step, and why it stopped if it stopped. Jobs age out of this list on
their own after a day.
</p>
</div>
{jobs.length === 0 ? (
<div className="card"><div className="empty-state">No extractions yet.</div></div>
) : (
jobs.map(job => <JobDetail key={job.job_id} job={job} />)
jobs.map(job => (
<JobDetail key={job.job_id} job={job} onForget={forget} />
))
)}
</div>
)

View file

@ -253,7 +253,7 @@ function ToolsSection() {
how somebody ends up in Settings wondering how to get back. */
{ to: '/access', icon: '🔑', label: 'Access', desc: 'Who may edit what' },
{ to: '/trash', icon: '🗑️', label: 'Trash', desc: 'Restore deleted questions' },
{ to: '/jobs', icon: '📋', label: 'Extraction jobs', desc: 'Extraction history' },
{ to: '/jobs', icon: '📋', label: 'Jobs', desc: 'Extractions, drafts and card runs, with their logs' },
].map(item => (
<Link key={item.to} to={item.to} className="set-card">
<div className="set-card-icon" aria-hidden="true">{item.icon}</div>

View file

@ -58,3 +58,11 @@
shows, so it brings its own layout; this only gives it room to breathe and
somewhere to scroll when the list is long. */
.tools-labs { margin-top: 14px; max-height: 60vh; overflow-y: auto; }
/* Where the jobs this page starts end up. Beside the heading, because it is a
destination rather than part of the trail back out. */
.tools-jobs-link {
float: right; margin-top: -34px;
font-size: .85rem; font-weight: 600; color: var(--primary); text-decoration: none;
}
.tools-jobs-link:hover { text-decoration: underline; }

View file

@ -58,6 +58,10 @@ export default function ToolsPage() {
looks like a different application. */}
<BackLink to="/questions/manage">Questions</BackLink>
<h1>Tools</h1>
{/* Where a job started here ends up. It used to be a badge in the site
header, on every page, for everybody polling every thirty seconds
for a number almost nobody had a use for. */}
<Link to="/jobs" className="tools-jobs-link">Jobs and logs </Link>
<p>
A document goes in, a model proposes questions, you read them, and the
ones worth keeping move into the bank. Nothing is in the bank until