- Real server-side pagination (platform/api/src/pagination.js) on the audit log (was a flat LIMIT 200), student attempt history (was LIMIT 100), and assessment session history (was fully unbounded - the one genuine growth risk found). Frontend updated (AuditView, AssessmentsView, StudentsView). - Admission control: resource-policy.yaml's new (disabled-by-default) admission_control.max_concurrent_labs, checked before touching Docker at all, returns a clean 503 CAPACITY_EXCEEDED instead of failing mid-provision. Relayed as a clean student-facing message, never a raw 500. New GET /capacity endpoint feeds the instructor dashboard. - cohort_instructors.role_in_cohort (existed since Phase 2B, never read) is now enforced: teacher vs viewer, rbac.js's requireCohortWriteAccess/requireStudentCohortWriteAccess. - GET /api/version - app version, deployed Git SHA, latest migration as a schema-version stand-in. - Structured assessment authoring UI (AssessmentsView.jsx's PoolSpecEditor) replaces the raw-JSON pool_spec textarea, backed by a new GET /api/admin/scenario-catalog endpoint. Owner-only raw-JSON fallback remains for anything the structured editor doesn't cover. - NEEDS_ATTENTION_FAIL_THRESHOLD/STALE_DAYS moved to env-var configuration. Full regression suite (e2e-phase2a/2b/3a/3b, 67/67) re-run clean after these changes; frontend production build confirmed clean.
137 lines
6.0 KiB
React
137 lines
6.0 KiB
React
import { useState, useEffect, useCallback } from 'react'
|
|
import { adminApi } from './api'
|
|
import { StatusPill } from './CohortsView'
|
|
import styles from './Admin.module.css'
|
|
|
|
export default function StudentsView() {
|
|
const [students, setStudents] = useState(null)
|
|
const [selected, setSelected] = useState(null)
|
|
const [error, setError] = useState(null)
|
|
|
|
const reload = useCallback(() => {
|
|
adminApi.students().then(setStudents).catch((e) => setError(e.message))
|
|
}, [])
|
|
useEffect(reload, [reload])
|
|
|
|
if (selected) return <StudentDetail studentId={selected} onBack={() => { setSelected(null); reload() }} />
|
|
|
|
return (
|
|
<div className={styles.card}>
|
|
<h2>Students ({students?.length ?? '…'})</h2>
|
|
{error && <div className={styles.errorBox}>{error}</div>}
|
|
{students && (
|
|
<table>
|
|
<thead><tr><th>Name</th><th>ID</th><th>Status</th><th>Last active</th><th /></tr></thead>
|
|
<tbody>
|
|
{students.map((s) => (
|
|
<tr key={s.student_id}>
|
|
<td>{s.display_name}</td>
|
|
<td className={styles.muted}>{s.student_id}</td>
|
|
<td><span className={`${styles.pill} ${s.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
|
|
<td>{s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'}</td>
|
|
<td><button className={styles.linkBtn} onClick={() => setSelected(s.student_id)}>Open</button></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StudentDetail({ studentId, onBack }) {
|
|
const [progress, setProgress] = useState(null)
|
|
const [attempts, setAttempts] = useState(null)
|
|
const [env, setEnv] = useState(null)
|
|
const [events, setEvents] = useState(null)
|
|
const [error, setError] = useState(null)
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
const reload = useCallback(() => {
|
|
adminApi.studentProgress(studentId).then(setProgress).catch(() => {})
|
|
// Phase 4.1 §16 — studentAttempts is now paginated; this panel only ever shows the most
|
|
// recent page (labeled "Recent attempts," not a full history browser — see
|
|
// /api/admin/students/:id/attempts's own pagination for the underlying capability).
|
|
adminApi.studentAttempts(studentId).then((r) => setAttempts(r.items)).catch(() => {})
|
|
adminApi.studentEnvironment(studentId).then((rows) => setEnv(rows?.find((e) => e.status !== 'destroyed') || null)).catch(() => {})
|
|
}, [studentId])
|
|
useEffect(reload, [reload])
|
|
|
|
const withBusy = (fn) => async () => {
|
|
setBusy(true); setError(null)
|
|
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
|
|
}
|
|
|
|
const loadEvents = async () => {
|
|
if (!env) return
|
|
try { setEvents(await adminApi.environmentEvents(env.environment_id)) } catch (e) { setError(e.message) }
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<button className={styles.linkBtn} onClick={onBack}>← Back to students</button>
|
|
<h2 style={{ margin: '1rem 0' }}>{studentId}</h2>
|
|
{error && <div className={styles.errorBox}>{error}</div>}
|
|
|
|
<div className={styles.card}>
|
|
<h3>Environment</h3>
|
|
{!env ? <p className={styles.muted}>No Lab provisioned.</p> : (
|
|
<>
|
|
<p>Status: <StatusPill status={env.status} /> — Lab host: {env.lab_host_id} — created {new Date(env.created_at).toLocaleString()}</p>
|
|
{env.last_active_at && <p className={styles.muted}>Last active: {new Date(env.last_active_at).toLocaleString()}</p>}
|
|
</>
|
|
)}
|
|
<div className={styles.formRow} style={{ marginTop: '0.75rem' }}>
|
|
{!env && <button className={styles.btn} disabled={busy} onClick={withBusy(() => adminApi.provisionStudentLab(studentId))}>Provision Lab</button>}
|
|
{env && <button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.resetEnvironment(env.environment_id))}>Reset/Recreate</button>}
|
|
{env && (
|
|
<button className={styles.btnDanger} disabled={busy} onClick={withBusy(async () => {
|
|
if (window.confirm('Destroy this Lab? Progress is not affected.')) await adminApi.destroyEnvironment(env.environment_id)
|
|
})}>Destroy Lab</button>
|
|
)}
|
|
{env && <button className={styles.linkBtn} onClick={loadEvents}>View event history</button>}
|
|
</div>
|
|
{events && (
|
|
<table style={{ marginTop: '0.75rem' }}>
|
|
<thead><tr><th>Event</th><th>Actor</th><th>When</th></tr></thead>
|
|
<tbody>
|
|
{events.map((e) => <tr key={e.id}><td>{e.event_type}</td><td>{e.actor}</td><td>{new Date(e.created_at).toLocaleString()}</td></tr>)}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.card}>
|
|
<h3>Curriculum progress</h3>
|
|
{!progress ? <p className={styles.muted}>Loading…</p> : progress.stages.map((s) => (
|
|
<div key={s.id} style={{ marginBottom: '0.5rem' }}>
|
|
<strong>{s.name}</strong>
|
|
<div className={styles.muted}>
|
|
{s.scenarios.filter((sc) => sc.status === 'completed').length}/{s.scenarios.length} complete
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className={styles.card}>
|
|
<h3>Recent attempts</h3>
|
|
{!attempts ? <p className={styles.muted}>Loading…</p> : attempts.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
|
<table>
|
|
<thead><tr><th>Scenario</th><th>Result</th><th>Checks</th><th>When</th></tr></thead>
|
|
<tbody>
|
|
{attempts.map((a) => (
|
|
<tr key={a.attempt_id}>
|
|
<td>{a.scenario_id}</td>
|
|
<td><span className={`${styles.pill} ${a.result === 'passed' ? styles.pillOk : styles.pillWarn}`}>{a.result}</span></td>
|
|
<td className={styles.muted}>{a.validation_count ?? '—'}</td>
|
|
<td>{new Date(a.created_at).toLocaleString()}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|