feat(platform): pagination, admission control, cohort roles, version endpoint, structured assessment editor

- 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.
This commit is contained in:
2026-08-24 20:15:39 -05:00
parent addf474b70
commit a8e543234c
13 changed files with 369 additions and 60 deletions
+3
View File
@@ -57,3 +57,6 @@ tr:hover td { background: var(--surface2); }
.linkBtn { background: none; border: none; color: var(--blue); cursor: pointer; font-size: 0.85rem; padding: 0; text-decoration: underline; }
.errorBox { background: var(--red-dim); color: var(--red); border: 1px solid var(--red); border-radius: var(--radius); padding: 0.6rem 0.8rem; font-size: 0.85rem; margin-bottom: 1rem; }
.muted { color: var(--text-2); font-size: 0.85rem; }
/* Phase 4.1 §16 — shared pagination control (AuditView, StudentsView attempts). */
.pager { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.75rem; }
+107 -13
View File
@@ -29,7 +29,7 @@ export default function AssessmentsView({ role }) {
{canAuthor && (
<div className={styles.card}>
<h2>Create assessment</h2>
<CreateAssessmentForm onCreated={reload} />
<CreateAssessmentForm onCreated={reload} role={role} />
</div>
)}
<div className={styles.card}>
@@ -56,20 +56,86 @@ export default function AssessmentsView({ role }) {
)
}
function CreateAssessmentForm({ onCreated }) {
const [form, setForm] = useState({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled', pool_spec: '' })
// Phase 4.1 §18 — pool_spec is built from structured picks (required scenarios + named random
// pools), not hand-typed JSON. `catalog` ({ stages: [{ id, name, scenarios: [{id,title}] }] })
// comes from GET /api/admin/scenario-catalog. An owner can still drop into a raw-JSON view for
// anything the structured editor doesn't cover — see the toggle below.
function PoolSpecEditor({ catalog, required, setRequired, pools, setPools }) {
const allScenarios = (catalog?.stages || []).flatMap((s) => s.scenarios.map((sc) => ({ ...sc, stageName: s.name })))
const titleFor = (id) => allScenarios.find((s) => s.id === id)?.title || id
const addPool = () => setPools([...pools, { count: 1, from: [] }])
const removePool = (i) => setPools(pools.filter((_, idx) => idx !== i))
const updatePool = (i, patch) => setPools(pools.map((p, idx) => (idx === i ? { ...p, ...patch } : p)))
return (
<div>
<label className={styles.label}>Required scenarios (always included)</label>
<select className={styles.select} multiple size={6} style={{ width: '100%' }}
value={required} onChange={(e) => setRequired([...e.target.selectedOptions].map((o) => o.value))}>
{catalog?.stages.map((stage) => (
<optgroup key={stage.id} label={stage.name}>
{stage.scenarios.map((sc) => <option key={sc.id} value={sc.id}>{sc.title}</option>)}
</optgroup>
))}
</select>
<p className={styles.muted}>{required.length ? `${required.length} selected: ${required.map(titleFor).join(', ')}` : 'None selected — cmd/ctrl-click to pick multiple.'}</p>
<label className={styles.label} style={{ marginTop: '1rem', display: 'block' }}>Random pools (draw N at random from a set, every attempt)</label>
{pools.map((pool, i) => (
<div key={i} className={styles.card} style={{ marginBottom: '0.5rem' }}>
<div className={styles.formRow}>
<div style={{ maxWidth: '100px' }}><label className={styles.label}>Draw count</label>
<input className={styles.input} type="number" min="1" value={pool.count}
onChange={(e) => updatePool(i, { count: Number(e.target.value) })} /></div>
<div><button type="button" className={styles.linkBtn} onClick={() => removePool(i)}>Remove pool</button></div>
</div>
<label className={styles.label}>Draw from</label>
<select className={styles.select} multiple size={5} style={{ width: '100%' }}
value={pool.from} onChange={(e) => updatePool(i, { from: [...e.target.selectedOptions].map((o) => o.value) })}>
{catalog?.stages.map((stage) => (
<optgroup key={stage.id} label={stage.name}>
{stage.scenarios.map((sc) => <option key={sc.id} value={sc.id}>{sc.title}</option>)}
</optgroup>
))}
</select>
{pool.count > pool.from.length && <p className={styles.muted} style={{ color: 'var(--amber, #b58900)' }}>Draw count ({pool.count}) exceeds the pool size ({pool.from.length}) — every draw will use every scenario, not a random subset.</p>}
</div>
))}
<button type="button" className={styles.linkBtn} onClick={addPool}>+ Add random pool</button>
</div>
)
}
function CreateAssessmentForm({ onCreated, role }) {
const [form, setForm] = useState({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled' })
const [catalog, setCatalog] = useState(null)
const [required, setRequired] = useState([])
const [pools, setPools] = useState([])
const [advanced, setAdvanced] = useState(false)
const [rawPoolSpec, setRawPoolSpec] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
useEffect(() => { adminApi.scenarioCatalog().then(setCatalog).catch((e) => setError(e.message)) }, [])
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
const pool_spec = JSON.parse(form.pool_spec || '{"required":[],"random":[]}')
// Owner's raw-JSON view, when open, is the source of truth — otherwise built from the
// structured pickers above.
const pool_spec = advanced
? JSON.parse(rawPoolSpec || '{"required":[],"random":[]}')
: { required, random: pools.filter((p) => p.count > 0 && p.from.length > 0) };
if (!advanced && !required.length && !pool_spec.random.length) {
throw new Error('Pick at least one required scenario or one random pool.')
}
await adminApi.createAssessment({ ...form, duration_minutes: Number(form.duration_minutes), pool_spec })
setForm({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled', pool_spec: '' })
setForm({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled' })
setRequired([]); setPools([]); setRawPoolSpec('')
onCreated()
} catch (e) { setError(e.message?.includes('JSON') ? 'pool_spec must be valid JSON' : e.message) } finally { setBusy(false) }
} catch (e) { setError(e.message?.includes('JSON') ? 'Raw pool_spec must be valid JSON' : e.message) } finally { setBusy(false) }
}
return (
@@ -91,26 +157,47 @@ function CreateAssessmentForm({ onCreated }) {
<option value="disabled">disabled</option><option value="flagged">flagged</option><option value="free">free</option>
</select></div>
</div>
<label className={styles.label}>Pool spec (JSON)</label>
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '4rem' }}
placeholder='{"required":["scenario-id"],"random":[{"count":1,"from":["a","b"]}]}'
value={form.pool_spec} onChange={(e) => setForm({ ...form, pool_spec: e.target.value })} required />
{role === 'owner' && (
<p className={styles.muted}>
<button type="button" className={styles.linkBtn} onClick={() => setAdvanced(!advanced)}>
{advanced ? '← Back to the structured editor' : 'Advanced: edit pool_spec as raw JSON →'}
</button>
</p>
)}
{advanced ? (
<>
<label className={styles.label}>Pool spec (JSON)</label>
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '4rem' }}
placeholder='{"required":["scenario-id"],"random":[{"count":1,"from":["a","b"]}]}'
value={rawPoolSpec} onChange={(e) => setRawPoolSpec(e.target.value)} required />
</>
) : !catalog ? <p className={styles.muted}>Loading scenario catalog…</p> : (
<PoolSpecEditor catalog={catalog} required={required} setRequired={setRequired} pools={pools} setPools={setPools} />
)}
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create'}</button>
</form>
)
}
function AssessmentDetail({ definition, cohorts, onBack }) {
const [sessions, setSessions] = useState(null)
// Phase 4.1 §16 — assessmentSessions is now paginated (was unbounded); `result` holds the
// full { items, page, total, total_pages } envelope, `sessions` stays the plain array for
// the table below so its render logic doesn't need to change.
const [result, setResult] = useState(null)
const [page, setPage] = useState(1)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [cohortId, setCohortId] = useState(cohorts?.[0]?.cohort_id || '')
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
adminApi.assessmentSessions(definition.assessment_definition_id).then(setSessions).catch((e) => setError(e.message))
}, [definition])
adminApi.assessmentSessions(definition.assessment_definition_id, page).then(setResult).catch((e) => setError(e.message))
}, [definition, page])
useEffect(reload, [reload])
const sessions = result?.items || null
const assign = async () => {
setBusy(true); setError(null); setNotice(null)
@@ -158,6 +245,13 @@ function AssessmentDetail({ definition, cohorts, onBack }) {
</tbody>
</table>
)}
{result && result.total_pages > 1 && (
<div className={styles.pager}>
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Previous</button>
<span className={styles.muted}>Page {result.page} of {result.total_pages} ({result.total} total)</span>
<button disabled={page >= result.total_pages} onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
)}
</div>
</>
)
+32 -21
View File
@@ -2,36 +2,47 @@ import { useState, useEffect } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// admin/owner only (enforced server-side too). Latest 200 events, no pagination UI — this is a
// pilot-scale audit log, not a SIEM; add pagination if that ever stops being true.
// admin/owner only (enforced server-side too). Phase 4.1 §16 — real server-side pagination
// (platform/api/src/pagination.js) replaces the old flat "latest 200, nothing older" cap.
export default function AuditView() {
const [events, setEvents] = useState(null)
const [page, setPage] = useState(1)
const [result, setResult] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
adminApi.auditEvents().then(setEvents).catch((e) => setError(e.message))
}, [])
setResult(null)
adminApi.auditEvents(page).then(setResult).catch((e) => setError(e.message))
}, [page])
const events = result?.items || []
return (
<div className={styles.card}>
<h2>Audit log</h2>
<p className={styles.muted}>Most recent 200 security/admin events.</p>
<p className={styles.muted}>{result ? `${result.total} total events` : 'Security/admin events, most recent first.'}</p>
{error && <div className={styles.errorBox}>{error}</div>}
{!events ? <p className={styles.muted}>Loading…</p> : (
<table>
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Resource</th><th>Result</th></tr></thead>
<tbody>
{events.map((e) => (
<tr key={e.audit_event_id}>
<td>{new Date(e.created_at).toLocaleString()}</td>
<td>{e.actor_type}{e.actor_id ? ` · ${e.actor_id.slice(0, 8)}` : ''}</td>
<td>{e.action}</td>
<td>{e.resource_type ? `${e.resource_type}${e.resource_id ? ` (${e.resource_id})` : ''}` : '—'}</td>
<td><span className={`${styles.pill} ${e.result === 'success' ? styles.pillOk : styles.pillErr}`}>{e.result}</span></td>
</tr>
))}
</tbody>
</table>
{!result ? <p className={styles.muted}>Loading…</p> : (
<>
<table>
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Resource</th><th>Result</th></tr></thead>
<tbody>
{events.map((e) => (
<tr key={e.audit_event_id}>
<td>{new Date(e.created_at).toLocaleString()}</td>
<td>{e.actor_type}{e.actor_id ? ` · ${e.actor_id.slice(0, 8)}` : ''}</td>
<td>{e.action}</td>
<td>{e.resource_type ? `${e.resource_type}${e.resource_id ? ` (${e.resource_id})` : ''}` : '—'}</td>
<td><span className={`${styles.pill} ${e.result === 'success' ? styles.pillOk : styles.pillErr}`}>{e.result}</span></td>
</tr>
))}
</tbody>
</table>
<div className={styles.pager}>
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Previous</button>
<span className={styles.muted}>Page {result.page} of {result.total_pages}</span>
<button disabled={page >= result.total_pages} onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
</>
)}
</div>
)
+4 -1
View File
@@ -49,7 +49,10 @@ function StudentDetail({ studentId, onBack }) {
const reload = useCallback(() => {
adminApi.studentProgress(studentId).then(setProgress).catch(() => {})
adminApi.studentAttempts(studentId).then(setAttempts).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])
+4 -3
View File
@@ -52,7 +52,7 @@ export const adminApi = {
createStudent: (display_name, cohort_id) => post('/api/admin/students', { display_name, cohort_id }),
updateStudent: (id, fields) => patch(`/api/admin/students/${id}`, fields),
studentProgress: (id) => get(`/api/admin/students/${id}/progress`),
studentAttempts: (id) => get(`/api/admin/students/${id}/attempts`),
studentAttempts: (id, page = 1) => get(`/api/admin/students/${id}/attempts?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
studentEnvironment: (id) => get(`/api/admin/students/${id}/environment`),
provisionStudentLab: (id) => post(`/api/admin/students/${id}/environment/provision`),
assignStudentCohort: (id, cohort_id) => post(`/api/admin/students/${id}/cohort`, { cohort_id }),
@@ -79,19 +79,20 @@ export const adminApi = {
revokeInstructorSessions: (id) => post(`/api/admin/instructors/${id}/revoke-sessions`),
resetInstructorPassword: (id, new_password) => post(`/api/admin/instructors/${id}/reset-password`, { new_password }),
auditEvents: () => get('/api/admin/audit-events'),
auditEvents: (page = 1) => get(`/api/admin/audit-events?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
// ── Phase 3B: learning analytics ────────────────────────────────────────────────────────
cohortLearning: (id) => get(`/api/admin/cohorts/${id}/learning`),
studentLearning: (id) => get(`/api/admin/students/${id}/learning`),
scenarioAnalytics: (cohortId) => get(`/api/admin/scenario-analytics${cohortId ? `?cohort_id=${cohortId}` : ''}`),
needsAttention: (cohortId) => get(`/api/admin/cohorts/${cohortId}/needs-attention`),
scenarioCatalog: () => get('/api/admin/scenario-catalog'), // Phase 4.1 §18 — structured assessment editor
// ── Phase 3B: assessment (exam + capstone) management ───────────────────────────────────
assessments: () => get('/api/admin/assessments'),
createAssessment: (fields) => post('/api/admin/assessments', fields),
updateAssessment: (id, fields) => patch(`/api/admin/assessments/${id}`, fields),
assignAssessment: (id, cohort_id) => post(`/api/admin/assessments/${id}/assign`, { cohort_id }),
assessmentSessions: (id) => get(`/api/admin/assessments/${id}/sessions`),
assessmentSessions: (id, page = 1) => get(`/api/admin/assessments/${id}/sessions?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
cancelAssessmentSession: (sessionId) => post(`/api/admin/assessment-sessions/${sessionId}/cancel`),
}