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:
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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`),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user