feat: jobs move to the workbench, with their logs and a way to clear them
**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:
parent
8461abf5bf
commit
4aa1352da7
8 changed files with 86 additions and 99 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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}>← Prev</button>
|
||||
{flipped ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue