Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45a147a61f | ||
|
|
8956a95446 | ||
|
|
d4db8f99f5 | ||
|
|
7e5f357b6f | ||
|
|
4e3dacc043 | ||
|
|
0beb0e8f5e | ||
|
|
b908ade8e0 | ||
|
|
01a862e007 | ||
|
|
e793d9476b | ||
|
|
957a99645a | ||
|
|
60a1e79930 |
@@ -127,27 +127,35 @@ Everything runs inside one image managed by `scripts/entrypoint.sh`.
|
||||
|
||||
## Multi-Student Deployment (RootNode Academy addition)
|
||||
|
||||
The upstream project assumes one learner, one container — this image is still exactly that,
|
||||
unmodified except for a node-pty build fix (see `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §1).
|
||||
RootNode Academy multiplies it into a real platform via separate provisioning and gateway
|
||||
layers rather than changing the image itself:
|
||||
The upstream project assumes one learner, one container — this image is still exactly that.
|
||||
Its backend/entrypoint/Dockerfile are unmodified except for a node-pty build fix (see
|
||||
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §1); its frontend bundle gained progressive-hint
|
||||
controls and an assessment-mode indicator in Phase 3B (`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`
|
||||
§10–11) — additive UI, not a rewrite of the Lab experience. RootNode Academy multiplies the
|
||||
image into a real platform via separate provisioning and gateway layers rather than changing
|
||||
its core:
|
||||
|
||||
- `platform/provisioner/` — Postgres-backed environment registry, per-student Docker
|
||||
networking, firewall-enforced egress, idle reaper (`docs/PHASE1_PLATFORM_FOUNDATION.md`).
|
||||
- `platform/api/` — lightweight Platform API: students, cohorts, curriculum, progress,
|
||||
scenario attempts, persistent student/instructor sessions, per-instructor accounts (RBAC:
|
||||
owner/admin/instructor), audit log, instructor/admin control plane. No k3s, no `--privileged`
|
||||
(`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`, `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`).
|
||||
- `platform/api/` — lightweight Platform API: students, cohorts, curriculum, progress, detailed
|
||||
scenario attempt telemetry, progressive hints, assessment/capstone engine, achievements,
|
||||
persistent student/instructor sessions, per-instructor accounts (RBAC: owner/admin/
|
||||
instructor), audit log, instructor/admin control plane. No k3s, no `--privileged`
|
||||
(`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`, `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`,
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`).
|
||||
- `platform/gateway/` — the only student-facing path to a Lab: resolves identity from a
|
||||
server-controlled, persistent session cookie (not a client-suppliable header/param), proxies
|
||||
HTTP/WebSocket into that one student's container, never a raw container IP/port
|
||||
(`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, session model in
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §3).
|
||||
- The student flow (roster login → curriculum dashboard → Launch Lab → Lab session →
|
||||
Reset/Recreate) lives in `frontend/src/platform/`; the instructor/admin portal (per-instructor
|
||||
login, cohorts, students, instructor accounts, audit log, classroom "Prepare Labs,"
|
||||
environment controls) lives in `frontend/src/admin/` at the `/admin` route — both small
|
||||
additions alongside the existing, unmodified Lab UI, not a rewrite of it.
|
||||
HTTP/WebSocket into that one student's container, never a raw container IP/port, and
|
||||
server-gates progressive hint reveal by stripping the Lab's own hint payload before it reaches
|
||||
the browser (`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, session model in
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §3, hint gating in
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §3).
|
||||
- The student flow (roster login → curriculum dashboard → Launch Lab → practice/assessments/
|
||||
capstone → achievements) lives in `frontend/src/platform/`; the instructor/admin portal
|
||||
(per-instructor login, cohorts, students, instructor accounts, audit log, classroom
|
||||
"Prepare Labs," environment controls, learning analytics, assessment/capstone management)
|
||||
lives in `frontend/src/admin/` at the `/admin` route — both additions alongside the existing
|
||||
Lab UI, not a rewrite of it.
|
||||
|
||||
**Never publish the raw privileged container** — front it with isolation (that's what the
|
||||
Gateway is). The one-container `docker run --privileged -p 7554:80 ...` command above stays
|
||||
@@ -171,6 +179,12 @@ Key properties of the platform (not this image):
|
||||
recorded in an audit log.
|
||||
- Sessions (student and instructor) are Postgres-backed and survive a Platform API restart —
|
||||
verified by actually restarting it mid-session and confirming access still works.
|
||||
- Every validation attempt, hint reveal, curriculum stage completion, exam, and Capstone
|
||||
attempt is recorded in Postgres, not the disposable Lab — a Lab reset or recreate never loses
|
||||
attempt telemetry, hint progress, or an in-progress assessment's timer. Verified live:
|
||||
reset a Lab mid-assessment, timer and drawn scenario set were unchanged afterward.
|
||||
- Exams and the RootNode Capstone use a server-authoritative timer and a server-drawn,
|
||||
server-stored scenario set — nothing the browser sends can change either.
|
||||
- CPU/memory/PID/storage limits are enforced per container, config-driven.
|
||||
- Egress is restricted to an allowlisted proxy at the firewall layer, not left open.
|
||||
- Idle/abandoned environments are automatically cleaned up, and a student can launch a fresh
|
||||
@@ -178,7 +192,7 @@ Key properties of the platform (not this image):
|
||||
|
||||
Production topology and what's built vs. not yet: `docs/LAB_ARCHITECTURE.md`,
|
||||
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`.
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+59
-12
@@ -71,14 +71,18 @@ Two separate structures, don't confuse them:
|
||||
- **RootNode path** (`scenarios/rootnode-path.json`) — the guided onboarding sequence,
|
||||
separate from bundles on purpose (it's *staged*, bundles are *flat*, and the bundle loader
|
||||
would choke on a staged file if it were dropped in `scenarios/bundles/`). Loaded by
|
||||
`loadCurriculumPath()`/`GET /api/curriculum/path`. Stages are Foundation → Workloads →
|
||||
Networking → Configuration → Troubleshooting → Real Ops → Capstone, currently built by
|
||||
reusing existing `1-k8s-basics` and `rootnode-real-ops` scenarios grouped by their existing
|
||||
`category` field — no new scenario content was written for this. **Capstone stage is a
|
||||
locked placeholder** (`"locked": true`, empty `scenario_ids`) — the capstone engine itself
|
||||
is a separate, not-yet-built roadmap item (see §7, item 6). Do not put real scenarios in it
|
||||
until that engine exists, or you'll reproduce the "duplicate beginner scenarios as capstone"
|
||||
anti-pattern this path was built to avoid.
|
||||
`loadCurriculumPath()`/`GET /api/curriculum/path` (raw content) and, as of Phase 3B,
|
||||
`computeCurriculumState()`/`GET /api/curriculum/state` (rule-driven lock/complete/next-scenario
|
||||
state — see `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §4). Stages are Foundation → Workloads →
|
||||
Networking → Configuration → Troubleshooting → Real Ops → Capstone, built by reusing existing
|
||||
`1-k8s-basics` and `rootnode-real-ops` scenarios grouped by their existing `category` field —
|
||||
no new scenario content was written for this. Each stage carries a `completion_rule`
|
||||
(`all_required`/`threshold`/`prerequisites`). **Capstone is no longer a hardcoded placeholder**
|
||||
— it's `"type": "capstone"` with an `"unlock_rule"` and a `"capstone_assessment_id"` pointing
|
||||
at the `rootnode-capstone` assessment definition (Postgres, `assessment_definitions` table),
|
||||
which is where its actual scenario draw lives — see the Phase 3B doc §7. Don't put real
|
||||
scenarios directly into the JSON stage's `scenario_ids` for capstone; that field stays empty
|
||||
by design, the draw is defined in the assessment definition instead.
|
||||
|
||||
To add/reorder a stage: edit `rootnode-path.json` directly, referencing existing
|
||||
`scenarios/data/*.json` ids. No backend code change needed — it's read fresh via
|
||||
@@ -138,7 +142,9 @@ Order agreed 2026-08-17/18. Status as of this doc:
|
||||
1. RootNode identity/branding pass — **partial.** Shell banner fixed
|
||||
(`⎈ RootNode Academy Lab — node: k8s-lab`). Upstream KubeKosh attribution left untouched in
|
||||
footer/README/LICENSE. Broader "make Real Ops first-class" UI work not started.
|
||||
2. RootNode curriculum mode — **backend done** (§4), frontend not started.
|
||||
2. RootNode curriculum mode — **done**. Rule-driven stage completion/unlock as of Phase 3B
|
||||
(§12), student-facing tabs (My Learning/Practice/Assessments/Capstone/Achievements) at
|
||||
`frontend/src/platform/`.
|
||||
3. Student accounts / persistent progress — **done**, Postgres-authoritative as of Phase 2B
|
||||
(§9), sessions persistent as of Phase 3A (§10). Roster name-picking is still classroom/pilot
|
||||
identification, not strong student auth — documented, not silently glossed over
|
||||
@@ -156,15 +162,18 @@ Order agreed 2026-08-17/18. Status as of this doc:
|
||||
documented adapter, not a working integration (docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md
|
||||
§16).
|
||||
6. Scenarios → teaching modules (incident context/investigation/prevention) — not started.
|
||||
7. Capstone/exam engine — not started (placeholder stage only, see §4).
|
||||
8. Progressive hint levels — not started.
|
||||
7. Capstone/exam engine — **done** as of Phase 3B (§12). RootNode Capstone (Real Ops-weighted,
|
||||
unlockable by curriculum-completion rule, not a permanent placeholder) plus a general exam
|
||||
engine (definitions, cohort assignment, random scenario draw, server-authoritative timer).
|
||||
8. Progressive hint levels — **done** as of Phase 3B (§12). Server-enforced sequential reveal,
|
||||
tracked per attempt, policy-aware under assessment mode (free/disabled/flagged).
|
||||
9. Production observability — not started.
|
||||
10. CI scenario testing — not started.
|
||||
11. Container/build optimization — explicitly deprioritized by design (platform works, not urgent).
|
||||
|
||||
Next slice: production OIDC/SSO, the production two-VM Proxmox deployment, and teaching-module
|
||||
scenario content are the biggest remaining items. See
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §18 for the full BUILT NOW vs. FUTURE split.
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §21 for the full Phase 4 boundary.
|
||||
|
||||
## 8. Platform Provisioner (Phase 1)
|
||||
|
||||
@@ -233,3 +242,41 @@ Full design: `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`. Operational refer
|
||||
scoping, CSRF, rate limiting, password change, and audit logging.
|
||||
- **Known limitations, future OIDC path, production HTTPS expectations:** see
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §15–17.
|
||||
|
||||
## 11. Dependency lock convention
|
||||
|
||||
Every first-party Node service commits its `package-lock.json`: `backend/`, `frontend/`,
|
||||
`platform/api/`, `platform/gateway/`, `platform/provisioner/`. This wasn't consistently true
|
||||
before Phase 3B — `platform/api/`'s lockfile existed locally but was never committed, and
|
||||
`platform/gateway/`/`platform/provisioner/` never had one generated at all. Neither was a
|
||||
deliberate policy; both were oversights, fixed in Phase 3B (`chore(deps)` commits).
|
||||
|
||||
Going forward: after any `npm install` that changes a service's dependencies, the updated
|
||||
`package-lock.json` is part of the same commit as the `package.json` change — never left
|
||||
untracked, never regenerated-and-ignored. Validate a lockfile actually matches its
|
||||
`package.json` with `npm ci` (fails loudly on drift) before committing either.
|
||||
|
||||
## 12. Learning & Assessment Engine (Phase 3B)
|
||||
|
||||
Full design: `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`.
|
||||
|
||||
- **What it is:** the teaching/assessment layer on top of everything Phases 1–3A built —
|
||||
detailed per-attempt telemetry (`scenario_attempts`, evolved from Phase 2B's one-row-per-click
|
||||
shape into a real attempt lifecycle), server-enforced progressive hints, rule-driven
|
||||
curriculum stage/capstone unlock logic, a full exam + capstone assessment engine
|
||||
(`assessment_definitions`/`assessment_sessions`/`assessment_session_scenarios`), and
|
||||
persistent `achievements` records.
|
||||
- **Where the boundary moved:** the Gateway now rewrites one Lab response
|
||||
(`GET /lab/:token/api/scenarios/:scenarioId`, stripping `hints`) so progressive hints can be
|
||||
genuinely server-gated rather than just UI-collapsed — the Lab image's own code/bytes are
|
||||
still untouched; this is a response rewrite at the Gateway boundary, same pattern as every
|
||||
other Lab-mediation decision made in earlier phases. The Lab's own frontend bundle *did*
|
||||
change this phase (`ScenarioPanel.jsx`, new `ProgressiveHints`/`AssessmentBanner` components)
|
||||
— Phase 3B explicitly authorized enhancing Lab UX, unlike prior phases.
|
||||
- **Test procedure:** `platform/test/e2e-phase3b.js` — covers attempt telemetry, hint
|
||||
sequencing/persistence-through-Lab-reset, curriculum/capstone lock-unlock, the exam engine
|
||||
(random draw, timer, expiry, restart persistence), two-student isolation, and instructor
|
||||
analytics/RBAC.
|
||||
- **Known limitations:** see the Phase 3B doc §20 — no automatic abandoned-attempt detection, no
|
||||
per-cohort needs-attention threshold configuration, raw-JSON pool_spec authoring in the admin
|
||||
UI (correct, not yet a guided form).
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
Status: **Phase 1 (platform foundation) + Phase 2A (Gateway + student flow) + Phase 2B
|
||||
(Platform API split, Postgres academic data, server sessions, admin portal) + Phase 3A
|
||||
(persistent sessions, per-instructor accounts, RBAC, audit log) IMPLEMENTED, 2026-08-18** — see
|
||||
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
|
||||
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`, and `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`
|
||||
for concrete schema/API/test results, and `platform/README.md` for how to run the stack. This
|
||||
document remains the architecture record for the full ephemeral-lab direction; production
|
||||
OIDC/SSO (an adapter interface exists, not a working integration — see the Phase 3A doc §16)
|
||||
and production Proxmox deployment are still future work (Phase 3B+ — see the Phase 3A doc §18).
|
||||
Supersedes the namespace-isolation
|
||||
(persistent sessions, per-instructor accounts, RBAC, audit log) + Phase 3B (learning/assessment
|
||||
engine: attempt telemetry, progressive hints, curriculum rules, exam + capstone engine,
|
||||
achievements) IMPLEMENTED, 2026-08-18** — see `docs/PHASE1_PLATFORM_FOUNDATION.md`,
|
||||
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`,
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, and
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` for concrete schema/API/test results, and
|
||||
`platform/README.md` for how to run the stack. This document remains the architecture record
|
||||
for the full ephemeral-lab direction; production OIDC/SSO (an adapter interface exists, not a
|
||||
working integration) and production Proxmox deployment are still future work (Phase 4 — see the
|
||||
Phase 3B doc §21). Supersedes the namespace-isolation
|
||||
(`shared_namespace`/`ephemeral_cluster`) approach that was in flight before this redirect — see
|
||||
`scenarios/SCHEMA.md`'s `isolation_type` note for the pointer back here.
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
# Phase 3B — Learning & Assessment Engine
|
||||
|
||||
Turns the platform infrastructure built in Phases 1–3A (identity, sessions, RBAC, disposable
|
||||
Labs, Gateway routing, admin portal) into a real teaching and assessment system: detailed
|
||||
attempt telemetry, progressive hints, rule-driven curriculum progression, a proper exam engine,
|
||||
a RootNode Capstone, and persistent achievement records. Builds on
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` — read that first if you haven't.
|
||||
|
||||
## 1. What changed, in one paragraph
|
||||
|
||||
Phase 2B's `scenario_attempts` table was one row per validation click, written atomically at
|
||||
completion, with `hint_count` deliberately left unmeasured. Phase 3B evolves it into a real
|
||||
attempt lifecycle — one row per continuous working session on a scenario, with live counters
|
||||
(validation attempts/passes/fails, hint reveals, duration) updated in place as the student
|
||||
works. On top of that: a rule-driven curriculum-progression engine that actually locks/unlocks
|
||||
stages instead of a permanent capstone placeholder; a progressive hint system that's genuinely
|
||||
server-enforced (not just collapsed-by-default in the UI); a full exam/capstone assessment
|
||||
engine with server-authoritative timers and random scenario draws; and persistent achievement
|
||||
records for stage/curriculum/assessment/capstone completion.
|
||||
|
||||
## 2. Learning record model
|
||||
|
||||
`scenario_attempts` (migration `012`) gained: `cohort_id`, `curriculum_stage` (both snapshotted
|
||||
at attempt-start, so a later cohort/curriculum change never rewrites history), `status`
|
||||
(`in_progress`/`completed`/`abandoned`), `validation_attempt_count`, `validation_pass_count`,
|
||||
`validation_fail_count`, `highest_hint_level`, `duration_seconds`, `assessment_session_id`.
|
||||
`result` (Phase 2B) is now nullable — meaningful only once `status = 'completed'`.
|
||||
`hint_count` (Phase 2B, always `NULL`) finally gets a `DEFAULT 0` and real increments.
|
||||
|
||||
This is an **evolution, not a new table** — Phase 2B's historical rows are backfilled (one
|
||||
validation performed, matching their original one-row-per-click semantics), not dropped or
|
||||
reshaped. `platform/api/src/attempts.js` owns the lifecycle:
|
||||
|
||||
- `getOrCreateOpenAttempt` — finds the open (`in_progress`) attempt for
|
||||
`(student, scenario, environment)`, or starts one. Environment-scoped so a destroyed/recreated
|
||||
Lab never silently resumes an attempt tied to compute that no longer exists.
|
||||
- `recordValidation` — increments the attempt/pass/fail counters; closes the attempt
|
||||
(`status='completed'`, `duration_seconds` computed) only on a pass.
|
||||
- `recordHintReveal` — sequential only: level *N* can't be revealed before *N-1* has been.
|
||||
Returns a 409 once every hint is exhausted.
|
||||
|
||||
No telemetry is invented that the Lab doesn't actually emit — `duration_seconds` is measured
|
||||
server-side from `started_at` to completion (not client-reported), and hint reveals only exist
|
||||
because Phase 3B builds a real reveal endpoint (§3), not because the old static hint array was
|
||||
reinterpreted as "measured."
|
||||
|
||||
## 3. Validation telemetry & progressive hints
|
||||
|
||||
`POST /api/scenarios/:id/start` — fire-and-forget from the Lab UI when a scenario becomes
|
||||
active (both task and MCQ types); creates/reopens the attempt with a real `started_at`.
|
||||
|
||||
`POST /api/progress/record` (Gateway-relayed from the Lab's existing validate/answer calls,
|
||||
unchanged mechanism from Phase 2A/2B) now routes through `attempts.recordValidation` instead of
|
||||
a flat insert, and — on a pass — checks curriculum/capstone/assessment consequences (§4, §6).
|
||||
|
||||
`POST /api/scenarios/:id/hint` reveals hints **one at a time, server-enforced**. This required a
|
||||
real architectural change: Phase 2B's Lab backend (unmodified, per the "Lab image stays
|
||||
unmodified" convention through Phase 3A) returns the scenario's full `hints` array in
|
||||
`GET /api/scenarios/:id` — always did, since the Lab has no student/attempt concept at all. A
|
||||
client could already see hint 3 in the raw response even with the UI collapsed. Making the gate
|
||||
real meant the Gateway now **strips `hints` from that response** (replacing it with a bare
|
||||
`hint_count`) for exactly the multi-student, Platform-mediated deployment
|
||||
(`platform/gateway/src/server.js`, `GET /lab/:token/api/scenarios/:scenarioId`) — the Lab
|
||||
image's own bytes are still untouched; this is a response rewrite at the boundary, the same
|
||||
pattern already used for the `hints`-stripping shape as everything else the Gateway mediates.
|
||||
Standalone/single-container dev mode never goes through the Gateway, so it keeps the original
|
||||
fully-preloaded array unchanged — the frontend detects which mode it's in
|
||||
(`scenario.hints === undefined && typeof scenario.hint_count === 'number'`) and switches to
|
||||
`ProgressiveHints` (`frontend/src/components/ProgressiveHints.jsx`) only in the gated case.
|
||||
|
||||
Instructor-visible attempt summary matches the shape in the Phase 3B spec directly:
|
||||
|
||||
```
|
||||
Student: Alice Scenario: Probe Port Mismatch Attempts: 2
|
||||
Validation: Failed 2, Passed 1 Hints used: 1/3 Time: 14m 22s Final: Completed
|
||||
```
|
||||
|
||||
— all straight off `scenario_attempts` columns, no derived/estimated numbers, and explicitly
|
||||
**not** turned into a score. See §12 for where instructors see this.
|
||||
|
||||
## 4. Curriculum completion rules
|
||||
|
||||
`scenarios/rootnode-path.json` gained a `version` field and, per stage, a `completion_rule`:
|
||||
|
||||
```json
|
||||
{ "type": "all_required" } // every scenario_id in the stage must be completed (default)
|
||||
{ "type": "threshold", "percent": 80 } // at least N% completed
|
||||
{ "type": "prerequisites", "scenario_ids": [...] } // only these specific ids must be completed
|
||||
```
|
||||
|
||||
The capstone stage is no longer a hardcoded `"locked": true` placeholder — it carries
|
||||
`"type": "capstone"`, an `"unlock_rule"` (`{"type":"stages_complete","stage_ids":[...]}`), and a
|
||||
`"capstone_assessment_id"` pointing at the assessment definition that drives it (§6/§7).
|
||||
|
||||
`platform/api/src/curriculum-rules.js`'s `computeCurriculumState(path, progress, capstoneStatus)`
|
||||
is the rule engine — pure, synchronous, no DB access — answering: per-stage locked/complete/
|
||||
percent/next-scenario, the student's current stage, overall percent complete, and the capstone's
|
||||
unlock status (server route supplies `capstoneStatus` after checking for a
|
||||
`capstone_completed` achievement, since that's Postgres state this module doesn't touch).
|
||||
Exposed at `GET /api/curriculum/state` (new; `GET /api/curriculum/path` from Phase 2A/2B is
|
||||
unchanged and still used by the pre-existing dashboard summary).
|
||||
|
||||
Stage/curriculum completion also triggers achievement recording (§8) automatically, from inside
|
||||
`POST /api/progress/record` — no separate "check for completion" step the student or frontend
|
||||
has to remember to call.
|
||||
|
||||
## 5 & 6. Assessment model and exam engine
|
||||
|
||||
`PRACTICE` is just... practice: the existing curriculum flow, hints freely available (subject to
|
||||
the scenario actually having any), unrestricted retries, no timer, no session concept.
|
||||
|
||||
`ASSESSMENT` (exam or capstone — same underlying engine, §7 covers what makes a capstone
|
||||
different) is a **definition** (the reusable template: pool, duration, hint policy, completion
|
||||
rule) producing **sessions** (one per student attempt, immutable snapshot of what was drawn and
|
||||
under what rules). New tables (migration `013`):
|
||||
|
||||
```
|
||||
assessment_definitions slug PK, type ('exam'|'capstone'), title, duration_minutes,
|
||||
hint_policy ('free'|'disabled'|'flagged'), pool_spec JSONB,
|
||||
completion_rule JSONB, unlock_rule JSONB (capstone only),
|
||||
version INTEGER, status ('active'|'inactive')
|
||||
assessment_cohort_assignments which cohorts a definition is available to
|
||||
assessment_sessions one per student attempt — snapshots type/title/duration_minutes/
|
||||
hint_policy from the definition at draw time, plus
|
||||
definition_version, started_at, server-computed expires_at,
|
||||
submitted_at, random_seed, result JSONB
|
||||
assessment_session_scenarios the drawn set, ordered, with per-scenario status + a real FK
|
||||
back to the scenario_attempts row once worked
|
||||
```
|
||||
|
||||
`exam_sessions` (Phase 1/2B, historical-import-only from the old Lab-local SQLite exam
|
||||
bookkeeping) is **left untouched** — it was already scoped as dead historical data, never the
|
||||
live mechanism, so there was nothing to migrate into the tables above without inventing data
|
||||
that was never real. This is a deliberate non-duplication: the assessment tables above *are*
|
||||
the real thing Phase 2B's note said didn't exist yet.
|
||||
|
||||
`pool_spec` shape:
|
||||
|
||||
```json
|
||||
{ "required": ["scenario-id", ...], "random": [{ "count": 1, "from": ["a", "b", "c"] }, ...] }
|
||||
```
|
||||
|
||||
`platform/api/src/assessments.js`'s `drawScenarios` includes every `required` id, plus `count`
|
||||
random picks from each `random` group, shuffled together into presentation order. The draw uses
|
||||
a tiny deterministic PRNG (mulberry32, seeded from `crypto.randomBytes` — no new dependency)
|
||||
purely so the seed can be recorded for audit; a student never sees the seed or the undrawn pool
|
||||
before starting.
|
||||
|
||||
**One assessment at a time per student** — `startSession` refuses if the student already has a
|
||||
`not_started`/`in_progress` session for *any* definition. This keeps "which scenario is this
|
||||
validate call happening inside" unambiguous for hint-policy enforcement (§19) without needing
|
||||
the client to declare a mode.
|
||||
|
||||
**Timer**: `expires_at` is computed once, server-side, at `started_at + duration_minutes`, and
|
||||
never recomputed from anything the client sends. Expiry is checked **lazily** — whenever a
|
||||
session is read or written, if `now() > expires_at` and it's still `in_progress`, it flips to
|
||||
`expired` right then. No background cron (explicitly out of scope this phase — see the
|
||||
non-goals list). Verified live: a 1-minute test definition correctly transitioned to `expired`
|
||||
on the first request after the minute passed, and unblocked starting a new assessment.
|
||||
|
||||
## 7. RootNode Capstone
|
||||
|
||||
A capstone is an `assessment_definitions` row with `type='capstone'` — same engine as an exam,
|
||||
different framing and unlock gate. The shipped default (seeded in migration `013`, idempotent
|
||||
`INSERT ... ON CONFLICT DO NOTHING`) is `rootnode-capstone`:
|
||||
|
||||
- **Required** (always drawn): all four RootNode Real Ops scenarios
|
||||
(`rootnode-runaway-monitor`, `broken-deployment`, `rootnode-probe-port-mismatch`,
|
||||
`rootnode-enforce-limits`) — "feature prominently," per the spec, satisfied by making them
|
||||
non-optional rather than just likely.
|
||||
- **Random**: one Workloads task, one Configuration task, one Troubleshooting task — task-type
|
||||
scenarios preferred over MCQ, since the point is operational ability, not recall.
|
||||
- 90 minutes, `hint_policy: 'flagged'` (available, but every use is recorded — appropriate for
|
||||
a capstone that should mostly reflect independent work without being punitive about needing a
|
||||
nudge).
|
||||
- `unlock_rule`: every non-capstone curriculum stage complete (Foundation through Real Ops).
|
||||
|
||||
`GET /api/capstone` is a student-friendly view combining `computeCurriculumState`'s capstone
|
||||
block with the definition and the student's own session history —
|
||||
`locked` → `unlocked` → `completed`, with a prerequisite checklist while locked. Verified live,
|
||||
end to end: locked with all six blocking stage ids, unlocked to zero blocking ids the moment the
|
||||
last stage completed, a real draw (7 scenarios, all four Real Ops present), completion of all
|
||||
seven finalizing the session and recording a `capstone_completed` achievement.
|
||||
|
||||
## 8. Achievement / completion records
|
||||
|
||||
`achievements` (migration `014`): `student_id`, `achievement_type`
|
||||
(`stage_completed`/`curriculum_completed`/`assessment_completed`/`capstone_completed`),
|
||||
`curriculum_path_id`/`curriculum_version` (for stage/curriculum types),
|
||||
`assessment_session_id` (for assessment/capstone types), `achieved_at`, `metadata`.
|
||||
|
||||
`recordAchievement` is idempotent via a `WHERE NOT EXISTS` guard (not a unique-index/
|
||||
`ON CONFLICT` dance — simple and correct at this write volume) — calling it again for something
|
||||
already recorded is a safe no-op. Triggered automatically: `checkStageAndCurriculumAchievements`
|
||||
runs inside `POST /api/progress/record` after every pass; `recordAssessmentAchievement` runs
|
||||
after `assessments.recordScenarioOutcome`/`submitSession` finalizes a session, and only on an
|
||||
actual pass (defensive — today's only `completion_rule` is `all_scenarios_passed`, which already
|
||||
guarantees a `completed` session passed, but a future threshold-based rule shouldn't silently
|
||||
grant achievements for a partial result).
|
||||
|
||||
**Not a certificate.** No public/shareable artifact is generated — this is explicitly a Phase
|
||||
4/5 concern (see the non-goals list); a certificate can be rendered from these records later
|
||||
without needing new data.
|
||||
|
||||
## 9. Assessment/exam Lab behavior
|
||||
|
||||
An assessment doesn't provision a separate Lab — the student works through their drawn
|
||||
scenarios inside the **same** disposable environment their curriculum work already uses. This
|
||||
is what makes "Lab reset doesn't erase assessment state" nearly free: assessment state
|
||||
(`assessment_sessions`, `assessment_session_scenarios`, `scenario_attempts`) lives entirely in
|
||||
Platform Postgres; the Lab environment is purely compute. Resetting/recreating it changes the
|
||||
container, never the rows. Verified live: reset an environment mid-assessment,
|
||||
`expires_at`/`status` on the session were unchanged afterward.
|
||||
|
||||
**Assessment-mode detection is entirely server-derived** — `findActiveAssessmentContextForScenario`
|
||||
looks up whether the scenario a `validate`/`hint` call is happening against is currently in the
|
||||
student's own active session's drawn set. Nothing the client sends declares "I'm in assessment
|
||||
mode"; the server's own `assessment_session_scenarios` table is the only source of truth (see
|
||||
§19).
|
||||
|
||||
If Lab infrastructure fails mid-assessment, the assessment session itself is unaffected — an
|
||||
instructor can reset (or, if truly needed, destroy/reprovision) the student's Lab via the
|
||||
existing admin environment controls (Phase 2B) without touching assessment state at all; there
|
||||
is no code path that resets an assessment as a side effect of a Lab operation.
|
||||
|
||||
## 10 & 11. Student UI and Lab UX
|
||||
|
||||
`frontend/src/platform/StudentDashboard.jsx` is now tabbed: **My Learning** (the original
|
||||
Phase 2A/2B "Up Next" + Launch/Reset Lab flow, extracted unchanged into `MyLearningTab.jsx` and
|
||||
extended with an overall percent-complete bar from the new rule-driven curriculum state),
|
||||
**Practice** (scenario/stage status + recent attempt history), **Assessments** (available/
|
||||
active-with-live-countdown/history), **Capstone** (locked checklist → unlocked → start/resume →
|
||||
completed, with attempt history), **Achievements** (a flat, dated list — not a leaderboard).
|
||||
|
||||
Inside the Lab UI itself (`frontend/src/components/ScenarioPanel.jsx`, `App.jsx`):
|
||||
`ProgressiveHints` replaces the static hint array render when the Gateway has stripped hints
|
||||
(§3); an `AssessmentBanner` (new, top-level, polls `/api/assessments/active`) shows a live
|
||||
countdown and per-scenario completion count whenever an assessment is running — distinct from,
|
||||
and rendered alongside, the pre-existing Lab-local exam-mode UI (`ExamTimer`/`ExamSession` etc.,
|
||||
upstream KubeKosh feature, untouched). Both render nothing in standalone/single-container dev
|
||||
mode (no Platform API to poll there), so neither regresses that deployment path.
|
||||
|
||||
## 12. Instructor learning analytics
|
||||
|
||||
`/admin` → **Learning**: cohort summary (student count, 7-day active-learner count, average
|
||||
completion, per-stage completion counts), a **Needs Attention** list, and a per-student
|
||||
drill-down (curriculum state, full attempt history with validation/hint counts, assessment
|
||||
history). `/admin` → **Assessments**: create/edit definitions (admin/owner), assign to a cohort,
|
||||
view/cancel sessions — visible to every instructor role, cohort-scoped server-side for the plain
|
||||
`instructor` role the same way Phase 3A's other cohort-scoped routes are.
|
||||
|
||||
`GET /api/admin/scenario-analytics` (optional `?cohort_id=`): completions, distinct students,
|
||||
average validation attempts, total failures, total hints used, average completion time — one
|
||||
`GROUP BY scenario_id` query over `scenario_attempts`, nothing pre-aggregated or cached.
|
||||
|
||||
## 13. "Needs Attention"
|
||||
|
||||
`GET /api/admin/cohorts/:id/needs-attention` computes four signals per student, on read (no
|
||||
stored table, no background job): **Repeated Attempts** (an open attempt with ≥3 failed
|
||||
validations), **May Need Help** (an open attempt where a hint has been used but the scenario
|
||||
isn't done), **Needs Attention — inactive** (no activity in 7 days), and **Needs Attention —
|
||||
incomplete assessment** (their most recent assessment session expired without completing). All
|
||||
four thresholds are named constants in `admin-learning-routes.js`
|
||||
(`NEEDS_ATTENTION_FAIL_THRESHOLD`, `NEEDS_ATTENTION_STALE_DAYS`), not hardcoded inline. Wording
|
||||
is deliberately instructional (`Needs Attention`, `May Need Help`, `Repeated Attempts`) — an
|
||||
explicit test asserts none of the returned labels match punitive language.
|
||||
|
||||
## 14 & 15. Assessment/capstone management (admin)
|
||||
|
||||
A structured form (`frontend/src/admin/AssessmentsView.jsx`) — id, title, type, duration, hint
|
||||
policy, and a raw JSON `pool_spec` textarea. Not a drag-and-drop authoring tool, per the explicit
|
||||
scope line; the JSON textarea is honest about what's actually being edited rather than hiding it
|
||||
behind partial UI that can't express every pool shape. Editing a definition's
|
||||
duration/hint_policy/pool_spec/completion_rule bumps its `version` automatically
|
||||
(`admin-learning-routes.js` diffs the incoming fields against the current row) — cosmetic edits
|
||||
(title, description) don't. Create/edit is admin/owner only server-side; assign-to-cohort and
|
||||
session view/cancel are available to a scoped `instructor` too, matching the RBAC matrix (§18).
|
||||
|
||||
## 16. Versioning
|
||||
|
||||
Deliberately minimal, per the explicit "don't overbuild full CMS versioning" instruction:
|
||||
|
||||
- **Curriculum path**: a plain `"version": 1` integer in `rootnode-path.json` itself, recorded
|
||||
on `stage_completed`/`curriculum_completed` achievements as a snapshot.
|
||||
- **Assessment definitions**: a plain incrementing `version` integer, bumped only on a semantic
|
||||
change (duration/hint_policy/pool_spec/completion_rule — anything that would change what a
|
||||
session is graded against). `assessment_sessions.definition_version` snapshots which version
|
||||
was live at draw time, alongside a full redundant snapshot of the fields themselves
|
||||
(title/duration/hint_policy) — so a later definition edit, or even deletion, can never change
|
||||
what an already-started session is measured against.
|
||||
|
||||
No full version-history table exists for either — if that's ever needed, `version` is already a
|
||||
column to build on, not a retrofit.
|
||||
|
||||
## 17. RBAC matrix (Phase 3B additions)
|
||||
|
||||
| Action | owner | admin | instructor | student |
|
||||
|---|---|---|---|---|
|
||||
| Create/edit assessment definitions | ✅ | ✅ | ❌ | ❌ |
|
||||
| Assign an assessment to a cohort | ✅ | ✅ | ✅ (own cohorts) | ❌ |
|
||||
| View/cancel assessment sessions | ✅ (any) | ✅ (any) | ✅ (own cohorts) | ❌ |
|
||||
| View cohort learning summary / needs-attention / scenario analytics | ✅ (global) | ✅ (global) | ✅ (own cohorts) | ❌ |
|
||||
| View a student's learning detail | ✅ | ✅ | ✅ (if enrolled in an owned cohort) | own only |
|
||||
| Start/work an assessment, view own attempts/hints/achievements | ❌ | ❌ | ❌ | ✅ (own only) |
|
||||
|
||||
Enforced entirely server-side, reusing Phase 3A's `rbac.js` (`requireAdminOrOwner`,
|
||||
`requireAnyInstructor`, `requireCohortAccess`, `requireStudentCohortAccess`) — no new
|
||||
authorization primitives were needed, only new routes wired through the existing ones.
|
||||
|
||||
## 18. Security / integrity
|
||||
|
||||
- **Timer is server-authoritative** (§6) — `expires_at` fixed at draw time, never accepts a
|
||||
client value, checked lazily on every touch.
|
||||
- **Scenario draw is server-stored** — `assessment_session_scenarios` is the only source of
|
||||
truth for what a student is supposed to be working on; nothing the client sends can add,
|
||||
remove, or reorder it.
|
||||
- **A student cannot submit/read another student's session** — every assessment route filters
|
||||
by `student_id = req.studentId` server-side; a foreign session id returns 404, not 403 (same
|
||||
"don't confirm existence" posture as Phase 3A's RBAC denials) — verified by an explicit test.
|
||||
- **Gateway ownership** (Phase 2A/2B/3A) is unchanged and still enforced on every proxied Lab
|
||||
request, HTTP and WebSocket, including the new `/start` and `/hint` relays.
|
||||
- **Lab recreation never resets attempt/exam state** (§9) — verified live.
|
||||
- **Hidden solution/pool data**: undrawn pool alternatives are never returned to the student
|
||||
(only the actual draw); hints beyond the currently-unlocked level are never sent (§3, the
|
||||
actual mechanism, not just a UI convention).
|
||||
- **No invasive anti-cheat** — no browser surveillance, no lockdown mode. Explicitly out of
|
||||
scope, per the non-goals list.
|
||||
|
||||
## 19. Dev/test commands
|
||||
|
||||
```bash
|
||||
cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
(cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||
node bootstrap-owner.js [email protected] "Your Name" "a-real-password-12-chars-plus")
|
||||
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase2a.js
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase2b.js
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase3a.js
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase3b.js
|
||||
```
|
||||
|
||||
`e2e-phase3b.js` additionally shells out to `docker compose restart api` for its restart-
|
||||
persistence case (set `SKIP_RESTART_TEST=1` to skip that one case if running somewhere that
|
||||
isn't safe to restart).
|
||||
|
||||
## 20. Known limitations
|
||||
|
||||
- **No automatic "abandoned" detection** — an attempt a student never returns to stays
|
||||
`in_progress` forever; there's no reaper tying Lab destruction back to attempt state. A
|
||||
documented, deliberate scope line (would need wiring to Provisioner `environment_events`,
|
||||
more machinery than the instructional-visibility goal needs).
|
||||
- **`cohort_instructors.role_in_cohort`** (Phase 3A) still isn't consulted for finer-grained
|
||||
assessment permissions — any cohort assignment grants the full scoped-instructor action set.
|
||||
- **Needs-attention thresholds are fixed constants**, not per-cohort configurable yet.
|
||||
- **No pagination** on the audit log (Phase 3A limitation, unchanged) or on scenario analytics.
|
||||
- **Assessment pool_spec authoring is raw JSON** in the admin UI — correct and complete, but not
|
||||
a guided form; a validation-assist UI is a natural but unbuilt next step.
|
||||
- **One assessment at a time per student** — intentional (§6), but means a student can't have a
|
||||
capstone and an exam "in flight" simultaneously even if that were ever desired.
|
||||
|
||||
## 21. Phase 4 boundary
|
||||
|
||||
Everything in this document runs on a single dev machine against
|
||||
`platform/docker-compose.dev.yml`. **Not built, and explicitly out of scope this phase:**
|
||||
production Hetzner/Proxmox deployment, production OIDC provider integration, a parent portal,
|
||||
payments, public signup, automated email, equipment inventory, a multi-host scheduler,
|
||||
Grafana/Prometheus production dashboards, public certificates, a marketplace/self-paced commerce
|
||||
layer, the six held KubeKosh scenarios, invasive proctoring, and AI-driven grading. All of that
|
||||
stays exactly where the Phase 3A doc's non-goals list and this document's §21 leave it — Phase 4
|
||||
territory.
|
||||
@@ -8,6 +8,7 @@ import ExamTimer from './components/ExamTimer'
|
||||
import ExamReport from './components/ExamReport'
|
||||
import ExamStartModal from './components/ExamStartModal'
|
||||
import ExamHistory from './components/ExamHistory'
|
||||
import AssessmentBanner from './components/AssessmentBanner'
|
||||
import styles from './App.module.css'
|
||||
|
||||
const MIN_SIDEBAR_W = 180
|
||||
@@ -303,6 +304,7 @@ export default function App() {
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
<Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} />
|
||||
<AssessmentBanner />
|
||||
|
||||
{/* Bundle navigation bar */}
|
||||
<BundleNav
|
||||
|
||||
@@ -6,16 +6,22 @@ import StudentsView from './StudentsView'
|
||||
import InstructorsView from './InstructorsView'
|
||||
import AuditView from './AuditView'
|
||||
import AccountView from './AccountView'
|
||||
import LearningView from './LearningView'
|
||||
import AssessmentsView from './AssessmentsView'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
|
||||
// separate rn_instructor session cookie, separate login). A student session cannot reach any
|
||||
// of these views or their underlying /api/admin/* routes. Per-person accounts replace Phase
|
||||
// 2B's single shared ADMIN_PASSWORD — see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md.
|
||||
// Learning/Assessments (Phase 3B) are visible to every instructor role — cohort scoping for a
|
||||
// plain 'instructor' happens server-side per route, not by hiding the whole tab.
|
||||
const NAV = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'cohorts', label: 'Cohorts' },
|
||||
{ key: 'students', label: 'Students' },
|
||||
{ key: 'learning', label: 'Learning' },
|
||||
{ key: 'assessments', label: 'Assessments' },
|
||||
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
|
||||
{ key: 'audit', label: 'Audit', roles: ['owner', 'admin'] },
|
||||
]
|
||||
@@ -53,6 +59,8 @@ export default function AdminPortal() {
|
||||
{view === 'dashboard' && <AdminDashboard />}
|
||||
{view === 'cohorts' && <CohortsView role={instructor.role} />}
|
||||
{view === 'students' && <StudentsView role={instructor.role} />}
|
||||
{view === 'learning' && <LearningView />}
|
||||
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
|
||||
{view === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
|
||||
{view === 'audit' && ['owner', 'admin'].includes(instructor.role) && <AuditView />}
|
||||
{view === 'account' && <AccountView instructor={instructor} />}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Exam and Capstone definition/session management. Definition authoring (create/edit) is
|
||||
// admin/owner only server-side (RBAC) — a plain instructor can still assign an existing
|
||||
// definition to their own cohort and view/cancel sessions within it, so the form below is
|
||||
// always shown but the server will 403 a create/edit attempt from an instructor role; this
|
||||
// mirrors CohortsView's approach of not pre-emptively hiding every control, since assessments
|
||||
// are viewed by all roles and only definition-authoring is restricted.
|
||||
export default function AssessmentsView({ role }) {
|
||||
const [assessments, setAssessments] = useState(null)
|
||||
const [cohorts, setCohorts] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const canAuthor = role === 'owner' || role === 'admin'
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.assessments().then(setAssessments).catch((e) => setError(e.message))
|
||||
adminApi.cohorts().then(setCohorts).catch(() => {})
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <AssessmentDetail definition={selected} cohorts={cohorts} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{canAuthor && (
|
||||
<div className={styles.card}>
|
||||
<h2>Create assessment</h2>
|
||||
<CreateAssessmentForm onCreated={reload} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.card}>
|
||||
<h2>Exams & Capstones</h2>
|
||||
{!assessments ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Title</th><th>Type</th><th>Duration</th><th>Hints</th><th>Status</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{assessments.map((a) => (
|
||||
<tr key={a.assessment_definition_id}>
|
||||
<td>{a.title}</td>
|
||||
<td><span className={styles.pill}>{a.type}</span></td>
|
||||
<td>{a.duration_minutes} min</td>
|
||||
<td>{a.hint_policy}</td>
|
||||
<td><span className={`${styles.pill} ${a.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{a.status}</span></td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(a)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateAssessmentForm({ onCreated }) {
|
||||
const [form, setForm] = useState({ id: '', type: 'exam', title: '', duration_minutes: 60, hint_policy: 'disabled', pool_spec: '' })
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const pool_spec = JSON.parse(form.pool_spec || '{"required":[],"random":[]}')
|
||||
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: '' })
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message?.includes('JSON') ? 'pool_spec must be valid JSON' : e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>ID (slug)</label>
|
||||
<input className={styles.input} value={form.id} onChange={(e) => setForm({ ...form, id: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Title</label>
|
||||
<input className={styles.input} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Type</label>
|
||||
<select className={styles.select} value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
|
||||
<option value="exam">exam</option><option value="capstone">capstone</option>
|
||||
</select></div>
|
||||
<div><label className={styles.label}>Duration (min)</label>
|
||||
<input className={styles.input} type="number" value={form.duration_minutes} onChange={(e) => setForm({ ...form, duration_minutes: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Hint policy</label>
|
||||
<select className={styles.select} value={form.hint_policy} onChange={(e) => setForm({ ...form, hint_policy: e.target.value })}>
|
||||
<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 />
|
||||
<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)
|
||||
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])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const assign = async () => {
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try { await adminApi.assignAssessment(definition.assessment_definition_id, cohortId); setNotice('Assigned.') }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const cancelSession = async (sessionId) => {
|
||||
if (!window.confirm('Cancel this session?')) return
|
||||
try { await adminApi.cancelAssessmentSession(sessionId); reload() } catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to assessments</button>
|
||||
<h2 style={{ margin: '1rem 0' }}>{definition.title} <span className={styles.muted}>({definition.type})</span></h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Assign to cohort</h3>
|
||||
<div className={styles.formRow}>
|
||||
<div><select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
|
||||
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
|
||||
</select></div>
|
||||
<div><button className={styles.btn} disabled={busy || !cohortId} onClick={assign}>Assign</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Sessions</h3>
|
||||
{!sessions ? <p className={styles.muted}>Loading…</p> : sessions.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Status</th><th>Started</th><th>Result</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{sessions.map((s) => (
|
||||
<tr key={s.assessment_session_id}>
|
||||
<td>{s.display_name}</td>
|
||||
<td><span className={`${styles.pill} ${s.status === 'completed' ? styles.pillOk : s.status === 'expired' ? styles.pillErr : styles.pillWarn}`}>{s.status}</span></td>
|
||||
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : '—'}</td>
|
||||
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
|
||||
<td>{['not_started', 'in_progress'].includes(s.status) && <button className={styles.linkBtn} onClick={() => cancelSession(s.assessment_session_id)}>Cancel</button>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Instructor learning analytics — cohort summary, "needs attention," per-scenario analytics,
|
||||
// and a student drill-down. Deliberately no leaderboards or scoring language — this is
|
||||
// instructional visibility, not a ranking (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §13).
|
||||
export default function LearningView() {
|
||||
const [cohorts, setCohorts] = useState(null)
|
||||
const [cohortId, setCohortId] = useState(null)
|
||||
const [summary, setSummary] = useState(null)
|
||||
const [needsAttention, setNeedsAttention] = useState(null)
|
||||
const [scenarioStats, setScenarioStats] = useState(null)
|
||||
const [selectedStudent, setSelectedStudent] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => { adminApi.cohorts().then((c) => { setCohorts(c); if (c[0]) setCohortId(c[0].cohort_id) }).catch((e) => setError(e.message)) }, [])
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!cohortId) return
|
||||
adminApi.cohortLearning(cohortId).then(setSummary).catch((e) => setError(e.message))
|
||||
adminApi.needsAttention(cohortId).then(setNeedsAttention).catch((e) => setError(e.message))
|
||||
adminApi.scenarioAnalytics(cohortId).then(setScenarioStats).catch((e) => setError(e.message))
|
||||
}, [cohortId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selectedStudent) return <StudentLearningDetail studentId={selectedStudent} onBack={() => setSelectedStudent(null)} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.card}>
|
||||
<label className={styles.label}>Cohort</label>
|
||||
<select className={styles.select} value={cohortId || ''} onChange={(e) => setCohortId(e.target.value)}>
|
||||
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div className={styles.card}>
|
||||
<h2>Cohort Summary</h2>
|
||||
<div className={styles.statGrid}>
|
||||
<div className={styles.statCard}><div className={styles.statValue}>{summary.student_count}</div><div className={styles.statLabel}>Students</div></div>
|
||||
<div className={styles.statCard}><div className={styles.statValue}>{summary.active_learners}</div><div className={styles.statLabel}>Active (7d)</div></div>
|
||||
<div className={styles.statCard}><div className={styles.statValue}>{summary.overall_completion_pct}%</div><div className={styles.statLabel}>Avg. Completion</div></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Stage</th><th>Students Complete</th></tr></thead>
|
||||
<tbody>
|
||||
{summary.stages.map((s) => (
|
||||
<tr key={s.id}><td>{s.name}</td><td>{s.completedBy}/{summary.student_count} ({s.pct}%)</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h2>Needs Attention</h2>
|
||||
{!needsAttention ? <p className={styles.muted}>Loading…</p> : needsAttention.length === 0 ? <p className={styles.muted}>No signals right now.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Signal</th><th>Detail</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{needsAttention.map((s, i) => (
|
||||
<tr key={i}>
|
||||
<td>{s.display_name}</td>
|
||||
<td><span className={`${styles.pill} ${styles.pillWarn}`}>{s.label}</span></td>
|
||||
<td className={styles.muted}>{s.detail}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelectedStudent(s.student_id)}>View</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h2>Scenario Analytics</h2>
|
||||
{!scenarioStats ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Scenario</th><th>Completions</th><th>Avg Attempts</th><th>Failures</th><th>Hints Used</th><th>Avg Time</th></tr></thead>
|
||||
<tbody>
|
||||
{scenarioStats.map((s) => (
|
||||
<tr key={s.scenario_id}>
|
||||
<td>{s.scenario_id}</td><td>{s.completions}</td><td>{s.avg_attempts}</td>
|
||||
<td>{s.total_failures}</td><td>{s.total_hints_used}</td>
|
||||
<td>{s.avg_duration_seconds ? `${Math.round(s.avg_duration_seconds / 60)}m` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StudentLearningDetail({ studentId, onBack }) {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => { adminApi.studentLearning(studentId).then(setData).catch((e) => setError(e.message)) }, [studentId])
|
||||
|
||||
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||
if (!data) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back</button>
|
||||
<h2 style={{ margin: '1rem 0' }}>{studentId}</h2>
|
||||
|
||||
{data.curriculum && (
|
||||
<div className={styles.card}>
|
||||
<h3>Curriculum ({data.curriculum.percent_complete}%)</h3>
|
||||
{data.curriculum.stages.map((s) => (
|
||||
<div key={s.id} style={{ fontSize: '0.85rem', padding: '0.2rem 0' }}>
|
||||
{s.complete ? '✅' : s.locked ? '🔒' : '🟡'} {s.name} — {s.completed}/{s.total}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Attempts</h3>
|
||||
<table>
|
||||
<thead><tr><th>Scenario</th><th>Status</th><th>Validations</th><th>Hints (max level)</th><th>Duration</th></tr></thead>
|
||||
<tbody>
|
||||
{data.attempts.map((a, i) => (
|
||||
<tr key={i}>
|
||||
<td>{a.scenario_id}</td>
|
||||
<td><span className={`${styles.pill} ${a.status === 'completed' ? styles.pillOk : styles.pillWarn}`}>{a.status}</span></td>
|
||||
<td>{a.validation_pass_count} passed, {a.validation_fail_count} failed</td>
|
||||
<td>{a.hint_count} ({a.highest_hint_level})</td>
|
||||
<td>{a.duration_seconds ? `${Math.round(a.duration_seconds / 60)}m` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Assessment History</h3>
|
||||
{data.assessment_sessions.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Assessment</th><th>Status</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
{data.assessment_sessions.map((s) => (
|
||||
<tr key={s.assessment_session_id}>
|
||||
<td>{s.title}</td>
|
||||
<td><span className={`${styles.pill} ${s.status === 'completed' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
|
||||
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -80,4 +80,18 @@ export const adminApi = {
|
||||
resetInstructorPassword: (id, new_password) => post(`/api/admin/instructors/${id}/reset-password`, { new_password }),
|
||||
|
||||
auditEvents: () => get('/api/admin/audit-events'),
|
||||
|
||||
// ── 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`),
|
||||
|
||||
// ── 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`),
|
||||
cancelAssessmentSession: (sessionId) => post(`/api/admin/assessment-sessions/${sessionId}/cancel`),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// Phase 3B — shows when the student has an active Platform-level assessment (exam or capstone)
|
||||
// running, distinct from the pre-existing Lab-local exam mode (ExamTimer/ExamSession above,
|
||||
// which predates this and stays untouched). Polls /api/assessments/active, which 404s/errors
|
||||
// harmlessly in standalone/single-container dev mode (no Platform API there at all) — so this
|
||||
// component renders nothing in that mode, by design.
|
||||
export default function AssessmentBanner() {
|
||||
const [active, setActive] = useState(null)
|
||||
const [remaining, setRemaining] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
function poll() {
|
||||
fetch('/api/assessments/active').then((r) => r.json()).then((d) => {
|
||||
if (!cancelled) setActive(d.active ? d : null)
|
||||
}).catch(() => { if (!cancelled) setActive(null) })
|
||||
}
|
||||
poll()
|
||||
const id = setInterval(poll, 15000)
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const tick = () => setRemaining(Math.max(0, new Date(active.expires_at) - Date.now()))
|
||||
tick()
|
||||
const id = setInterval(tick, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [active])
|
||||
|
||||
if (!active) return null
|
||||
const m = Math.floor(remaining / 60000), s = Math.floor((remaining % 60000) / 1000)
|
||||
const completedCount = active.scenarios.filter((sc) => sc.status === 'completed').length
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '1rem', padding: '0.5rem 1rem',
|
||||
background: 'var(--amber-dim)', color: 'var(--amber)', fontSize: '0.85rem', fontFamily: 'var(--mono)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<span>🎯 Assessment Mode: {active.title}</span>
|
||||
<span>{completedCount}/{active.scenarios.length} scenarios complete</span>
|
||||
<span>⏱ {m}m {s}s remaining</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import styles from './ScenarioPanel.module.css'
|
||||
|
||||
// Phase 3B progressive hints — used when the Gateway has stripped the full `hints` array from
|
||||
// the scenario payload (the multi-student, Platform-mediated deployment; see
|
||||
// platform/gateway/src/server.js's GET /lab/:token/api/scenarios/:scenarioId). Hints are
|
||||
// fetched one at a time from the Platform API, server-enforced sequential (can't skip to hint
|
||||
// 3), and every reveal is recorded against the student's attempt — none of that is possible
|
||||
// with the old fully-preloaded array this replaces for this deployment mode.
|
||||
//
|
||||
// In standalone/single-container dev mode there's no Gateway rewrite, so `scenario.hints` is
|
||||
// still the full array and ScenarioPanel never renders this component at all — see the
|
||||
// `hintCount != null` check at the call site.
|
||||
export default function ProgressiveHints({ scenarioId, totalHints }) {
|
||||
const [revealed, setRevealed] = useState([]) // [{level, title, body, command}]
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [copiedIdx, setCopiedIdx] = useState(null)
|
||||
const [assessmentHintPolicy, setAssessmentHintPolicy] = useState(null) // null = not in an assessment; 'free'|'disabled'|'flagged'
|
||||
|
||||
// A scenario/environment change resets local reveal state — the server is still the source
|
||||
// of truth (GET /attempt would show the real highest_hint_level), this is just what's
|
||||
// rendered locally this session. Also checks whether this scenario is part of an active
|
||||
// Platform assessment, purely to show the policy indicator pre-emptively (the server enforces
|
||||
// the policy regardless, on every reveal call — this is UX, not the security boundary).
|
||||
useEffect(() => {
|
||||
setRevealed([]); setError(null); setAssessmentHintPolicy(null)
|
||||
fetch('/api/assessments/active').then((r) => r.json()).then((d) => {
|
||||
if (d.active && d.scenarios?.some((s) => s.scenario_id === scenarioId)) setAssessmentHintPolicy(d.hint_policy)
|
||||
}).catch(() => {})
|
||||
}, [scenarioId])
|
||||
|
||||
const revealNext = useCallback(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const r = await fetch(`api/scenarios/${scenarioId}/hint`, { method: 'POST' })
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setError(d.message || d.error || 'Could not reveal a hint.'); return }
|
||||
setRevealed((h) => [...h, { level: d.level, ...d.hint }])
|
||||
} catch {
|
||||
setError('Could not reach the platform to reveal a hint.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [scenarioId])
|
||||
|
||||
function copyCmd(cmd, idx) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(cmd).then(() => { setCopiedIdx(idx); setTimeout(() => setCopiedIdx(null), 1800) })
|
||||
}
|
||||
}
|
||||
|
||||
if (totalHints === 0) return <div className={styles.noHints}>No hints available for this scenario.</div>
|
||||
|
||||
if (assessmentHintPolicy === 'disabled' && revealed.length === 0) {
|
||||
return <div className={styles.noHints}>Hints Disabled — this assessment does not allow hints.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{assessmentHintPolicy === 'flagged' && <div className={styles.setupNote} style={{ marginBottom: '0.75rem' }}>Using a hint will be recorded.</div>}
|
||||
{revealed.map((hint, i) => (
|
||||
<div key={i} className={styles.hintCard}>
|
||||
<div className={styles.hintHeader}>
|
||||
<div className={styles.hintLeft}>
|
||||
<span className={styles.hintNum}>Hint {hint.level}</span>
|
||||
<span className={styles.hintTitle}>{hint.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hintBody}>
|
||||
<p className={styles.hintText}><ReactMarkdown remarkPlugins={[remarkGfm]}>{hint.body}</ReactMarkdown></p>
|
||||
{hint.command && (
|
||||
<div className={styles.cmdBlock}>
|
||||
<pre className={styles.cmdPre}>{hint.command}</pre>
|
||||
<button className={styles.copyBtn} onClick={() => copyCmd(hint.command, i)}>{copiedIdx === i ? '✓ Copied' : 'Copy'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{error && <div className={styles.validateError}>⚠ {error}</div>}
|
||||
{revealed.length < totalHints && (
|
||||
<button className={styles.setupBtn} disabled={loading} onClick={revealNext}>
|
||||
{loading ? 'Revealing…' : `Reveal Hint ${revealed.length + 1} of ${totalHints}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import styles from './ScenarioPanel.module.css'
|
||||
import { useConfirm } from './useConfirm'
|
||||
import ProgressiveHints from './ProgressiveHints'
|
||||
|
||||
// Inline markdown: renders without a wrapping <p> — safe for buttons/spans
|
||||
const inlineComponents = {
|
||||
@@ -48,6 +49,15 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
setHintsRevealed([])
|
||||
}, [scenario?.id])
|
||||
|
||||
// Phase 3B attempt telemetry — fire-and-forget "scenario started" so the Platform can record
|
||||
// a real started_at for this working session. Only meaningful under the Platform-multiplied
|
||||
// deployment (routed through the Gateway); 404s harmlessly in standalone/single-container dev
|
||||
// mode, where there's no Platform API to reach at all.
|
||||
useEffect(() => {
|
||||
if (!scenario?.id) return
|
||||
fetch(`api/scenarios/${scenario.id}/start`, { method: 'POST' }).catch(() => {})
|
||||
}, [scenario?.id])
|
||||
|
||||
const timeSpentRef = useRef(0)
|
||||
const lastScenarioIdRef = useRef(null)
|
||||
|
||||
@@ -341,7 +351,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t === 'problem' ? '📄 Problem'
|
||||
: t === 'hints' ? `💡 Hints (${scenario.hints?.length || 0})`
|
||||
: t === 'hints' ? `💡 Hints (${scenario.hints?.length ?? scenario.hint_count ?? 0})`
|
||||
: '✓ Validate'}
|
||||
</button>
|
||||
))}
|
||||
@@ -448,6 +458,15 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
{/* HINTS TAB */}
|
||||
{tab === 'hints' && (
|
||||
<div className={styles.tabPane} style={{ animation: 'fadeIn 0.2s ease' }}>
|
||||
{/* Phase 3B: the Gateway strips `hints` from the payload under the Platform-
|
||||
multiplied deployment (see platform/gateway/src/server.js) — hint_count survives
|
||||
as the signal to switch to server-gated progressive reveal instead of the old
|
||||
fully-preloaded array. Standalone/dev mode still has the full array (no Gateway
|
||||
rewrite happens there) and keeps the original behavior untouched below. */}
|
||||
{scenario.hints === undefined && typeof scenario.hint_count === 'number' ? (
|
||||
<ProgressiveHints scenarioId={scenario.id} totalHints={scenario.hint_count} />
|
||||
) : (
|
||||
<>
|
||||
{scenario.hints?.length === 0 && (
|
||||
<div className={styles.noHints}>No hints available for this scenario.</div>
|
||||
)}
|
||||
@@ -483,6 +502,8 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const LABELS = {
|
||||
stage_completed: 'Stage completed', curriculum_completed: 'Curriculum completed',
|
||||
assessment_completed: 'Assessment completed', capstone_completed: 'Capstone completed',
|
||||
}
|
||||
const ICONS = {
|
||||
stage_completed: '✅', curriculum_completed: '🎓', assessment_completed: '📝', capstone_completed: '🏁',
|
||||
}
|
||||
|
||||
// Not a public certificate — a durable internal record. Phase 4/5 can render a certificate from
|
||||
// these later (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §16); nothing here is shareable yet.
|
||||
export default function AchievementsTab() {
|
||||
const [achievements, setAchievements] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.achievements().then(setAchievements).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className={styles.card}>
|
||||
<h2>Achievements & History</h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{!achievements ? <p className={styles.muted}>Loading…</p> : achievements.length === 0 ? <p className={styles.muted}>Nothing yet — complete a stage to see it here.</p> : (
|
||||
achievements.map((a) => (
|
||||
<div key={a.achievement_id} style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', padding: '0.5rem 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<span style={{ fontSize: '1.2rem' }}>{ICONS[a.achievement_type] || '🏅'}</span>
|
||||
<div>
|
||||
<div>{LABELS[a.achievement_type] || a.achievement_type}{a.stage_id ? `: ${a.stage_id}` : ''}{a.metadata?.title ? `: ${a.metadata.title}` : ''}</div>
|
||||
<div className={styles.muted} style={{ fontSize: '0.78rem' }}>{new Date(a.achieved_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Available exams (assigned to the student's cohort), an active session if one is running (with
|
||||
// a live countdown), and completed history. Starting an exam draws its scenario set
|
||||
// server-side — the student works through it inside their existing Lab, the same one My
|
||||
// Learning launches (see docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §9 for why a separate Lab
|
||||
// per assessment isn't needed).
|
||||
export default function AssessmentsTab() {
|
||||
const [assessments, setAssessments] = useState(null)
|
||||
const [active, setActive] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
api.assessments().then(setAssessments).catch((e) => setError(e.message))
|
||||
api.activeAssessment().then(setActive).catch(() => {})
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const handleStart = async (definitionId) => {
|
||||
setBusy(true); setError(null)
|
||||
try { await api.startAssessment(definitionId); reload() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
{active?.active && (
|
||||
<section className={styles.card}>
|
||||
<h2>Active Assessment</h2>
|
||||
<p><strong>{active.title}</strong></p>
|
||||
<CountdownLine expiresAt={active.expires_at} />
|
||||
<table className={styles.dataTable} style={{ marginTop: '0.75rem' }}>
|
||||
<thead><tr><th>#</th><th>Scenario</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{active.scenarios.map((s) => (
|
||||
<tr key={s.scenario_id}>
|
||||
<td>{s.position + 1}</td><td>{s.scenario_id}</td>
|
||||
<td>
|
||||
{s.status === 'completed' ? <span className={`${styles.pill} ${styles.pillOk}`}>completed</span>
|
||||
: <span className={`${styles.pill} ${styles.pillMuted}`}>{s.status}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className={styles.hint} style={{ marginTop: '0.75rem' }}>Launch your Lab from My Learning and work through these scenarios there — this list stays in sync automatically.</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>Available Assessments</h2>
|
||||
{!assessments ? <p className={styles.muted}>Loading…</p> : assessments.length === 0 ? <p className={styles.muted}>None assigned to your cohort yet.</p> : (
|
||||
assessments.map((a) => {
|
||||
const completedSessions = a.sessions.filter((s) => s.status === 'completed')
|
||||
return (
|
||||
<div key={a.assessment_definition_id} style={{ padding: '0.75rem 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<p><strong>{a.title}</strong> <span className={styles.muted}>· {a.duration_minutes} min</span></p>
|
||||
{a.description && <p className={styles.muted} style={{ fontSize: '0.85rem' }}>{a.description}</p>}
|
||||
{completedSessions.length > 0 && (
|
||||
<p className={styles.hint}>Completed {completedSessions.length}× — best result: {completedSessions.some((s) => s.result?.passed) ? 'passed' : 'not yet passed'}</p>
|
||||
)}
|
||||
{!active?.active && (
|
||||
<button className={styles.secondaryBtn} disabled={busy} onClick={() => handleStart(a.assessment_definition_id)}>
|
||||
{busy ? 'Starting…' : 'Start'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>History</h2>
|
||||
{!assessments ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table className={styles.dataTable}>
|
||||
<thead><tr><th>Assessment</th><th>Started</th><th>Status</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
{assessments.flatMap((a) => a.sessions).filter((s) => s.status !== 'not_started').sort((a, b) => new Date(b.started_at) - new Date(a.started_at)).map((s) => (
|
||||
<tr key={s.assessment_session_id}>
|
||||
<td>{assessments.find((a) => a.sessions.includes(s))?.title}</td>
|
||||
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : '—'}</td>
|
||||
<td><StatusPill status={s.status} /></td>
|
||||
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusPill({ status }) {
|
||||
const cls = status === 'completed' ? styles.pillOk : status === 'expired' || status === 'cancelled' ? styles.pillErr : styles.pillWarn
|
||||
return <span className={`${styles.pill} ${cls}`}>{status}</span>
|
||||
}
|
||||
|
||||
function CountdownLine({ expiresAt }) {
|
||||
const [remaining, setRemaining] = useState(() => Math.max(0, new Date(expiresAt) - Date.now()))
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setRemaining(Math.max(0, new Date(expiresAt) - Date.now())), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [expiresAt])
|
||||
const m = Math.floor(remaining / 60000), s = Math.floor((remaining % 60000) / 1000)
|
||||
return <p className={styles.statusLine}><span className={styles.spinner} /> Time remaining: {m}m {s}s</p>
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const STAGE_NAMES = {
|
||||
foundation: 'Foundation', workloads: 'Workloads', networking: 'Networking',
|
||||
configuration: 'Configuration', troubleshooting: 'Troubleshooting', 'real-ops': 'RootNode: Real Ops',
|
||||
}
|
||||
|
||||
export default function CapstoneTab() {
|
||||
const [capstone, setCapstone] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
api.capstone().then(setCapstone).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const handleStart = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try { await api.startAssessment(capstone.assessment_definition_id); reload() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||
if (!capstone) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
if (capstone.status === 'locked') {
|
||||
return (
|
||||
<section className={styles.card}>
|
||||
<h2>Capstone</h2>
|
||||
<div className={styles.lockedBox}>
|
||||
<p style={{ fontSize: '2rem' }}>🔒</p>
|
||||
<p>Complete the rest of the curriculum to unlock the RootNode Capstone.</p>
|
||||
</div>
|
||||
<h3 style={{ marginTop: '1rem', fontSize: '0.9rem' }}>Prerequisites</h3>
|
||||
{capstone.blocking_stage_ids.map((id) => (
|
||||
<div key={id} className={styles.checklistItem}><span>⬜</span> {STAGE_NAMES[id] || id}</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const activeSession = capstone.sessions.find((s) => s.status === 'in_progress');
|
||||
const completedSession = capstone.sessions.find((s) => s.status === 'completed' && s.result?.passed);
|
||||
|
||||
return (
|
||||
<section className={styles.card}>
|
||||
<h2>Capstone {capstone.status === 'completed' ? <span className={`${styles.pill} ${styles.pillOk}`} style={{ marginLeft: '0.5rem' }}>completed</span> : <span className={`${styles.pill} ${styles.pillOk}`} style={{ marginLeft: '0.5rem' }}>unlocked</span>}</h2>
|
||||
{capstone.definition && (
|
||||
<>
|
||||
<p><strong>{capstone.definition.title}</strong> <span className={styles.muted}>· {capstone.definition.duration_minutes} min</span></p>
|
||||
<p className={styles.muted} style={{ fontSize: '0.85rem' }}>{capstone.definition.description}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{completedSession ? (
|
||||
<p className={styles.hint} style={{ marginTop: '1rem' }}>
|
||||
Completed {new Date(completedSession.submitted_at).toLocaleString()} — {completedSession.result.scenarios_passed}/{completedSession.result.scenarios_total} scenarios passed.
|
||||
</p>
|
||||
) : activeSession ? (
|
||||
<p className={styles.hint} style={{ marginTop: '1rem' }}>Your Capstone is in progress — launch your Lab from My Learning to continue.</p>
|
||||
) : (
|
||||
<button className={styles.primaryBtn} style={{ marginTop: '1rem' }} disabled={busy} onClick={handleStart}>
|
||||
{busy ? 'Starting…' : 'Start Capstone'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{capstone.sessions.length > 0 && (
|
||||
<>
|
||||
<h3 style={{ marginTop: '1.5rem', fontSize: '0.9rem' }}>Attempt History</h3>
|
||||
<table className={styles.dataTable}>
|
||||
<thead><tr><th>Started</th><th>Status</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
{capstone.sessions.map((s) => (
|
||||
<tr key={s.assessment_session_id}>
|
||||
<td>{s.started_at ? new Date(s.started_at).toLocaleString() : '—'}</td>
|
||||
<td>{s.status}</td>
|
||||
<td>{s.result ? `${s.result.scenarios_passed}/${s.result.scenarios_total}` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const POLL_MS = 2500
|
||||
|
||||
// The original Phase 2A/2B "Up Next" + Launch Lab / Reset flow, now extended with overall
|
||||
// percent-complete (Phase 3B's rule-driven curriculum state, GET /api/curriculum/state, instead
|
||||
// of the older /api/curriculum/path stats-only shape — same stage list, richer per-stage data).
|
||||
export default function MyLearningTab() {
|
||||
const [curriculum, setCurriculum] = useState(null)
|
||||
const [envStatus, setEnvStatus] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const pollRef = useRef(null)
|
||||
|
||||
const loadCurriculum = useCallback(() => {
|
||||
api.curriculumState().then(setCurriculum).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
const loadEnvStatus = useCallback(() => {
|
||||
api.environmentStatus().then(setEnvStatus).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadCurriculum(); loadEnvStatus() }, [loadCurriculum, loadEnvStatus])
|
||||
|
||||
useEffect(() => {
|
||||
const inFlight = envStatus && ['requested', 'provisioning', 'resetting'].includes(envStatus.status)
|
||||
if (inFlight && !pollRef.current) {
|
||||
pollRef.current = setInterval(loadEnvStatus, POLL_MS)
|
||||
} else if (!inFlight && pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } }
|
||||
}, [envStatus, loadEnvStatus])
|
||||
|
||||
const handleLaunch = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const result = await api.launchEnvironment()
|
||||
setEnvStatus(result)
|
||||
if (result.status === 'running' && result.launch_path) window.location.href = result.launch_path
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
const handleReset = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try { setEnvStatus(await api.resetEnvironment()) } catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const currentStage = curriculum?.stages?.find((s) => s.id === curriculum.current_stage)
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>Current Stage</h2>
|
||||
{!curriculum ? <p className={styles.muted}>Loading curriculum…</p> : (
|
||||
<>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.3rem' }}>
|
||||
<span>Overall progress</span><span className={styles.muted}>{curriculum.percent_complete}%</span>
|
||||
</div>
|
||||
<div className={styles.progressBarTrack}><div className={styles.progressBarFill} style={{ width: `${curriculum.percent_complete}%` }} /></div>
|
||||
</div>
|
||||
<div className={styles.stageRow}>
|
||||
{curriculum.stages.map((s) => (
|
||||
<div key={s.id} className={`${styles.stagePill} ${s.id === curriculum.current_stage ? styles.stagePillActive : ''} ${s.locked ? styles.stagePillLocked : ''}`}>
|
||||
<span>{s.icon}</span> {s.name}
|
||||
<span className={styles.stageCount}>{s.completed}/{s.total}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{currentStage ? (
|
||||
<p className={styles.nextUp}>
|
||||
Up next: <strong>{currentStage.name}</strong>
|
||||
{currentStage.next_scenario && <> — {currentStage.next_scenario}</>}
|
||||
</p>
|
||||
) : (
|
||||
<p className={styles.nextUp}>All curriculum stages complete — your Capstone is waiting.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>Your Lab</h2>
|
||||
<EnvironmentPanel status={envStatus} busy={busy} onLaunch={handleLaunch} onReset={handleReset} />
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvironmentPanel({ status, busy, onLaunch, onReset }) {
|
||||
const s = status?.status || 'none'
|
||||
|
||||
if (s === 'none' || s === 'destroyed') {
|
||||
return (
|
||||
<>
|
||||
<p className={styles.muted}>No lab running yet.</p>
|
||||
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
|
||||
{busy ? 'Launching…' : 'Launch Lab'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (['requested', 'provisioning', 'resetting'].includes(s)) {
|
||||
return <p className={styles.statusLine}><span className={styles.spinner} /> {s === 'resetting' ? 'Recreating your lab…' : 'Starting your lab…'}</p>
|
||||
}
|
||||
if (s === 'running') {
|
||||
return (
|
||||
<>
|
||||
<p className={styles.statusLine}><span className={styles.dotOk} /> Lab ready.</p>
|
||||
<div className={styles.btnRow}>
|
||||
<a className={styles.primaryBtn} href={`${status.launch_path}`}>Continue Lab</a>
|
||||
<button className={styles.secondaryBtn} disabled={busy} onClick={onReset}>
|
||||
{busy ? 'Working…' : 'Recreate Lab'}
|
||||
</button>
|
||||
</div>
|
||||
<p className={styles.hint}>Recreating gives you a clean environment. Your progress is saved separately and is never lost.</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (s === 'error') {
|
||||
return (
|
||||
<>
|
||||
<p className={styles.statusLine}><span className={styles.dotErr} /> {status.error_reason || 'Your lab hit a problem.'}</p>
|
||||
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
|
||||
{busy ? 'Launching…' : 'Try Again'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <p className={styles.muted}>Unknown state ({s}).</p>
|
||||
}
|
||||
@@ -159,3 +159,25 @@
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Learning tabs (Phase 3B) ─────────────────────────────────────────── */
|
||||
|
||||
.tabNav { display: flex; gap: 1.25rem; padding: 0 2rem; border-bottom: 1px solid var(--border); }
|
||||
.tab { color: var(--text-2); cursor: pointer; font-size: 0.9rem; background: none; border: none; padding: 0.75rem 0; }
|
||||
.tabActive { color: var(--green); border-bottom: 2px solid var(--green); }
|
||||
|
||||
.progressBarTrack { background: var(--surface3); border-radius: 4px; height: 8px; overflow: hidden; }
|
||||
.progressBarFill { background: var(--green); height: 8px; }
|
||||
|
||||
table.dataTable { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
.dataTable th { text-align: left; color: var(--text-2); font-weight: 600; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border2); font-size: 0.72rem; text-transform: uppercase; }
|
||||
.dataTable td { padding: 0.5rem 0.5rem; border-bottom: 1px solid var(--border); }
|
||||
|
||||
.pill { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px; font-size: 0.75rem; font-family: var(--mono); }
|
||||
.pillOk { background: var(--green-dim); color: var(--green); }
|
||||
.pillWarn { background: var(--amber-dim); color: var(--amber); }
|
||||
.pillErr { background: var(--red-dim); color: var(--red); }
|
||||
.pillMuted { background: var(--surface3); color: var(--text-2); }
|
||||
|
||||
.lockedBox { text-align: center; padding: 2rem 1rem; color: var(--text-2); }
|
||||
.checklistItem { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0; font-size: 0.88rem; }
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
// Available/completed scenarios (from curriculum state) + recent attempt history
|
||||
// (GET /api/attempts/recent) — purely informational, no controls here; the actual work happens
|
||||
// inside the Lab itself once launched from My Learning.
|
||||
export default function PracticeTab() {
|
||||
const [curriculum, setCurriculum] = useState(null)
|
||||
const [attempts, setAttempts] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.curriculumState().then(setCurriculum).catch((e) => setError(e.message))
|
||||
api.recentAttempts().then(setAttempts).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
const allScenarios = curriculum?.stages.flatMap((s) => Array(s.total).fill(null).map((_, i) => i)) || []
|
||||
const completedCount = curriculum?.stages.reduce((n, s) => n + s.completed, 0) || 0
|
||||
const totalCount = allScenarios.length
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<section className={styles.card}>
|
||||
<h2>Scenarios</h2>
|
||||
{!curriculum ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table className={styles.dataTable}>
|
||||
<thead><tr><th>Stage</th><th>Completed</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{curriculum.stages.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.icon} {s.name}</td>
|
||||
<td>{s.completed}/{s.total}</td>
|
||||
<td>
|
||||
{s.locked ? <span className={`${styles.pill} ${styles.pillMuted}`}>locked</span>
|
||||
: s.complete ? <span className={`${styles.pill} ${styles.pillOk}`}>complete</span>
|
||||
: <span className={`${styles.pill} ${styles.pillWarn}`}>in progress</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{curriculum && <p className={styles.hint} style={{ marginTop: '0.75rem' }}>{completedCount}/{totalCount} scenarios completed overall.</p>}
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>Recent Attempts</h2>
|
||||
{!attempts ? <p className={styles.muted}>Loading…</p> : attempts.length === 0 ? <p className={styles.muted}>No attempts yet — launch your Lab and pick a scenario.</p> : (
|
||||
<table className={styles.dataTable}>
|
||||
<thead><tr><th>Scenario</th><th>Status</th><th>Validations</th><th>Hints</th><th>Duration</th></tr></thead>
|
||||
<tbody>
|
||||
{attempts.map((a, i) => (
|
||||
<tr key={i}>
|
||||
<td>{a.scenario_id}</td>
|
||||
<td>
|
||||
{a.status === 'completed' ? <span className={`${styles.pill} ${styles.pillOk}`}>completed</span>
|
||||
: <span className={`${styles.pill} ${styles.pillWarn}`}>in progress</span>}
|
||||
</td>
|
||||
<td>{a.validation_pass_count} passed, {a.validation_fail_count} failed</td>
|
||||
<td>{a.hint_count || 0}</td>
|
||||
<td>{a.duration_seconds ? formatDuration(a.duration_seconds) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const m = Math.floor(seconds / 60), s = seconds % 60
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
@@ -1,78 +1,27 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
import MyLearningTab from './MyLearningTab'
|
||||
import PracticeTab from './PracticeTab'
|
||||
import AssessmentsTab from './AssessmentsTab'
|
||||
import CapstoneTab from './CapstoneTab'
|
||||
import AchievementsTab from './AchievementsTab'
|
||||
|
||||
const POLL_MS = 2500
|
||||
const TABS = [
|
||||
{ key: 'learning', label: 'My Learning' },
|
||||
{ key: 'practice', label: 'Practice' },
|
||||
{ key: 'assessments', label: 'Assessments' },
|
||||
{ key: 'capstone', label: 'Capstone' },
|
||||
{ key: 'achievements', label: 'Achievements' },
|
||||
]
|
||||
|
||||
// C/D/F. Student home ("Up Next") + Launch Lab / Reset flow.
|
||||
// Uses the existing GET /api/curriculum/path — the RootNode path (Foundation -> Workloads ->
|
||||
// Networking -> Configuration -> Troubleshooting -> Real Ops -> Capstone) is not reinvented
|
||||
// here, just rendered.
|
||||
// Student home — tabbed as of Phase 3B (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §10): My
|
||||
// Learning (the original Phase 2A/2B "Up Next" + Lab launch flow, unchanged in behavior, just
|
||||
// extracted into its own component), Practice, Assessments, Capstone, Achievements/History.
|
||||
export default function StudentDashboard({ student, onLogout }) {
|
||||
const [curriculum, setCurriculum] = useState(null)
|
||||
const [envStatus, setEnvStatus] = useState(null) // { status, launch_path?, error_reason? }
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const pollRef = useRef(null)
|
||||
|
||||
const loadCurriculum = useCallback(() => {
|
||||
api.curriculumPath().then(setCurriculum).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
const loadEnvStatus = useCallback(() => {
|
||||
api.environmentStatus().then(setEnvStatus).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadCurriculum(); loadEnvStatus() }, [loadCurriculum, loadEnvStatus])
|
||||
|
||||
// Poll while an environment is in-flight (provisioning/resetting) so the UI transitions to
|
||||
// ready/error on its own — matches requirement D.4: provisioning -> starting -> ready -> error.
|
||||
useEffect(() => {
|
||||
const inFlight = envStatus && ['requested', 'provisioning', 'resetting'].includes(envStatus.status)
|
||||
if (inFlight && !pollRef.current) {
|
||||
pollRef.current = setInterval(loadEnvStatus, POLL_MS)
|
||||
} else if (!inFlight && pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } }
|
||||
}, [envStatus, loadEnvStatus])
|
||||
|
||||
const handleLaunch = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const result = await api.launchEnvironment()
|
||||
setEnvStatus(result)
|
||||
if (result.status === 'running' && result.launch_path) {
|
||||
// No ?sid= needed anymore (Phase 2B) — the Gateway verifies ownership from the
|
||||
// rn_session cookie itself, which travels with this navigation automatically. See
|
||||
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3.
|
||||
window.location.href = result.launch_path
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const result = await api.resetEnvironment()
|
||||
setEnvStatus(result)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const [tab, setTab] = useState('learning')
|
||||
const handleLogout = () => { api.logout().then(onLogout) }
|
||||
|
||||
const nextStage = curriculum?.stages?.find((s) => s.id === curriculum.next_stage)
|
||||
const nextScenario = nextStage?.scenarios?.find((sc) => sc.status !== 'completed')
|
||||
|
||||
return (
|
||||
<div className={styles.dashWrap}>
|
||||
<header className={styles.dashHeader}>
|
||||
@@ -83,78 +32,19 @@ export default function StudentDashboard({ student, onLogout }) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<nav className={styles.tabNav}>
|
||||
{TABS.map((t) => (
|
||||
<button key={t.key} className={`${styles.tab} ${tab === t.key ? styles.tabActive : ''}`} onClick={() => setTab(t.key)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>Up Next</h2>
|
||||
{!curriculum ? <p className={styles.muted}>Loading curriculum…</p> : (
|
||||
<>
|
||||
<div className={styles.stageRow}>
|
||||
{curriculum.stages.map((s) => (
|
||||
<div key={s.id} className={`${styles.stagePill} ${s.id === curriculum.next_stage ? styles.stagePillActive : ''} ${s.locked ? styles.stagePillLocked : ''}`}>
|
||||
<span>{s.icon}</span> {s.name}
|
||||
<span className={styles.stageCount}>{s.stats.completed}/{s.stats.total}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{nextStage && (
|
||||
<p className={styles.nextUp}>
|
||||
Next: <strong>{nextStage.name}</strong>
|
||||
{nextScenario && <> — {nextScenario.title}</>}
|
||||
</p>
|
||||
)}
|
||||
{!nextStage && <p className={styles.nextUp}>All unlocked stages complete — nice work.</p>}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2>Your Lab</h2>
|
||||
<EnvironmentPanel status={envStatus} busy={busy} onLaunch={handleLaunch} onReset={handleReset} />
|
||||
</section>
|
||||
{tab === 'learning' && <MyLearningTab />}
|
||||
{tab === 'practice' && <PracticeTab />}
|
||||
{tab === 'assessments' && <AssessmentsTab />}
|
||||
{tab === 'capstone' && <CapstoneTab />}
|
||||
{tab === 'achievements' && <AchievementsTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvironmentPanel({ status, busy, onLaunch, onReset }) {
|
||||
const s = status?.status || 'none'
|
||||
|
||||
if (s === 'none' || s === 'destroyed') {
|
||||
return (
|
||||
<>
|
||||
<p className={styles.muted}>No lab running yet.</p>
|
||||
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
|
||||
{busy ? 'Launching…' : 'Launch Lab'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (['requested', 'provisioning', 'resetting'].includes(s)) {
|
||||
return <p className={styles.statusLine}><span className={styles.spinner} /> {s === 'resetting' ? 'Recreating your lab…' : 'Starting your lab…'}</p>
|
||||
}
|
||||
if (s === 'running') {
|
||||
return (
|
||||
<>
|
||||
<p className={styles.statusLine}><span className={styles.dotOk} /> Lab ready.</p>
|
||||
<div className={styles.btnRow}>
|
||||
<a className={styles.primaryBtn} href={`${status.launch_path}`}>Continue Lab</a>
|
||||
<button className={styles.secondaryBtn} disabled={busy} onClick={onReset}>
|
||||
{busy ? 'Working…' : 'Recreate Lab'}
|
||||
</button>
|
||||
</div>
|
||||
<p className={styles.hint}>Recreating gives you a clean environment. Your progress is saved separately and is never lost.</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (s === 'error') {
|
||||
return (
|
||||
<>
|
||||
<p className={styles.statusLine}><span className={styles.dotErr} /> {status.error_reason || 'Your lab hit a problem.'}</p>
|
||||
<button className={styles.primaryBtn} disabled={busy} onClick={onLaunch}>
|
||||
{busy ? 'Launching…' : 'Try Again'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <p className={styles.muted}>Unknown state ({s}).</p>
|
||||
}
|
||||
|
||||
@@ -66,8 +66,21 @@ export const api = {
|
||||
whoami: () => fetch('/api/session/whoami').then(json),
|
||||
|
||||
curriculumPath: () => fetch('/api/curriculum/path').then(json),
|
||||
curriculumState: () => fetch('/api/curriculum/state').then(json),
|
||||
|
||||
environmentStatus: () => fetch('/api/environment/status').then(json),
|
||||
launchEnvironment: () => mutate('/api/environment/launch'),
|
||||
resetEnvironment: () => mutate('/api/environment/reset'),
|
||||
|
||||
recentAttempts: () => fetch('/api/attempts/recent').then(json),
|
||||
|
||||
assessments: () => fetch('/api/assessments').then(json),
|
||||
activeAssessment: () => fetch('/api/assessments/active').then(json),
|
||||
assessmentDetail: (sessionId) => fetch(`/api/assessments/${sessionId}`).then(json),
|
||||
startAssessment: (definitionId) => mutate(`/api/assessments/${definitionId}/start`),
|
||||
submitAssessment: (sessionId) => mutate(`/api/assessments/${sessionId}/submit`),
|
||||
|
||||
capstone: () => fetch('/api/capstone').then(json),
|
||||
|
||||
achievements: () => fetch('/api/achievements').then(json),
|
||||
}
|
||||
|
||||
+29
-18
@@ -1,21 +1,27 @@
|
||||
# RootNode Academy — Platform
|
||||
|
||||
The full multi-student platform, layered on top of the unmodified Lab image at the repo root.
|
||||
Start here, then go to the component you need:
|
||||
The full multi-student platform, layered on top of the Lab image at the repo root (backend/
|
||||
entrypoint/Dockerfile unmodified; frontend bundle gained Phase 3B learning UX, see
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §10–11). Start here, then go to the component you need:
|
||||
|
||||
- `provisioner/` — Postgres-backed environment registry and lifecycle. Design:
|
||||
`docs/LAB_ARCHITECTURE.md`, `docs/PHASE1_PLATFORM_FOUNDATION.md`. Operational reference:
|
||||
`provisioner/README.md`.
|
||||
- `api/` — the lightweight Platform API: students, cohorts, enrollments, curriculum, progress,
|
||||
scenario attempts, per-instructor accounts, persistent student/instructor sessions, RBAC,
|
||||
audit logging, and the instructor/admin control plane. No k3s, no `--privileged` — this
|
||||
replaced Phase 2A's "identity" instance, which was a full Lab image booting an unused k3s
|
||||
cluster just to serve these APIs. Design: `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (split +
|
||||
Postgres migration), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (sessions, accounts,
|
||||
RBAC, audit). Operational reference: `api/README.md`.
|
||||
- `api/` — the lightweight Platform API: students, cohorts, enrollments, curriculum, detailed
|
||||
scenario attempt telemetry, progressive hints, curriculum completion rules, the exam +
|
||||
capstone assessment engine, achievements, per-instructor accounts, persistent student/
|
||||
instructor sessions, RBAC, audit logging, and the instructor/admin control plane. No k3s, no
|
||||
`--privileged` — this replaced Phase 2A's "identity" instance, which was a full Lab image
|
||||
booting an unused k3s cluster just to serve these APIs. Design:
|
||||
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (split + Postgres migration),
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (sessions, accounts, RBAC, audit),
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` (learning/assessment engine). Operational
|
||||
reference: `api/README.md`.
|
||||
- `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware
|
||||
routing derived from a server-controlled session — not a client-suppliable header/param).
|
||||
Design: `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (routing), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (persistent sessions, CSRF).
|
||||
routing derived from a server-controlled session — not a client-suppliable header/param — and,
|
||||
as of Phase 3B, server-side gating of progressive hint reveal). Design:
|
||||
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (routing), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`
|
||||
(persistent sessions, CSRF), `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §3 (hint gating).
|
||||
- `egress-proxy/` — shared allowlisting proxy every Lab's outbound traffic passes through.
|
||||
- `docker-compose.dev.yml` — the full stack (Postgres, egress proxy, Provisioner, Platform API,
|
||||
Gateway) on one machine. This is the Phase 1/2A/2B **local dev exception** — production is
|
||||
@@ -54,16 +60,21 @@ further instructor accounts from the Instructors tab).
|
||||
- `test/e2e-phase3a.js` — persistent sessions (including a real Platform API restart), instructor
|
||||
auth (no enumeration, rate limiting), RBAC across owner/admin/instructor, cohort-scoped
|
||||
instructor access, CSRF enforcement, password change, and audit logging.
|
||||
- `test/e2e-phase3b.js` — attempt telemetry, progressive hint sequencing/persistence through a
|
||||
Lab reset, curriculum stage/Capstone lock-unlock, the exam engine (random draw, server
|
||||
timer, expiry, restart persistence), the Capstone workflow end to end, two-student assessment
|
||||
isolation, and instructor learning analytics/RBAC.
|
||||
|
||||
All three e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
|
||||
All four e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
|
||||
account (see "Running the full stack locally" above) — the old `ADMIN_PASSWORD` mechanism is
|
||||
gone. They're slow (real k3s boots) and need the full stack up. None of them substitute for the
|
||||
manual real-Lab-image checks (node-pty, terminal WebSocket, `kubectl`, scenario validation) in
|
||||
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 — those need a browser-equivalent client and
|
||||
aren't automated here.
|
||||
gone. They're slow (real k3s boots, `e2e-phase3b.js` also runs a real API restart) and need the
|
||||
full stack up. None of them substitute for the manual real-Lab-image checks (node-pty, terminal
|
||||
WebSocket, `kubectl`, scenario validation) in `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 —
|
||||
those need a browser-equivalent client and aren't automated here.
|
||||
|
||||
## Known limitations
|
||||
|
||||
See `provisioner/README.md` (Phase 1), `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9 (Phase
|
||||
2A), and `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (Phase 3A, supersedes Phase 2B's
|
||||
identity limitations) for the full, honest lists.
|
||||
2A), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (Phase 3A identity limitations), and
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §20 (Phase 3B learning/assessment limitations) for the
|
||||
full, honest lists.
|
||||
|
||||
+17
-11
@@ -1,9 +1,11 @@
|
||||
# RootNode Academy — Platform API (Phase 3A)
|
||||
# RootNode Academy — Platform API (Phase 3B)
|
||||
|
||||
Lightweight service: no k3s, no `--privileged`, no Lab terminal runtime. See
|
||||
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` for the original split/migration design and
|
||||
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` for the original split/migration design,
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` for persistent sessions, per-instructor
|
||||
accounts, RBAC, and audit logging — this file is the operational reference.
|
||||
accounts, RBAC, and audit logging, and `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` for attempt
|
||||
telemetry, progressive hints, curriculum rules, and the exam/capstone engine — this file is the
|
||||
operational reference.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
@@ -71,14 +73,18 @@ with instructor accounts, which are Phase 3A-native and start empty until you bo
|
||||
## Tests
|
||||
|
||||
`platform/test/e2e-phase2a.js` (student-flow regression), `platform/test/e2e-phase2b.js` (the
|
||||
classroom acceptance workflow), and `platform/test/e2e-phase3a.js` (persistent sessions
|
||||
including a real restart, instructor auth, RBAC, cohort scoping, CSRF, audit log). All three
|
||||
need the full stack up and `OWNER_EMAIL` / `OWNER_PASSWORD` set to a bootstrapped account — see
|
||||
`platform/README.md`.
|
||||
classroom acceptance workflow), `platform/test/e2e-phase3a.js` (persistent sessions including a
|
||||
real restart, instructor auth, RBAC, cohort scoping, CSRF, audit log), and
|
||||
`platform/test/e2e-phase3b.js` (attempt telemetry, progressive hints, curriculum/Capstone
|
||||
unlock rules, the exam engine including a real restart, two-student assessment isolation,
|
||||
instructor analytics). All four need the full stack up and `OWNER_EMAIL` / `OWNER_PASSWORD` set
|
||||
to a bootstrapped account — see `platform/README.md`.
|
||||
|
||||
## Known limitations
|
||||
|
||||
See `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` "Known limitations" — sessions are
|
||||
Postgres-backed (survive a restart) but there's no distributed revocation channel to the
|
||||
Gateway's short-lived whoami cache, exam mode's live bookkeeping stays Lab-local, and OIDC/SSO
|
||||
is a documented adapter interface, not a working integration yet.
|
||||
See `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` "Known limitations" (sessions are
|
||||
Postgres-backed and survive a restart, but there's no distributed revocation channel to the
|
||||
Gateway's short-lived whoami cache; OIDC/SSO is a documented adapter interface, not a working
|
||||
integration yet) and `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §20 (no automatic
|
||||
abandoned-attempt detection, needs-attention thresholds are fixed constants, `pool_spec`
|
||||
authoring is raw JSON in the admin UI).
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Evolves scenario_attempts (Phase 2B) from "one row per validation click" into a real attempt
|
||||
-- lifecycle: one row per continuous working session on a scenario, with aggregated telemetry
|
||||
-- counters, updated in place as the student works — not a new table (Phase 3B explicitly asks
|
||||
-- to extend/refine rather than duplicate). See platform/api/src/attempts.js.
|
||||
--
|
||||
-- Backward compatible: every new column is nullable or has a DEFAULT, so Phase 2B's existing
|
||||
-- rows (real historical data from testing) stay valid without being dropped or rewritten in
|
||||
-- shape — the backfill UPDATE below just gives them sane values under the new semantics.
|
||||
|
||||
-- Phase 2B required `result` on every row (it was always written at completion time, atomically).
|
||||
-- Under the new attempt lifecycle a row can exist mid-attempt (status='in_progress') with no
|
||||
-- result yet — result is only ever set once the attempt reaches a terminal state.
|
||||
ALTER TABLE scenario_attempts ALTER COLUMN result DROP NOT NULL;
|
||||
|
||||
-- hint_count (migration 005) was deliberately left NULL / undefaulted — Phase 2B's note was
|
||||
-- "not measurable today." It's measurable now (this is the phase that builds real hint
|
||||
-- tracking), so it needs a DEFAULT 0 for new rows to actually increment from, and existing NULL
|
||||
-- rows backfilled to 0 (same "not measured" meaning either way, just now a real zero).
|
||||
ALTER TABLE scenario_attempts ALTER COLUMN hint_count SET DEFAULT 0;
|
||||
UPDATE scenario_attempts SET hint_count = 0 WHERE hint_count IS NULL;
|
||||
|
||||
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS cohort_id TEXT;
|
||||
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS curriculum_stage TEXT;
|
||||
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'completed';
|
||||
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS validation_attempt_count INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS validation_pass_count INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS validation_fail_count INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS highest_hint_level INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;
|
||||
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS assessment_session_id UUID;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE scenario_attempts ADD CONSTRAINT scenario_attempts_status_check
|
||||
CHECK (status IN ('in_progress', 'completed', 'abandoned'));
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- Backfill: Phase 2B rows were written atomically at completion time (one INSERT per validate
|
||||
-- call, already carrying completed_at + result) — under the new semantics that's a completed
|
||||
-- attempt with exactly one validation performed.
|
||||
UPDATE scenario_attempts SET
|
||||
status = 'completed',
|
||||
validation_attempt_count = 1,
|
||||
validation_pass_count = CASE WHEN result = 'passed' THEN 1 ELSE 0 END,
|
||||
validation_fail_count = CASE WHEN result = 'failed' THEN 1 ELSE 0 END
|
||||
WHERE validation_attempt_count = 0 AND completed_at IS NOT NULL;
|
||||
|
||||
-- "Find the open attempt for this student+scenario+environment" is the hot lookup path (every
|
||||
-- hint reveal and validate call needs it) — environment-scoped so a Lab recreate doesn't
|
||||
-- silently resume an attempt tied to a destroyed environment.
|
||||
CREATE INDEX IF NOT EXISTS idx_attempts_open_lookup
|
||||
ON scenario_attempts(student_id, scenario_id, environment_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_attempts_cohort ON scenario_attempts(cohort_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attempts_assessment_session ON scenario_attempts(assessment_session_id);
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Assessment engine: definitions (exam/capstone templates), cohort assignment, live sessions,
|
||||
-- and the per-session scenario draw. exam_sessions (migration 006) is left untouched — it was
|
||||
-- already scoped in Phase 2B as historical-import-only from the old Lab-local SQLite exam
|
||||
-- bookkeeping, never the live mechanism. These tables ARE the live mechanism; duplicating
|
||||
-- exam_sessions' purpose would mean building the real thing twice, so this doesn't reuse it.
|
||||
--
|
||||
-- Definitions are lightly versioned (a plain incrementing integer, not full history) so a
|
||||
-- later edit to pool_spec/duration/hint_policy never changes what an already-started session is
|
||||
-- graded against — every session snapshots the fields that matter at draw time.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assessment_definitions (
|
||||
assessment_definition_id TEXT PRIMARY KEY, -- slug, e.g. 'rootnode-capstone'
|
||||
type TEXT NOT NULL CHECK (type IN ('exam', 'capstone')),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
duration_minutes INTEGER NOT NULL,
|
||||
hint_policy TEXT NOT NULL DEFAULT 'disabled' CHECK (hint_policy IN ('free', 'disabled', 'flagged')),
|
||||
pool_spec JSONB NOT NULL, -- {"required": [scenario_id,...], "random": [{"count":n,"from":[scenario_id,...]},...]}
|
||||
completion_rule JSONB NOT NULL DEFAULT '{"type":"all_scenarios_passed"}',
|
||||
unlock_rule JSONB, -- capstone only: {"type":"stages_complete","stage_ids":[...]}
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||||
created_by UUID REFERENCES instructors(instructor_id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assessment_cohort_assignments (
|
||||
assessment_definition_id TEXT NOT NULL REFERENCES assessment_definitions(assessment_definition_id),
|
||||
cohort_id TEXT NOT NULL REFERENCES cohorts(cohort_id),
|
||||
assigned_by UUID REFERENCES instructors(instructor_id),
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (assessment_definition_id, cohort_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assessment_sessions (
|
||||
assessment_session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
assessment_definition_id TEXT NOT NULL REFERENCES assessment_definitions(assessment_definition_id),
|
||||
definition_version INTEGER NOT NULL, -- snapshot: which version of the definition was drawn against
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
cohort_id TEXT,
|
||||
type TEXT NOT NULL, -- snapshot
|
||||
title TEXT NOT NULL, -- snapshot
|
||||
duration_minutes INTEGER NOT NULL, -- snapshot
|
||||
hint_policy TEXT NOT NULL, -- snapshot
|
||||
status TEXT NOT NULL DEFAULT 'not_started'
|
||||
CHECK (status IN ('not_started', 'in_progress', 'completed', 'expired', 'cancelled')),
|
||||
started_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ, -- server-computed = started_at + duration_minutes; authoritative, never client-set
|
||||
submitted_at TIMESTAMPTZ,
|
||||
random_seed TEXT, -- recorded for audit/reproducibility, not exposed to the student before the draw
|
||||
result JSONB, -- {"scenarios_total":n,"scenarios_passed":n,"passed":bool}
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assessment_session_scenarios (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
assessment_session_id UUID NOT NULL REFERENCES assessment_sessions(assessment_session_id),
|
||||
scenario_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed', 'failed')),
|
||||
attempt_id BIGINT REFERENCES scenario_attempts(attempt_id),
|
||||
UNIQUE (assessment_session_id, scenario_id)
|
||||
);
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE scenario_attempts ADD CONSTRAINT scenario_attempts_assessment_session_fk
|
||||
FOREIGN KEY (assessment_session_id) REFERENCES assessment_sessions(assessment_session_id);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_assessment_sessions_student ON assessment_sessions(student_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_assessment_sessions_definition ON assessment_sessions(assessment_definition_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_assessment_session_scenarios_session ON assessment_session_scenarios(assessment_session_id);
|
||||
|
||||
-- Seed defaults — a working RootNode Capstone and one example exam ship out of the box, per the
|
||||
-- Phase 3B spec ("ship with a sensible RootNode default"). Scenario ids are a snapshot of
|
||||
-- scenarios/rootnode-path.json's stage lists as of this migration; edit the definition via the
|
||||
-- admin API if curriculum content changes later, same as any other admin-owned config.
|
||||
INSERT INTO assessment_definitions (assessment_definition_id, type, title, description, duration_minutes, hint_policy, pool_spec, completion_rule, unlock_rule)
|
||||
VALUES (
|
||||
'rootnode-capstone', 'capstone', 'RootNode Capstone',
|
||||
'Combines skills from every curriculum stage into one operational assessment, weighted toward RootNode Real Ops incident scenarios. Evaluates operational ability, not memorized kubectl syntax.',
|
||||
90, 'flagged',
|
||||
'{"required": ["rootnode-runaway-monitor","broken-deployment","rootnode-probe-port-mismatch","rootnode-enforce-limits"], "random": [{"count":1,"from":["deploy-nginx","scale-deployment","rolling-update-task","liveness-probe-task","edit-deployment-basics"]},{"count":1,"from":["resource-limits-task","env-vars-configmap","secrets-basics"]},{"count":1,"from":["extract-logs-basics","kubectl-cp-basics","exec-command-basics","node-cordon-basics"]}]}',
|
||||
'{"type":"all_scenarios_passed"}',
|
||||
'{"type":"stages_complete","stage_ids":["foundation","workloads","networking","configuration","troubleshooting","real-ops"]}'
|
||||
) ON CONFLICT (assessment_definition_id) DO NOTHING;
|
||||
|
||||
INSERT INTO assessment_definitions (assessment_definition_id, type, title, description, duration_minutes, hint_policy, pool_spec, completion_rule)
|
||||
VALUES (
|
||||
'rootnode-quick-assessment', 'exam', 'RootNode Kubernetes Assessment',
|
||||
'A timed, randomized skills check drawn from across the curriculum. Example exam definition — instructors can create their own from the admin portal.',
|
||||
60, 'disabled',
|
||||
'{"required": [], "random": [{"count":2,"from":["deploy-nginx","scale-deployment","rolling-update-task","liveness-probe-task","daemonset-mcq"]},{"count":1,"from":["services-mcq","expose-service-basics"]},{"count":1,"from":["extract-logs-basics","kubectl-cp-basics","exec-command-basics","node-cordon-basics"]},{"count":1,"from":["rootnode-runaway-monitor","broken-deployment","rootnode-probe-port-mismatch","rootnode-enforce-limits"]}]}',
|
||||
'{"type":"all_scenarios_passed"}'
|
||||
) ON CONFLICT (assessment_definition_id) DO NOTHING;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Persistent completion records — stage/curriculum/assessment/capstone. Not a certificate
|
||||
-- (Phase 4/5 territory, see docs/PHASE3B_LEARNING_AND_ASSESSMENT.md): just the durable fact
|
||||
-- that something was achieved, with enough context to render a certificate from later without
|
||||
-- re-deriving it from raw attempt history.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS achievements (
|
||||
achievement_id BIGSERIAL PRIMARY KEY,
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
achievement_type TEXT NOT NULL CHECK (achievement_type IN
|
||||
('stage_completed', 'curriculum_completed', 'assessment_completed', 'capstone_completed')),
|
||||
curriculum_path_id TEXT,
|
||||
curriculum_version INTEGER,
|
||||
stage_id TEXT,
|
||||
assessment_session_id UUID REFERENCES assessment_sessions(assessment_session_id),
|
||||
achieved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
metadata JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_achievements_student ON achievements(student_id, achievement_type);
|
||||
Generated
+1281
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
// Persistent completion records. recordAchievement is idempotent (a WHERE NOT EXISTS guard,
|
||||
// not a unique-index/ON CONFLICT dance — simple and correct at this write volume) so calling it
|
||||
// again for something already recorded is a safe no-op, not a duplicate row.
|
||||
|
||||
async function recordAchievement(pool, { studentId, type, curriculumPathId, curriculumVersion, stageId, assessmentSessionId, metadata }) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO achievements (student_id, achievement_type, curriculum_path_id, curriculum_version, stage_id, assessment_session_id, metadata)
|
||||
SELECT $1, $2, $3, $4, $5, $6, $7
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM achievements
|
||||
WHERE student_id = $1 AND achievement_type = $2
|
||||
AND stage_id IS NOT DISTINCT FROM $5
|
||||
AND assessment_session_id IS NOT DISTINCT FROM $6
|
||||
)
|
||||
RETURNING *`,
|
||||
[studentId, type, curriculumPathId || null, curriculumVersion || null, stageId || null, assessmentSessionId || null, metadata ? JSON.stringify(metadata) : null]
|
||||
);
|
||||
return rows[0] || null; // null means it already existed — not an error
|
||||
}
|
||||
|
||||
// Called after a progress update — checks whether this newly-completed scenario just finished
|
||||
// a stage (and, transitively, the whole curriculum), recording achievements as needed. Cheap:
|
||||
// only runs the curriculum-state computation the caller already has on hand.
|
||||
async function checkStageAndCurriculumAchievements(pool, { studentId, path, curriculumState }) {
|
||||
for (const stage of curriculumState.stages) {
|
||||
if (stage.complete) {
|
||||
await recordAchievement(pool, {
|
||||
studentId, type: 'stage_completed', curriculumPathId: path.id, curriculumVersion: path.version,
|
||||
stageId: stage.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
const allContentStagesComplete = curriculumState.stages.every((s) => s.complete);
|
||||
if (allContentStagesComplete) {
|
||||
await recordAchievement(pool, {
|
||||
studentId, type: 'curriculum_completed', curriculumPathId: path.id, curriculumVersion: path.version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Called when assessments.recordScenarioOutcome/submitSession finalizes a session — records
|
||||
// 'capstone_completed' or 'assessment_completed' depending on the session's type. Only fires on
|
||||
// an actual pass (defensive check — the only completion_rule implemented today is
|
||||
// all_scenarios_passed, which already guarantees this, but a future threshold-based rule
|
||||
// shouldn't silently start granting achievements for a partial result).
|
||||
async function recordAssessmentAchievement(pool, session) {
|
||||
if (!session || !session.result?.passed) return null;
|
||||
const type = session.type === 'capstone' ? 'capstone_completed' : 'assessment_completed';
|
||||
return recordAchievement(pool, {
|
||||
studentId: session.student_id, type, assessmentSessionId: session.assessment_session_id,
|
||||
metadata: { assessment_definition_id: session.assessment_definition_id, title: session.title },
|
||||
});
|
||||
}
|
||||
|
||||
async function listAchievements(pool, studentId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM achievements WHERE student_id = $1 ORDER BY achieved_at DESC`, [studentId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
module.exports = { recordAchievement, checkStageAndCurriculumAchievements, recordAssessmentAchievement, listAchievements };
|
||||
@@ -0,0 +1,264 @@
|
||||
// Instructor/Admin learning API — cohort/student/scenario analytics, "needs attention," and
|
||||
// assessment (exam + capstone) definition/session management. Same RBAC shape as
|
||||
// admin-routes.js: owner/admin get broad or global access, a plain 'instructor' is scoped to
|
||||
// their assigned cohorts (see rbac.js). Assessment/capstone DEFINITION authoring (create/edit/
|
||||
// activate) stays admin/owner-only — assigning an already-approved assessment to one of their
|
||||
// own cohorts, and viewing/canceling sessions within their scope, is what a plain instructor
|
||||
// can do, per the Phase 3B RBAC matrix.
|
||||
|
||||
const { computeCurriculumState } = require('./curriculum-rules');
|
||||
|
||||
const NEEDS_ATTENTION_FAIL_THRESHOLD = 3; // repeated validation failures on one scenario
|
||||
const NEEDS_ATTENTION_STALE_DAYS = 7; // no progress in this many days
|
||||
|
||||
module.exports = function registerAdminLearningRoutes(app, deps) {
|
||||
const { instructorSession, rbac, curriculum, getPool, loadProgress, recordAudit } = deps;
|
||||
const { requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess } = rbac;
|
||||
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => {
|
||||
try { const r = fn(req, res, next); if (r && r.catch) r.catch(next); } catch (e) { next(e); }
|
||||
};
|
||||
}
|
||||
const admin = {
|
||||
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
|
||||
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
|
||||
patch: (p, ...mw) => app.patch(p, instructorSession, ...mw.map(wrap)),
|
||||
};
|
||||
|
||||
// ── Cohort learning summary ──────────────────────────────────────────────────────────────
|
||||
admin.get('/api/admin/cohorts/:id/learning', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
|
||||
const pool = getPool();
|
||||
const path = curriculum.loadCurriculumPath();
|
||||
const { rows: students } = await pool.query(
|
||||
`SELECT s.student_id, s.display_name, s.last_active_at FROM cohort_enrollments e
|
||||
JOIN students s ON s.student_id = e.student_id
|
||||
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled'`,
|
||||
[req.params.id]
|
||||
);
|
||||
const stageTotals = path ? path.stages.filter((s) => s.type !== 'capstone').map((s) => ({ id: s.id, name: s.name, completedBy: 0 })) : [];
|
||||
let overallPct = 0, activeLearners = 0;
|
||||
const staleCutoff = Date.now() - NEEDS_ATTENTION_STALE_DAYS * 86400000;
|
||||
for (const s of students) {
|
||||
const progress = await loadProgress(pool, s.student_id);
|
||||
const state = path ? computeCurriculumState(path, progress) : { stages: [], percent_complete: 0 };
|
||||
overallPct += state.percent_complete;
|
||||
if (s.last_active_at && new Date(s.last_active_at).getTime() > staleCutoff) activeLearners++;
|
||||
state.stages.forEach((st, i) => { if (st.complete) stageTotals[i].completedBy++; });
|
||||
}
|
||||
res.json({
|
||||
student_count: students.length,
|
||||
active_learners: activeLearners,
|
||||
overall_completion_pct: students.length ? Math.round(overallPct / students.length) : 0,
|
||||
stages: stageTotals.map((s) => ({ ...s, pct: students.length ? Math.round((s.completedBy / students.length) * 100) : 0 })),
|
||||
});
|
||||
});
|
||||
|
||||
// ── Student learning detail ──────────────────────────────────────────────────────────────
|
||||
admin.get('/api/admin/students/:id/learning', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
||||
const pool = getPool();
|
||||
const path = curriculum.loadCurriculumPath();
|
||||
const progress = await loadProgress(pool, req.params.id);
|
||||
const capstoneAchievement = (await pool.query(
|
||||
`SELECT * FROM achievements WHERE student_id = $1 AND achievement_type = 'capstone_completed' LIMIT 1`, [req.params.id]
|
||||
)).rows[0];
|
||||
const state = path ? computeCurriculumState(path, progress, capstoneAchievement ? 'completed' : undefined) : null;
|
||||
|
||||
const { rows: attempts } = await pool.query(
|
||||
`SELECT scenario_id, status, result, validation_attempt_count, validation_pass_count, validation_fail_count,
|
||||
hint_count, highest_hint_level, duration_seconds, started_at, completed_at
|
||||
FROM scenario_attempts WHERE student_id = $1 ORDER BY started_at DESC NULLS LAST LIMIT 100`,
|
||||
[req.params.id]
|
||||
);
|
||||
const { rows: assessmentSessions } = await pool.query(
|
||||
`SELECT assessment_session_id, assessment_definition_id, type, title, status, started_at, submitted_at, result
|
||||
FROM assessment_sessions WHERE student_id = $1 ORDER BY created_at DESC`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json({
|
||||
curriculum: state, attempts, assessment_sessions: assessmentSessions,
|
||||
achievements: (await pool.query(`SELECT * FROM achievements WHERE student_id = $1 ORDER BY achieved_at DESC`, [req.params.id])).rows,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Scenario analytics ───────────────────────────────────────────────────────────────────
|
||||
admin.get('/api/admin/scenario-analytics', requireAnyInstructor, async (req, res) => {
|
||||
const pool = getPool();
|
||||
const cohortId = req.query.cohort_id || null;
|
||||
if (req.instructor.role === 'instructor') {
|
||||
if (!cohortId) return res.status(400).json({ error: 'cohort_id is required for the instructor role' });
|
||||
const { rowCount } = await pool.query(`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`, [cohortId, req.instructor.instructor_id]);
|
||||
if (!rowCount) return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT scenario_id,
|
||||
count(*) FILTER (WHERE status = 'completed')::int AS completions,
|
||||
count(DISTINCT student_id)::int AS distinct_students,
|
||||
avg(validation_attempt_count)::numeric(10,1) AS avg_attempts,
|
||||
sum(validation_fail_count)::int AS total_failures,
|
||||
sum(hint_count)::int AS total_hints_used,
|
||||
avg(duration_seconds) FILTER (WHERE duration_seconds IS NOT NULL)::int AS avg_duration_seconds
|
||||
FROM scenario_attempts
|
||||
WHERE ($1::text IS NULL OR cohort_id = $1)
|
||||
GROUP BY scenario_id ORDER BY completions DESC`,
|
||||
[cohortId]
|
||||
);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// ── Needs attention ───────────────────────────────────────────────────────────────────────
|
||||
admin.get('/api/admin/cohorts/:id/needs-attention', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
|
||||
const pool = getPool();
|
||||
const staleCutoff = new Date(Date.now() - NEEDS_ATTENTION_STALE_DAYS * 86400000);
|
||||
const { rows: students } = await pool.query(
|
||||
`SELECT s.student_id, s.display_name, s.last_active_at FROM cohort_enrollments e
|
||||
JOIN students s ON s.student_id = e.student_id WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled'`,
|
||||
[req.params.id]
|
||||
);
|
||||
const signals = [];
|
||||
for (const s of students) {
|
||||
const { rows: stuck } = await pool.query(
|
||||
`SELECT scenario_id, validation_fail_count, highest_hint_level FROM scenario_attempts
|
||||
WHERE student_id = $1 AND status = 'in_progress' AND validation_fail_count >= $2
|
||||
ORDER BY validation_fail_count DESC LIMIT 1`,
|
||||
[s.student_id, NEEDS_ATTENTION_FAIL_THRESHOLD]
|
||||
);
|
||||
if (stuck[0]) {
|
||||
signals.push({ student_id: s.student_id, display_name: s.display_name, reason: 'repeated_attempts',
|
||||
label: 'Repeated Attempts', scenario_id: stuck[0].scenario_id, detail: `${stuck[0].validation_fail_count} failed validations` });
|
||||
}
|
||||
const { rows: maxedHints } = await pool.query(
|
||||
`SELECT sa.scenario_id, sa.highest_hint_level FROM scenario_attempts sa
|
||||
WHERE sa.student_id = $1 AND sa.status = 'in_progress' AND sa.highest_hint_level > 0 LIMIT 1`,
|
||||
[s.student_id]
|
||||
);
|
||||
if (maxedHints[0]) {
|
||||
signals.push({ student_id: s.student_id, display_name: s.display_name, reason: 'used_hints',
|
||||
label: 'May Need Help', scenario_id: maxedHints[0].scenario_id, detail: `hint level ${maxedHints[0].highest_hint_level} used, still in progress` });
|
||||
}
|
||||
if (!s.last_active_at || new Date(s.last_active_at) < staleCutoff) {
|
||||
signals.push({ student_id: s.student_id, display_name: s.display_name, reason: 'inactive',
|
||||
label: 'Needs Attention', detail: s.last_active_at ? `inactive since ${s.last_active_at}` : 'never active' });
|
||||
}
|
||||
const { rows: failedAssessment } = await pool.query(
|
||||
`SELECT assessment_session_id, title FROM assessment_sessions WHERE student_id = $1 AND status = 'expired' ORDER BY created_at DESC LIMIT 1`,
|
||||
[s.student_id]
|
||||
);
|
||||
if (failedAssessment[0]) {
|
||||
signals.push({ student_id: s.student_id, display_name: s.display_name, reason: 'incomplete_assessment',
|
||||
label: 'Needs Attention', detail: `"${failedAssessment[0].title}" expired without completion` });
|
||||
}
|
||||
}
|
||||
res.json(signals);
|
||||
});
|
||||
|
||||
// ── Assessment definitions ───────────────────────────────────────────────────────────────
|
||||
admin.get('/api/admin/assessments', requireAnyInstructor, async (req, res) => {
|
||||
const { rows } = await getPool().query(`SELECT * FROM assessment_definitions ORDER BY type, title`);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
admin.post('/api/admin/assessments', requireAdminOrOwner, async (req, res) => {
|
||||
const { id, type, title, description, duration_minutes, hint_policy, pool_spec, completion_rule, unlock_rule } = req.body || {};
|
||||
if (!id || !type || !title || !duration_minutes || !pool_spec) {
|
||||
return res.status(400).json({ error: 'id, type, title, duration_minutes, and pool_spec are required' });
|
||||
}
|
||||
if (!['exam', 'capstone'].includes(type)) return res.status(400).json({ error: 'invalid type' });
|
||||
const pool = getPool();
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO assessment_definitions (assessment_definition_id, type, title, description, duration_minutes, hint_policy, pool_spec, completion_rule, unlock_rule, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
[id, type, title, description || null, duration_minutes, hint_policy || 'disabled', JSON.stringify(pool_spec),
|
||||
JSON.stringify(completion_rule || { type: 'all_scenarios_passed' }), unlock_rule ? JSON.stringify(unlock_rule) : null, req.instructor.instructor_id]
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'id already exists' });
|
||||
throw e;
|
||||
}
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'assessment_create', resourceType: 'assessment_definition', resourceId: id, result: 'success', sourceIp: req.ip });
|
||||
res.status(201).json({ id });
|
||||
});
|
||||
|
||||
admin.patch('/api/admin/assessments/:id', requireAdminOrOwner, async (req, res) => {
|
||||
const { title, description, duration_minutes, hint_policy, pool_spec, completion_rule, unlock_rule, status } = req.body || {};
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(`SELECT * FROM assessment_definitions WHERE assessment_definition_id = $1`, [req.params.id]);
|
||||
const current = rows[0];
|
||||
if (!current) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
// Anything that would change what an in-progress session is graded against bumps the
|
||||
// version — already-started sessions keep their own snapshot, so this never changes a
|
||||
// result retroactively.
|
||||
const semanticChange = [
|
||||
duration_minutes !== undefined && duration_minutes !== current.duration_minutes,
|
||||
hint_policy !== undefined && hint_policy !== current.hint_policy,
|
||||
pool_spec !== undefined && JSON.stringify(pool_spec) !== JSON.stringify(current.pool_spec),
|
||||
completion_rule !== undefined && JSON.stringify(completion_rule) !== JSON.stringify(current.completion_rule),
|
||||
].some(Boolean);
|
||||
|
||||
const fields = ['updated_at = now()'], values = [];
|
||||
const set = (col, val) => { values.push(val); fields.push(`${col} = $${values.length}`); };
|
||||
if (title !== undefined) set('title', title);
|
||||
if (description !== undefined) set('description', description);
|
||||
if (duration_minutes !== undefined) set('duration_minutes', duration_minutes);
|
||||
if (hint_policy !== undefined) set('hint_policy', hint_policy);
|
||||
if (pool_spec !== undefined) set('pool_spec', JSON.stringify(pool_spec));
|
||||
if (completion_rule !== undefined) set('completion_rule', JSON.stringify(completion_rule));
|
||||
if (unlock_rule !== undefined) set('unlock_rule', JSON.stringify(unlock_rule));
|
||||
if (status !== undefined && ['active', 'inactive'].includes(status)) set('status', status);
|
||||
if (semanticChange) { values.push(current.version + 1); fields.push(`version = $${values.length}`); }
|
||||
values.push(req.params.id);
|
||||
await pool.query(`UPDATE assessment_definitions SET ${fields.join(', ')} WHERE assessment_definition_id = $${values.length}`, values);
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'assessment_update', resourceType: 'assessment_definition', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { semanticChange } });
|
||||
res.json({ ok: true, version_bumped: semanticChange });
|
||||
});
|
||||
|
||||
admin.post('/api/admin/assessments/:id/assign', requireAnyInstructor, async (req, res) => {
|
||||
const { cohort_id } = req.body || {};
|
||||
if (!cohort_id) return res.status(400).json({ error: 'cohort_id required' });
|
||||
if (req.instructor.role === 'instructor') {
|
||||
const { rowCount } = await getPool().query(`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`, [cohort_id, req.instructor.instructor_id]);
|
||||
if (!rowCount) return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
const pool = getPool();
|
||||
await pool.query(
|
||||
`INSERT INTO assessment_cohort_assignments (assessment_definition_id, cohort_id, assigned_by) VALUES ($1,$2,$3)
|
||||
ON CONFLICT (assessment_definition_id, cohort_id) DO NOTHING`,
|
||||
[req.params.id, cohort_id, req.instructor.instructor_id]
|
||||
);
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'assessment_assign', resourceType: 'assessment_definition', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { cohort_id } });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
admin.get('/api/admin/assessments/:id/sessions', requireAnyInstructor, async (req, res) => {
|
||||
const pool = getPool();
|
||||
let scopeFilter = '';
|
||||
const params = [req.params.id];
|
||||
if (req.instructor.role === 'instructor') {
|
||||
scopeFilter = `AND s.cohort_id IN (SELECT cohort_id FROM cohort_instructors WHERE instructor_id = $2)`;
|
||||
params.push(req.instructor.instructor_id);
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.assessment_session_id, s.student_id, st.display_name, s.status, s.started_at, s.submitted_at, s.expires_at, s.result
|
||||
FROM assessment_sessions s JOIN students st ON st.student_id = s.student_id
|
||||
WHERE s.assessment_definition_id = $1 ${scopeFilter} ORDER BY s.created_at DESC`,
|
||||
params
|
||||
);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
admin.post('/api/admin/assessment-sessions/:sessionId/cancel', requireAnyInstructor, async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(`SELECT * FROM assessment_sessions WHERE assessment_session_id = $1`, [req.params.sessionId]);
|
||||
const session = rows[0];
|
||||
if (!session) return res.status(404).json({ error: 'not found' });
|
||||
if (req.instructor.role === 'instructor') {
|
||||
const { rowCount } = await pool.query(`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`, [session.cohort_id, req.instructor.instructor_id]);
|
||||
if (!rowCount) return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
await pool.query(`UPDATE assessment_sessions SET status = 'cancelled', submitted_at = COALESCE(submitted_at, now()) WHERE assessment_session_id = $1 AND status IN ('not_started','in_progress')`, [req.params.sessionId]);
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'assessment_session_cancel', resourceType: 'assessment_session', resourceId: req.params.sessionId, result: 'success', sourceIp: req.ip });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
// Assessment engine — exam and capstone definitions, cohort assignment, and live sessions.
|
||||
// The timer and the drawn scenario set are BOTH server-authoritative: expires_at is computed
|
||||
// once at session start and never recomputed from a client-supplied value, and the drawn
|
||||
// scenario_ids live only in Postgres (assessment_session_scenarios) — a student can see them
|
||||
// (they need to know what to work on) but can't set or replace them. See
|
||||
// docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §19 for the full integrity story.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Tiny deterministic PRNG (mulberry32) — enough for a reproducible/audit-able draw without a
|
||||
// new dependency. Seeded from a string via a cheap hash; the seed itself is recorded on the
|
||||
// session for audit, not exposed to the student before the draw happens.
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return function () {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
function seedToInt(seed) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) { h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0; }
|
||||
return h;
|
||||
}
|
||||
function shuffle(arr, rng) {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// Draws a scenario set from a pool_spec: every id in `required` is included, plus `count`
|
||||
// randomly-chosen ids from each `random` group's `from` list, all shuffled together into the
|
||||
// student's presentation order.
|
||||
function drawScenarios(poolSpec, seed) {
|
||||
const actualSeed = seed || crypto.randomBytes(8).toString('hex');
|
||||
const rng = mulberry32(seedToInt(actualSeed));
|
||||
const required = poolSpec.required || [];
|
||||
const randomPicks = (poolSpec.random || []).flatMap((group) => shuffle(group.from, rng).slice(0, group.count));
|
||||
return { scenario_ids: shuffle([...required, ...randomPicks], rng), seed: actualSeed };
|
||||
}
|
||||
|
||||
async function getActiveSessionForStudent(pool, studentId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM assessment_sessions WHERE student_id = $1 AND status IN ('not_started', 'in_progress')
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[studentId]
|
||||
);
|
||||
const session = rows[0];
|
||||
if (!session) return null;
|
||||
return lazyExpire(pool, session);
|
||||
}
|
||||
|
||||
// Lazy expiry — checked whenever a session is read or written, rather than a background cron
|
||||
// (no multi-host scheduler in this phase, see the Phase 3B non-goals list). A session past its
|
||||
// server-computed expires_at is transitioned to 'expired' on the next touch, whoever that is.
|
||||
async function lazyExpire(pool, session) {
|
||||
if (session.status === 'in_progress' && session.expires_at && new Date(session.expires_at) <= new Date()) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE assessment_sessions SET status = 'expired', submitted_at = COALESCE(submitted_at, now())
|
||||
WHERE assessment_session_id = $1 AND status = 'in_progress' RETURNING *`,
|
||||
[session.assessment_session_id]
|
||||
);
|
||||
return rows[0] || session;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
async function getSessionScenarios(pool, sessionId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM assessment_session_scenarios WHERE assessment_session_id = $1 ORDER BY position`, [sessionId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Starts a new session for a student against a definition — refuses if they already have a
|
||||
// live (not_started/in_progress) session for ANY definition, exam or capstone (one assessment
|
||||
// at a time; keeps the "which Lab scenario am I working on for which purpose" question
|
||||
// unambiguous for both the student and the hint-policy/attempt-linking logic).
|
||||
async function startSession(pool, { definition, studentId, cohortId }) {
|
||||
const active = await getActiveSessionForStudent(pool, studentId);
|
||||
if (active) { const err = new Error('An assessment is already in progress.'); err.code = 'ALREADY_ACTIVE'; throw err; }
|
||||
|
||||
const { scenario_ids, seed } = drawScenarios(definition.pool_spec, null);
|
||||
const startedAt = new Date();
|
||||
const expiresAt = new Date(startedAt.getTime() + definition.duration_minutes * 60 * 1000);
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO assessment_sessions
|
||||
(assessment_definition_id, definition_version, student_id, cohort_id, type, title, duration_minutes, hint_policy,
|
||||
status, started_at, expires_at, random_seed)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'in_progress',$9,$10,$11) RETURNING *`,
|
||||
[definition.assessment_definition_id, definition.version, studentId, cohortId || null, definition.type,
|
||||
definition.title, definition.duration_minutes, definition.hint_policy, startedAt, expiresAt, seed]
|
||||
);
|
||||
const session = rows[0];
|
||||
for (let i = 0; i < scenario_ids.length; i++) {
|
||||
await client.query(
|
||||
`INSERT INTO assessment_session_scenarios (assessment_session_id, scenario_id, position) VALUES ($1,$2,$3)`,
|
||||
[session.assessment_session_id, scenario_ids[i], i]
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return session;
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Finds the live session (if any) that has THIS scenario_id in its draw for THIS student — the
|
||||
// server-side signal that a validate/hint call is happening "inside" an assessment, derived
|
||||
// entirely from what the server itself stored at draw time. A client asserting "I'm in
|
||||
// assessment mode" is never trusted; this lookup is the only source of truth.
|
||||
async function findActiveAssessmentContextForScenario(pool, studentId, scenarioId) {
|
||||
const session = await getActiveSessionForStudent(pool, studentId);
|
||||
if (!session || session.status !== 'in_progress') return null;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM assessment_session_scenarios WHERE assessment_session_id = $1 AND scenario_id = $2`,
|
||||
[session.assessment_session_id, scenarioId]
|
||||
);
|
||||
if (!rows[0]) return null;
|
||||
return { session, sessionScenario: rows[0] };
|
||||
}
|
||||
|
||||
// Updates the per-scenario row after a scenario_attempts completion, and finalizes the whole
|
||||
// session once every drawn scenario is in a terminal state. Returns the finalized session row
|
||||
// if this call was the one that completed it, otherwise null — the caller uses that to decide
|
||||
// whether to record an achievement.
|
||||
async function recordScenarioOutcome(pool, sessionId, scenarioId, attemptId, passed) {
|
||||
await pool.query(
|
||||
`UPDATE assessment_session_scenarios SET status = $3, attempt_id = $4
|
||||
WHERE assessment_session_id = $1 AND scenario_id = $2`,
|
||||
[sessionId, scenarioId, passed ? 'completed' : 'in_progress', attemptId]
|
||||
);
|
||||
if (!passed) return null; // still workable — session stays open until this scenario passes or time runs out
|
||||
|
||||
const scenarios = await getSessionScenarios(pool, sessionId);
|
||||
const allDone = scenarios.every((s) => s.status === 'completed');
|
||||
if (!allDone) return null;
|
||||
|
||||
const total = scenarios.length;
|
||||
const passedCount = scenarios.filter((s) => s.status === 'completed').length;
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE assessment_sessions SET status = 'completed', submitted_at = now(),
|
||||
result = $2 WHERE assessment_session_id = $1 AND status = 'in_progress' RETURNING *`,
|
||||
[sessionId, JSON.stringify({ scenarios_total: total, scenarios_passed: passedCount, passed: passedCount === total })]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function submitSession(pool, sessionId) {
|
||||
const scenarios = await getSessionScenarios(pool, sessionId);
|
||||
const total = scenarios.length;
|
||||
const passedCount = scenarios.filter((s) => s.status === 'completed').length;
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE assessment_sessions SET status = 'completed', submitted_at = now(), result = $2
|
||||
WHERE assessment_session_id = $1 AND status = 'in_progress' RETURNING *`,
|
||||
[sessionId, JSON.stringify({ scenarios_total: total, scenarios_passed: passedCount, passed: passedCount === total })]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
drawScenarios, getActiveSessionForStudent, lazyExpire, getSessionScenarios, startSession,
|
||||
findActiveAssessmentContextForScenario, recordScenarioOutcome, submitSession,
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
// Attempt lifecycle — the Phase 3B core. One scenario_attempts row per continuous working
|
||||
// session on a scenario (not per validation click, see migrations/012). Every mutation here
|
||||
// updates that one row in place; nothing is ever inserted twice for the same open attempt.
|
||||
//
|
||||
// Deliberately does NOT try to detect "abandoned" attempts automatically (e.g. on Lab destroy)
|
||||
// — that would need a reaper-style background job wired to Provisioner environment_events,
|
||||
// which is more machinery than the instructional-visibility goal here needs. An attempt just
|
||||
// stays 'in_progress' if the student never returns to finish it; instructors can already see
|
||||
// "in_progress, N failed validations" as a real signal. Documented as a known limitation.
|
||||
|
||||
async function getOrCreateOpenAttempt(pool, { studentId, scenarioId, environmentId, cohortId, curriculumStage, assessmentSessionId }) {
|
||||
const { rows: existing } = await pool.query(
|
||||
`SELECT * FROM scenario_attempts
|
||||
WHERE student_id = $1 AND scenario_id = $2 AND environment_id = $3 AND status = 'in_progress'
|
||||
ORDER BY started_at DESC LIMIT 1`,
|
||||
[studentId, scenarioId, environmentId]
|
||||
);
|
||||
if (existing[0]) return existing[0];
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO scenario_attempts
|
||||
(student_id, scenario_id, environment_id, cohort_id, curriculum_stage, started_at, status, assessment_session_id)
|
||||
VALUES ($1, $2, $3, $4, $5, now(), 'in_progress', $6)
|
||||
RETURNING *`,
|
||||
[studentId, scenarioId, environmentId, cohortId || null, curriculumStage || null, assessmentSessionId || null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Records one validation outcome against the currently open attempt (creating one first if
|
||||
// none exists — a student can hit Validate without an explicit "start" call ever having fired,
|
||||
// e.g. if the frontend missed it; this stays correct either way). Closes the attempt on a pass.
|
||||
async function recordValidation(pool, ctx, passed) {
|
||||
const attempt = await getOrCreateOpenAttempt(pool, ctx);
|
||||
const now = new Date();
|
||||
if (passed) {
|
||||
const durationSeconds = attempt.started_at ? Math.max(0, Math.round((now - new Date(attempt.started_at)) / 1000)) : null;
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE scenario_attempts SET
|
||||
validation_attempt_count = validation_attempt_count + 1,
|
||||
validation_pass_count = validation_pass_count + 1,
|
||||
status = 'completed', result = 'passed', completed_at = now(), duration_seconds = $2
|
||||
WHERE attempt_id = $1 RETURNING *`,
|
||||
[attempt.attempt_id, durationSeconds]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE scenario_attempts SET
|
||||
validation_attempt_count = validation_attempt_count + 1,
|
||||
validation_fail_count = validation_fail_count + 1
|
||||
WHERE attempt_id = $1 RETURNING *`,
|
||||
[attempt.attempt_id]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Reveals the next hint, sequentially — level N can only be revealed once level N-1 has been.
|
||||
// Returns null if the requested reveal is out of order (caller treats that as a 409/400) or if
|
||||
// there are no more hints left.
|
||||
async function recordHintReveal(pool, ctx, totalHints) {
|
||||
const attempt = await getOrCreateOpenAttempt(pool, ctx);
|
||||
const nextLevel = attempt.highest_hint_level + 1;
|
||||
if (nextLevel > totalHints) return { ok: false, reason: 'no_more_hints', attempt };
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE scenario_attempts SET hint_count = hint_count + 1, highest_hint_level = $2
|
||||
WHERE attempt_id = $1 RETURNING *`,
|
||||
[attempt.attempt_id, nextLevel]
|
||||
);
|
||||
return { ok: true, level: nextLevel, attempt: rows[0] };
|
||||
}
|
||||
|
||||
async function getAttemptSummary(pool, { studentId, scenarioId, environmentId }) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM scenario_attempts
|
||||
WHERE student_id = $1 AND scenario_id = $2 AND ($3::uuid IS NULL OR environment_id = $3)
|
||||
ORDER BY started_at DESC LIMIT 1`,
|
||||
[studentId, scenarioId, environmentId || null]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
module.exports = { getOrCreateOpenAttempt, recordValidation, recordHintReveal, getAttemptSummary };
|
||||
@@ -0,0 +1,83 @@
|
||||
// Curriculum progression rules — reads scenarios/rootnode-path.json's per-stage
|
||||
// completion_rule (and the capstone stage's unlock_rule) against a student's progress map to
|
||||
// answer: current stage, completed/locked stages, next required scenario, percent complete,
|
||||
// and what's blocking advancement. Rule-driven, not adaptive — see
|
||||
// docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §4 for why that's a deliberate scope line.
|
||||
//
|
||||
// Capstone completeness itself isn't computed here (a capstone stage has no scenario_ids to
|
||||
// check against progress) — the caller supplies it after checking for a 'capstone_completed'
|
||||
// achievement, since that's Postgres-backed state this module has no access to.
|
||||
|
||||
function isStageComplete(stage, progress) {
|
||||
const ids = stage.scenario_ids || [];
|
||||
if (!ids.length) return false;
|
||||
const rule = stage.completion_rule || { type: 'all_required' };
|
||||
const completedIds = ids.filter((id) => progress[id]?.status === 'completed');
|
||||
switch (rule.type) {
|
||||
case 'threshold':
|
||||
return completedIds.length / ids.length >= (rule.percent || 100) / 100;
|
||||
case 'prerequisites': {
|
||||
const required = rule.scenario_ids && rule.scenario_ids.length ? rule.scenario_ids : ids;
|
||||
return required.every((id) => progress[id]?.status === 'completed');
|
||||
}
|
||||
case 'all_required':
|
||||
default:
|
||||
return completedIds.length === ids.length;
|
||||
}
|
||||
}
|
||||
|
||||
function nextIncompleteScenario(stage, progress) {
|
||||
return (stage.scenario_ids || []).find((id) => progress[id]?.status !== 'completed') || null;
|
||||
}
|
||||
|
||||
// capstoneStatus: 'locked' | 'unlocked' | 'completed' — supplied by the caller (server route),
|
||||
// since it depends on Postgres state (achievements) this module can't see.
|
||||
function computeCurriculumState(path, progress, capstoneStatus) {
|
||||
const contentStages = path.stages.filter((s) => s.type !== 'capstone');
|
||||
const capstoneStage = path.stages.find((s) => s.type === 'capstone');
|
||||
|
||||
let priorComplete = true; // the first content stage is always unlocked
|
||||
const stageCompletion = {}; // stage id -> bool, used by the capstone's stages_complete rule
|
||||
|
||||
const stages = contentStages.map((stage) => {
|
||||
const complete = isStageComplete(stage, progress);
|
||||
stageCompletion[stage.id] = complete;
|
||||
const locked = !priorComplete;
|
||||
if (!complete) priorComplete = false; // next stage stays locked until this one is done
|
||||
const ids = stage.scenario_ids || [];
|
||||
const completedCount = ids.filter((id) => progress[id]?.status === 'completed').length;
|
||||
return {
|
||||
id: stage.id, name: stage.name, icon: stage.icon, locked, complete,
|
||||
total: ids.length, completed: completedCount,
|
||||
pct: ids.length ? Math.round((completedCount / ids.length) * 100) : 0,
|
||||
next_scenario: locked || complete ? null : nextIncompleteScenario(stage, progress),
|
||||
};
|
||||
});
|
||||
|
||||
const currentStage = stages.find((s) => !s.locked && !s.complete) || null;
|
||||
const overallTotal = contentStages.reduce((n, s) => n + (s.scenario_ids || []).length, 0);
|
||||
const overallCompleted = contentStages.reduce(
|
||||
(n, s) => n + (s.scenario_ids || []).filter((id) => progress[id]?.status === 'completed').length, 0
|
||||
);
|
||||
|
||||
let capstone = null;
|
||||
if (capstoneStage) {
|
||||
const rule = capstoneStage.unlock_rule || { type: 'stages_complete', stage_ids: contentStages.map((s) => s.id) };
|
||||
const unlockedByRule = rule.type === 'stages_complete' && rule.stage_ids.every((id) => stageCompletion[id]);
|
||||
const blocking = rule.type === 'stages_complete' ? rule.stage_ids.filter((id) => !stageCompletion[id]) : [];
|
||||
capstone = {
|
||||
id: capstoneStage.id, name: capstoneStage.name, icon: capstoneStage.icon,
|
||||
assessment_definition_id: capstoneStage.capstone_assessment_id,
|
||||
status: capstoneStatus || (unlockedByRule ? 'unlocked' : 'locked'),
|
||||
blocking_stage_ids: blocking,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stages, current_stage: currentStage?.id || null,
|
||||
percent_complete: overallTotal ? Math.round((overallCompleted / overallTotal) * 100) : 0,
|
||||
capstone,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { computeCurriculumState, isStageComplete };
|
||||
@@ -0,0 +1,222 @@
|
||||
// Student-facing learning API — scenario attempt lifecycle (start/hint), curriculum state,
|
||||
// assessments (list/start/active/detail/submit), capstone status, and achievement history.
|
||||
// Everything here requires a student session (studentSession middleware, mounted by the
|
||||
// caller) and derives identity/ownership entirely server-side — never from anything the client
|
||||
// asserts. See docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §19.
|
||||
|
||||
const attempts = require('./attempts');
|
||||
const assessmentsLib = require('./assessments');
|
||||
const achievementsLib = require('./achievements');
|
||||
const { computeCurriculumState } = require('./curriculum-rules');
|
||||
|
||||
async function studentCohortId(pool, studentId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT cohort_id FROM cohort_enrollments WHERE student_id = $1 AND enrollment_status = 'enrolled' ORDER BY enrolled_at LIMIT 1`,
|
||||
[studentId]
|
||||
);
|
||||
return rows[0]?.cohort_id || null;
|
||||
}
|
||||
|
||||
function findStageForScenario(path, scenarioId) {
|
||||
return path?.stages.find((s) => (s.scenario_ids || []).includes(scenarioId))?.id || null;
|
||||
}
|
||||
|
||||
async function getCapstoneAchievement(pool, studentId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM achievements WHERE student_id = $1 AND achievement_type = 'capstone_completed' LIMIT 1`, [studentId]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
module.exports = function registerLearningRoutes(app, deps) {
|
||||
const { studentSession, getPool, curriculum, loadProgress, wrap } = deps;
|
||||
|
||||
// ── Scenario attempt lifecycle ──────────────────────────────────────────────────────────
|
||||
app.post('/api/scenarios/:scenarioId/start', studentSession, wrap(async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { environment_id } = req.body || {};
|
||||
const path = curriculum.loadCurriculumPath();
|
||||
const cohortId = await studentCohortId(pool, req.studentId);
|
||||
const stage = findStageForScenario(path, req.params.scenarioId);
|
||||
const assessmentCtx = await assessmentsLib.findActiveAssessmentContextForScenario(pool, req.studentId, req.params.scenarioId);
|
||||
const attempt = await attempts.getOrCreateOpenAttempt(pool, {
|
||||
studentId: req.studentId, scenarioId: req.params.scenarioId, environmentId: environment_id || null,
|
||||
cohortId, curriculumStage: stage, assessmentSessionId: assessmentCtx?.session.assessment_session_id,
|
||||
});
|
||||
res.json({ attempt_id: attempt.attempt_id, status: attempt.status, started_at: attempt.started_at });
|
||||
}));
|
||||
|
||||
app.get('/api/scenarios/:scenarioId/attempt', studentSession, wrap(async (req, res) => {
|
||||
const attempt = await attempts.getAttemptSummary(getPool(), {
|
||||
studentId: req.studentId, scenarioId: req.params.scenarioId, environmentId: req.query.environment_id,
|
||||
});
|
||||
const scenario = curriculum.loadScenarios().find((s) => s.id === req.params.scenarioId);
|
||||
res.json({
|
||||
attempt: attempt ? {
|
||||
status: attempt.status, validation_attempt_count: attempt.validation_attempt_count,
|
||||
validation_pass_count: attempt.validation_pass_count, validation_fail_count: attempt.validation_fail_count,
|
||||
hint_count: attempt.hint_count, highest_hint_level: attempt.highest_hint_level,
|
||||
duration_seconds: attempt.duration_seconds, started_at: attempt.started_at, completed_at: attempt.completed_at,
|
||||
} : null,
|
||||
total_hints: scenario?.hints?.length || 0,
|
||||
});
|
||||
}));
|
||||
|
||||
app.post('/api/scenarios/:scenarioId/hint', studentSession, wrap(async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { environment_id } = req.body || {};
|
||||
const scenario = curriculum.loadScenarios().find((s) => s.id === req.params.scenarioId);
|
||||
if (!scenario) return res.status(404).json({ error: 'scenario_not_found' });
|
||||
const hints = scenario.hints || [];
|
||||
if (!hints.length) return res.status(404).json({ error: 'no_hints' });
|
||||
|
||||
const assessmentCtx = await assessmentsLib.findActiveAssessmentContextForScenario(pool, req.studentId, req.params.scenarioId);
|
||||
if (assessmentCtx && assessmentCtx.session.hint_policy === 'disabled') {
|
||||
return res.status(403).json({ error: 'hints_disabled', message: 'Hints are disabled for this assessment.' });
|
||||
}
|
||||
|
||||
const path = curriculum.loadCurriculumPath();
|
||||
const cohortId = await studentCohortId(pool, req.studentId);
|
||||
const stage = findStageForScenario(path, req.params.scenarioId);
|
||||
const result = await attempts.recordHintReveal(pool, {
|
||||
studentId: req.studentId, scenarioId: req.params.scenarioId, environmentId: environment_id || null,
|
||||
cohortId, curriculumStage: stage, assessmentSessionId: assessmentCtx?.session.assessment_session_id,
|
||||
}, hints.length);
|
||||
|
||||
if (!result.ok) return res.status(409).json({ error: result.reason });
|
||||
const { title, body, command } = hints[result.level - 1];
|
||||
res.json({
|
||||
hint: { title, body, command }, level: result.level, total_hints: hints.length,
|
||||
flagged: !!(assessmentCtx && assessmentCtx.session.hint_policy === 'flagged'),
|
||||
});
|
||||
}));
|
||||
|
||||
// ── Curriculum state (extends /api/curriculum/path's response with rule-driven state) ─────
|
||||
app.get('/api/curriculum/state', studentSession, wrap(async (req, res) => {
|
||||
const pool = getPool();
|
||||
const path = curriculum.loadCurriculumPath();
|
||||
if (!path) return res.status(404).json({ error: 'curriculum_not_found' });
|
||||
const progress = await loadProgress(pool, req.studentId);
|
||||
const capstoneAchievement = await getCapstoneAchievement(pool, req.studentId);
|
||||
const capstoneStatus = capstoneAchievement ? 'completed' : undefined; // undefined lets computeCurriculumState derive locked/unlocked from the rule
|
||||
res.json(computeCurriculumState(path, progress, capstoneStatus));
|
||||
}));
|
||||
|
||||
// ── Assessments ──────────────────────────────────────────────────────────────────────────
|
||||
app.get('/api/assessments', studentSession, wrap(async (req, res) => {
|
||||
const pool = getPool();
|
||||
const cohortId = await studentCohortId(pool, req.studentId);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT d.assessment_definition_id, d.type, d.title, d.description, d.duration_minutes, d.hint_policy
|
||||
FROM assessment_definitions d
|
||||
JOIN assessment_cohort_assignments a ON a.assessment_definition_id = d.assessment_definition_id
|
||||
WHERE a.cohort_id = $1 AND d.status = 'active' AND d.type = 'exam'`,
|
||||
[cohortId || '__none__']
|
||||
);
|
||||
const { rows: sessions } = await pool.query(
|
||||
`SELECT assessment_definition_id, status, result, started_at, submitted_at, assessment_session_id
|
||||
FROM assessment_sessions WHERE student_id = $1 ORDER BY created_at DESC`,
|
||||
[req.studentId]
|
||||
);
|
||||
res.json(rows.map((d) => ({
|
||||
...d,
|
||||
sessions: sessions.filter((s) => s.assessment_definition_id === d.assessment_definition_id),
|
||||
})));
|
||||
}));
|
||||
|
||||
app.get('/api/assessments/active', studentSession, wrap(async (req, res) => {
|
||||
const pool = getPool();
|
||||
const session = await assessmentsLib.getActiveSessionForStudent(pool, req.studentId);
|
||||
if (!session || session.status !== 'in_progress') return res.json({ active: false });
|
||||
const scenarios = await assessmentsLib.getSessionScenarios(pool, session.assessment_session_id);
|
||||
res.json({
|
||||
active: true, assessment_session_id: session.assessment_session_id, type: session.type, title: session.title,
|
||||
hint_policy: session.hint_policy, started_at: session.started_at, expires_at: session.expires_at,
|
||||
scenarios: scenarios.map((s) => ({ scenario_id: s.scenario_id, position: s.position, status: s.status })),
|
||||
});
|
||||
}));
|
||||
|
||||
app.get('/api/assessments/:sessionId', studentSession, wrap(async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM assessment_sessions WHERE assessment_session_id = $1 AND student_id = $2`,
|
||||
[req.params.sessionId, req.studentId]
|
||||
);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'not_found' });
|
||||
const session = await assessmentsLib.lazyExpire(pool, rows[0]);
|
||||
const scenarios = await assessmentsLib.getSessionScenarios(pool, session.assessment_session_id);
|
||||
res.json({ ...session, scenarios });
|
||||
}));
|
||||
|
||||
app.post('/api/assessments/:definitionId/start', studentSession, wrap(async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM assessment_definitions WHERE assessment_definition_id = $1 AND status = 'active'`,
|
||||
[req.params.definitionId]
|
||||
);
|
||||
const definition = rows[0];
|
||||
if (!definition) return res.status(404).json({ error: 'not_found' });
|
||||
const cohortId = await studentCohortId(pool, req.studentId);
|
||||
try {
|
||||
const session = await assessmentsLib.startSession(pool, { definition, studentId: req.studentId, cohortId });
|
||||
const scenarios = await assessmentsLib.getSessionScenarios(pool, session.assessment_session_id);
|
||||
res.status(201).json({
|
||||
assessment_session_id: session.assessment_session_id, type: session.type, title: session.title,
|
||||
started_at: session.started_at, expires_at: session.expires_at, hint_policy: session.hint_policy,
|
||||
scenarios: scenarios.map((s) => ({ scenario_id: s.scenario_id, position: s.position, status: s.status })),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.code === 'ALREADY_ACTIVE') return res.status(409).json({ error: 'already_active', message: e.message });
|
||||
throw e;
|
||||
}
|
||||
}));
|
||||
|
||||
app.post('/api/assessments/:sessionId/submit', studentSession, wrap(async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM assessment_sessions WHERE assessment_session_id = $1 AND student_id = $2`,
|
||||
[req.params.sessionId, req.studentId]
|
||||
);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'not_found' });
|
||||
if (rows[0].status !== 'in_progress') return res.status(409).json({ error: 'not_in_progress' });
|
||||
const session = await assessmentsLib.submitSession(pool, req.params.sessionId);
|
||||
await achievementsLib.recordAssessmentAchievement(pool, session);
|
||||
res.json(session);
|
||||
}));
|
||||
|
||||
// ── Capstone (a thin, student-friendly view over curriculum state + the capstone definition) ─
|
||||
app.get('/api/capstone', studentSession, wrap(async (req, res) => {
|
||||
const pool = getPool();
|
||||
const path = curriculum.loadCurriculumPath();
|
||||
const progress = await loadProgress(pool, req.studentId);
|
||||
const capstoneAchievement = await getCapstoneAchievement(pool, req.studentId);
|
||||
const state = computeCurriculumState(path, progress, capstoneAchievement ? 'completed' : undefined);
|
||||
if (!state.capstone) return res.status(404).json({ error: 'no_capstone_configured' });
|
||||
const { rows } = await pool.query(
|
||||
`SELECT assessment_definition_id, title, description, duration_minutes, hint_policy FROM assessment_definitions WHERE assessment_definition_id = $1`,
|
||||
[state.capstone.assessment_definition_id]
|
||||
);
|
||||
const { rows: sessionRows } = await pool.query(
|
||||
`SELECT assessment_session_id, status, started_at, expires_at, submitted_at, result FROM assessment_sessions
|
||||
WHERE student_id = $1 AND assessment_definition_id = $2 ORDER BY created_at DESC`,
|
||||
[req.studentId, state.capstone.assessment_definition_id]
|
||||
);
|
||||
res.json({ ...state.capstone, definition: rows[0] || null, sessions: sessionRows });
|
||||
}));
|
||||
|
||||
// ── Achievements ─────────────────────────────────────────────────────────────────────────
|
||||
app.get('/api/achievements', studentSession, wrap(async (req, res) => {
|
||||
res.json(await achievementsLib.listAchievements(getPool(), req.studentId));
|
||||
}));
|
||||
|
||||
// ── Recent attempts (for the student's own Practice view) ──────────────────────────────────
|
||||
app.get('/api/attempts/recent', studentSession, wrap(async (req, res) => {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT scenario_id, status, result, validation_attempt_count, validation_pass_count,
|
||||
validation_fail_count, hint_count, duration_seconds, started_at, completed_at
|
||||
FROM scenario_attempts WHERE student_id = $1 ORDER BY started_at DESC NULLS LAST LIMIT 20`,
|
||||
[req.studentId]
|
||||
);
|
||||
res.json(rows);
|
||||
}));
|
||||
};
|
||||
@@ -20,6 +20,10 @@ const { parseCookies, checkCsrf, makeCsrfToken } = require('./security');
|
||||
const { buildCookie, clearCookie } = require('./cookies');
|
||||
const createRbac = require('./rbac');
|
||||
const { INSTRUCTOR_COOKIE, CSRF_COOKIE: INSTRUCTOR_CSRF_COOKIE } = require('./instructor-auth-routes');
|
||||
const attemptsLib = require('./attempts');
|
||||
const assessments = require('./assessments');
|
||||
const achievements = require('./achievements');
|
||||
const { computeCurriculumState } = require('./curriculum-rules');
|
||||
|
||||
const PORT = Number(process.env.PLATFORM_API_PORT) || 4300;
|
||||
const STUDENT_COOKIE = 'rn_session';
|
||||
@@ -167,24 +171,52 @@ app.get('/api/progress', wrap(studentSession), async (req, res) => {
|
||||
});
|
||||
|
||||
app.post('/api/progress/record', wrap(studentSession), async (req, res) => {
|
||||
const { scenario_id, status, environment_id, validation_count } = req.body || {};
|
||||
const { scenario_id, status, environment_id } = req.body || {};
|
||||
if (!scenario_id || !['completed', 'in_progress'].includes(status)) {
|
||||
return res.status(400).json({ error: 'scenario_id and a valid status are required' });
|
||||
}
|
||||
const pool = getPool();
|
||||
const passed = status === 'completed';
|
||||
|
||||
// Attempt telemetry (Phase 3B) — one row per continuous working session, updated in place.
|
||||
const curriculumPath = curriculum.loadCurriculumPath();
|
||||
const stage = curriculumPath?.stages.find((s) => (s.scenario_ids || []).includes(scenario_id))?.id || null;
|
||||
const { rows: enrolledRows } = await pool.query(
|
||||
`SELECT cohort_id FROM cohort_enrollments WHERE student_id = $1 AND enrollment_status = 'enrolled' ORDER BY enrolled_at LIMIT 1`,
|
||||
[req.studentId]
|
||||
);
|
||||
const cohortId = enrolledRows[0]?.cohort_id || null;
|
||||
const assessmentCtx = await assessments.findActiveAssessmentContextForScenario(pool, req.studentId, scenario_id);
|
||||
const attempt = await attemptsLib.recordValidation(pool, {
|
||||
studentId: req.studentId, scenarioId: scenario_id, environmentId: environment_id || null,
|
||||
cohortId, curriculumStage: stage, assessmentSessionId: assessmentCtx?.session.assessment_session_id,
|
||||
}, passed);
|
||||
|
||||
// Legacy aggregate progress row (Phase 1/2A/2B) — kept for the existing curriculum/dashboard
|
||||
// views that read it; attempt-level detail above is the new source of truth for telemetry.
|
||||
const progress = await loadProgress(pool, req.studentId);
|
||||
const prev = progress[scenario_id] || { attempts: 0 };
|
||||
const now = new Date().toISOString();
|
||||
await saveOneProgress(pool, req.studentId, scenario_id, {
|
||||
...prev, status, attempts: (prev.attempts || 0) + 1, last_validated: now,
|
||||
completed_at: status === 'completed' ? now : prev.completed_at,
|
||||
completed_at: passed ? now : prev.completed_at,
|
||||
});
|
||||
await pool.query(
|
||||
`INSERT INTO scenario_attempts (student_id, scenario_id, environment_id, completed_at, result, validation_count)
|
||||
VALUES ($1, $2, $3, now(), $4, $5)`,
|
||||
[req.studentId, scenario_id, environment_id || null, status === 'completed' ? 'passed' : 'failed', validation_count ?? null]
|
||||
);
|
||||
await touchStudent(pool, req.studentId);
|
||||
|
||||
if (passed) {
|
||||
if (assessmentCtx) {
|
||||
const finalizedSession = await assessments.recordScenarioOutcome(pool, assessmentCtx.session.assessment_session_id, scenario_id, attempt.attempt_id, true);
|
||||
await achievements.recordAssessmentAchievement(pool, finalizedSession);
|
||||
}
|
||||
if (curriculumPath) {
|
||||
const freshProgress = await loadProgress(pool, req.studentId);
|
||||
const state = computeCurriculumState(curriculumPath, freshProgress);
|
||||
await achievements.checkStageAndCurriculumAchievements(pool, { studentId: req.studentId, path: curriculumPath, curriculumState: state });
|
||||
}
|
||||
} else if (assessmentCtx) {
|
||||
await assessments.recordScenarioOutcome(pool, assessmentCtx.session.assessment_session_id, scenario_id, attempt.attempt_id, false);
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -249,10 +281,16 @@ app.get('/api/environment/by-token/:token', async (req, res) => {
|
||||
|
||||
require('./instructor-auth-routes')(app, { pool: getPool(), sessions, instructorSession: wrap(instructorSession) });
|
||||
|
||||
require('./learning-routes')(app, { studentSession, getPool, curriculum, loadProgress, wrap });
|
||||
|
||||
const rbac = createRbac(getPool());
|
||||
require('./admin-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac: createRbac(getPool()), provisioner, curriculum,
|
||||
instructorSession: wrap(instructorSession), rbac, provisioner, curriculum,
|
||||
getPool, loadProgress, saveOneProgress, recordAudit,
|
||||
});
|
||||
require('./admin-learning-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, curriculum, getPool, loadProgress, recordAudit,
|
||||
});
|
||||
|
||||
// Fallback to the platform-shell SPA for any unmatched non-API route
|
||||
app.get('*', (req, res) => {
|
||||
|
||||
Generated
+803
@@ -0,0 +1,803 @@
|
||||
{
|
||||
"name": "rootnode-gateway",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rootnode-gateway",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"http-proxy": "^1.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy": {
|
||||
"version": "1.18.1",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
|
||||
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^4.0.0",
|
||||
"follow-redirects": "^1.0.0",
|
||||
"requires-port": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,66 @@ app.post('/lab/:token/api/scenarios/:scenarioId/answer', express.json(), async (
|
||||
});
|
||||
});
|
||||
|
||||
// ── Phase 3B learning relay — /start and /hint are pure Platform API operations, unlike
|
||||
// validate/answer above (those call the Lab first, then relay the outcome). The Lab has no
|
||||
// concept of attempts, hints-as-tracked-state, or assessments; these forward straight through
|
||||
// to Platform API with the browser's own session + CSRF token (same forwarding requirement
|
||||
// documented on relayScenarioOutcome above — both cookie AND header, not just the header). ────
|
||||
async function relayToPlatformApi(req, res, platformPath, extraBody) {
|
||||
const cookies = parseCookies(req);
|
||||
const token = cookies[STUDENT_COOKIE];
|
||||
const csrf = cookies[STUDENT_CSRF_COOKIE] || '';
|
||||
try {
|
||||
const platformRes = await fetch(`${PLATFORM_API_URL}${platformPath}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `${STUDENT_COOKIE}=${token}; ${STUDENT_CSRF_COOKIE}=${csrf}`,
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: JSON.stringify({ ...(req.body || {}), ...extraBody }),
|
||||
});
|
||||
const body = await platformRes.json().catch(() => ({}));
|
||||
res.status(platformRes.status).json(body);
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: 'platform_unreachable' });
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/lab/:token/api/scenarios/:scenarioId/start', express.json(), async (req, res) => {
|
||||
const env = await resolveByToken(req.params.token);
|
||||
const own = await checkOwnership(req, env);
|
||||
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
|
||||
await relayToPlatformApi(req, res, `/api/scenarios/${req.params.scenarioId}/start`, { environment_id: env.environment_id });
|
||||
});
|
||||
|
||||
app.post('/lab/:token/api/scenarios/:scenarioId/hint', express.json(), async (req, res) => {
|
||||
const env = await resolveByToken(req.params.token);
|
||||
const own = await checkOwnership(req, env);
|
||||
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
|
||||
await relayToPlatformApi(req, res, `/api/scenarios/${req.params.scenarioId}/hint`, { environment_id: env.environment_id });
|
||||
});
|
||||
|
||||
// Strips `hints` from the Lab's own scenario-detail response before it reaches the browser —
|
||||
// the Lab backend still returns the full array (it has no student/attempt concept and was
|
||||
// never modified), but hints must only be revealed progressively through the endpoint above.
|
||||
// The Lab image itself stays byte-for-byte unmodified; this is a response rewrite at the
|
||||
// Gateway, not a Lab change.
|
||||
app.get('/lab/:token/api/scenarios/:scenarioId', async (req, res) => {
|
||||
const env = await resolveByToken(req.params.token);
|
||||
const own = await checkOwnership(req, env);
|
||||
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
|
||||
if (!isReachable(env)) return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running.');
|
||||
try {
|
||||
const labRes = await fetch(`http://${env.internal_ip}:${env.internal_port}/api/scenarios/${req.params.scenarioId}`);
|
||||
const body = await labRes.json().catch(() => ({}));
|
||||
const { hints, ...withoutHints } = body;
|
||||
res.status(labRes.status).json({ ...withoutHints, hint_count: Array.isArray(hints) ? hints.length : 0 });
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: 'lab_unreachable' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── /lab/:token/* — HTTP proxy into the student's own Lab, prefix-stripped ─────────────────
|
||||
app.use('/lab/:token', async (req, res, next) => {
|
||||
const { token } = req.params;
|
||||
|
||||
Generated
+1574
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 3B — Learning & Assessment Engine acceptance test. Covers attempt telemetry,
|
||||
// progressive hints, curriculum stage/capstone unlock rules, the exam engine (random draw,
|
||||
// timer, expiry, restart persistence), the capstone workflow end to end, two-student isolation,
|
||||
// and instructor learning analytics/RBAC. Runs against a live platform/docker-compose.dev.yml
|
||||
// stack — slow (real k3s boots + a real API restart). No framework, matching project
|
||||
// convention.
|
||||
//
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase3b.js
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('./session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const API = process.env.PLATFORM_API_URL || 'http://localhost:4300';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
const SKIP_RESTART_TEST = process.env.SKIP_RESTART_TEST === '1';
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
const RUN_ID = Date.now();
|
||||
const COHORT_ID = `e2e-3b-${RUN_ID}`;
|
||||
|
||||
const FOUNDATION = ['pod-basics-mcq', 'kubectl-essentials-mcq', 'labels-selectors-mcq', 'init-containers-mcq', 'namespaces-basics',
|
||||
'multi-container-pod-basics', 'pod-labeling-basics', 'jsonpath-basics', 'dry-run-manifest-basics', 'delete-by-label-basics',
|
||||
'annotate-resource-basics', 'init-container-task-basics', 'restart-policy-mcq', 'kubectl-output-format-mcq',
|
||||
'kubectl-explain-basics', 'kubectl-patch-basics', 'field-selector-basics', 'sort-by-basics', 'kubectl-diff-basics', 'static-pods-mcq'];
|
||||
const WORKLOADS = ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq'];
|
||||
const NETWORKING = ['services-mcq', 'expose-service-basics'];
|
||||
const CONFIGURATION = ['configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline'];
|
||||
const TROUBLESHOOTING = ['extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq'];
|
||||
const REAL_OPS = ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-probe-port-mismatch', 'rootnode-enforce-limits'];
|
||||
const ALL_NON_FOUNDATION = [...WORKLOADS, ...NETWORKING, ...CONFIGURATION, ...TROUBLESHOOTING, ...REAL_OPS];
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.message}`); failed++; }
|
||||
}
|
||||
|
||||
async function complete(session, scenarioId, envId) {
|
||||
const { status } = await session.request('POST', '/api/progress/record', { scenario_id: scenarioId, status: 'completed', environment_id: envId });
|
||||
assert.equal(status, 200, `completing ${scenarioId} should 200`);
|
||||
}
|
||||
async function fail(session, scenarioId, envId) {
|
||||
const { status } = await session.request('POST', '/api/progress/record', { scenario_id: scenarioId, status: 'in_progress', environment_id: envId });
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 3B learning/assessment acceptance test — slow, real k3s boots + a real API restart\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
|
||||
await test('setup: cohort + two students + Prepare Labs', async () => {
|
||||
assert.equal((await owner.request('POST', '/api/admin/cohorts', { cohort_id: COHORT_ID, name: 'E2E 3B' })).status, 201);
|
||||
const a = await owner.request('POST', '/api/admin/students', { display_name: `E2E-3B-Alice-${RUN_ID}`, cohort_id: COHORT_ID });
|
||||
const b = await owner.request('POST', '/api/admin/students', { display_name: `E2E-3B-Bob-${RUN_ID}`, cohort_id: COHORT_ID });
|
||||
global.__alice = a.body.student_id; global.__bob = b.body.student_id;
|
||||
const { status, body } = await owner.request('POST', `/api/admin/cohorts/${COHORT_ID}/prepare-labs`);
|
||||
assert.equal(status, 200);
|
||||
assert.ok(body.results.every((r) => r.status === 'running'), `expected all running: ${JSON.stringify(body.results)}`);
|
||||
});
|
||||
|
||||
const alice = makeSession(GW);
|
||||
const bob = makeSession(GW);
|
||||
let aliceEnv, bobEnv;
|
||||
await test('students log in and launch their prepared labs', async () => {
|
||||
await alice.request('POST', '/api/login', { student_id: global.__alice });
|
||||
await bob.request('POST', '/api/login', { student_id: global.__bob });
|
||||
const a = await alice.request('POST', '/api/environment/launch');
|
||||
const b = await bob.request('POST', '/api/environment/launch');
|
||||
assert.equal(a.body.status, 'running'); assert.equal(b.body.status, 'running');
|
||||
aliceEnv = a.body.launch_path.split('/')[2];
|
||||
bobEnv = b.body.launch_path.split('/')[2];
|
||||
// resolve environment_id via the admin view (routing token isn't the environment_id)
|
||||
const aEnvRow = (await owner.request('GET', `/api/admin/students/${global.__alice}/environment`)).body.find((e) => e.status === 'running');
|
||||
const bEnvRow = (await owner.request('GET', `/api/admin/students/${global.__bob}/environment`)).body.find((e) => e.status === 'running');
|
||||
global.__aliceEnvId = aEnvRow.environment_id; global.__bobEnvId = bEnvRow.environment_id;
|
||||
});
|
||||
|
||||
// ── Attempt telemetry ────────────────────────────────────────────────────────────────────
|
||||
await test('learning records: start, fail, pass — telemetry counters correct', async () => {
|
||||
const sid = FOUNDATION[0];
|
||||
await alice.request('POST', `/api/scenarios/${sid}/start`, { environment_id: global.__aliceEnvId });
|
||||
await fail(alice, sid, global.__aliceEnvId);
|
||||
await fail(alice, sid, global.__aliceEnvId);
|
||||
await complete(alice, sid, global.__aliceEnvId);
|
||||
const { body } = await alice.request('GET', `/api/scenarios/${sid}/attempt?environment_id=${global.__aliceEnvId}`);
|
||||
assert.equal(body.attempt.status, 'completed');
|
||||
assert.equal(body.attempt.validation_attempt_count, 3);
|
||||
assert.equal(body.attempt.validation_pass_count, 1);
|
||||
assert.equal(body.attempt.validation_fail_count, 2);
|
||||
assert.ok(body.attempt.duration_seconds !== null);
|
||||
});
|
||||
|
||||
// ── Progressive hints ────────────────────────────────────────────────────────────────────
|
||||
await test('hints: sequential reveal, exhaustion, and persistence after Lab reset', async () => {
|
||||
const sid = 'namespaces-basics'; // has 2 hints — needed to exercise sequential reveal past level 1
|
||||
await alice.request('POST', `/api/scenarios/${sid}/start`, { environment_id: global.__aliceEnvId });
|
||||
const h1 = await alice.request('POST', `/api/scenarios/${sid}/hint`, { environment_id: global.__aliceEnvId });
|
||||
assert.equal(h1.status, 200); assert.equal(h1.body.level, 1);
|
||||
const h2 = await alice.request('POST', `/api/scenarios/${sid}/hint`, { environment_id: global.__aliceEnvId });
|
||||
assert.equal(h2.body.level, 2);
|
||||
// Lab reset must not erase attempt/hint state (Phase 3B §9) — the attempt lives in Platform
|
||||
// Postgres, keyed by environment_id, which survives a reset (same env row, new container).
|
||||
const resetRes = await alice.request('POST', '/api/environment/reset');
|
||||
assert.equal(resetRes.body.status, 'running');
|
||||
const { body: attemptAfterReset } = await alice.request('GET', `/api/scenarios/${sid}/attempt?environment_id=${global.__aliceEnvId}`);
|
||||
assert.equal(attemptAfterReset.attempt.highest_hint_level, 2);
|
||||
assert.equal(attemptAfterReset.attempt.hint_count, 2);
|
||||
});
|
||||
|
||||
// ── Curriculum completion + stage unlock ────────────────────────────────────────────────
|
||||
await test('curriculum: stage locked until prerequisites, unlocks on completion', async () => {
|
||||
const before = await alice.request('GET', '/api/curriculum/state');
|
||||
assert.equal(before.body.stages.find((s) => s.id === 'workloads').locked, true);
|
||||
for (const sid of FOUNDATION) await complete(alice, sid, global.__aliceEnvId);
|
||||
const after = await alice.request('GET', '/api/curriculum/state');
|
||||
assert.equal(after.body.stages.find((s) => s.id === 'foundation').complete, true);
|
||||
assert.equal(after.body.stages.find((s) => s.id === 'workloads').locked, false);
|
||||
assert.equal(after.body.current_stage, 'workloads');
|
||||
});
|
||||
|
||||
await test('achievements: stage_completed recorded for Foundation', async () => {
|
||||
const { body } = await alice.request('GET', '/api/achievements');
|
||||
assert.ok(body.some((a) => a.achievement_type === 'stage_completed' && a.stage_id === 'foundation'));
|
||||
});
|
||||
|
||||
// ── Capstone lock/unlock ─────────────────────────────────────────────────────────────────
|
||||
await test('capstone: locked before all stages complete', async () => {
|
||||
const { body } = await alice.request('GET', '/api/capstone');
|
||||
assert.equal(body.status, 'locked');
|
||||
assert.ok(body.blocking_stage_ids.length > 0);
|
||||
});
|
||||
|
||||
await test('capstone: unlocks once every prerequisite stage is complete', async () => {
|
||||
for (const sid of ALL_NON_FOUNDATION) await complete(alice, sid, global.__aliceEnvId);
|
||||
const { body } = await alice.request('GET', '/api/capstone');
|
||||
assert.equal(body.status, 'unlocked');
|
||||
assert.equal(body.blocking_stage_ids.length, 0);
|
||||
});
|
||||
|
||||
let capstoneScenarios;
|
||||
await test('capstone: start draws a scenario set featuring Real Ops prominently', async () => {
|
||||
const { status, body } = await alice.request('POST', '/api/assessments/rootnode-capstone/start');
|
||||
assert.equal(status, 201);
|
||||
capstoneScenarios = body.scenarios.map((s) => s.scenario_id);
|
||||
assert.equal(capstoneScenarios.length, 7); // 4 required Real Ops + 3 random
|
||||
for (const req of REAL_OPS) assert.ok(capstoneScenarios.includes(req), `capstone draw missing required ${req}`);
|
||||
});
|
||||
|
||||
await test('capstone: cannot start a second assessment while one is active', async () => {
|
||||
const { status, body } = await alice.request('POST', '/api/assessments/rootnode-quick-assessment/start');
|
||||
assert.equal(status, 409); assert.equal(body.error, 'already_active');
|
||||
});
|
||||
|
||||
await test('capstone: completing all drawn scenarios finalizes the session and records an achievement', async () => {
|
||||
for (const sid of capstoneScenarios) await complete(alice, sid, global.__aliceEnvId);
|
||||
const { body } = await alice.request('GET', '/api/capstone');
|
||||
assert.equal(body.status, 'completed');
|
||||
const ach = await alice.request('GET', '/api/achievements');
|
||||
assert.ok(ach.body.some((a) => a.achievement_type === 'capstone_completed'));
|
||||
});
|
||||
|
||||
// ── Exam engine (Bob) ────────────────────────────────────────────────────────────────────
|
||||
let examSessionId, examScenarios, examExpiresAt;
|
||||
await test('exam: assign to cohort, start, random draw matches the definition\'s pool spec', async () => {
|
||||
assert.equal((await owner.request('POST', '/api/admin/assessments/rootnode-quick-assessment/assign', { cohort_id: COHORT_ID })).status, 200);
|
||||
const { status, body } = await bob.request('POST', '/api/assessments/rootnode-quick-assessment/start');
|
||||
assert.equal(status, 201);
|
||||
examSessionId = body.assessment_session_id; examScenarios = body.scenarios.map((s) => s.scenario_id); examExpiresAt = body.expires_at;
|
||||
assert.equal(examScenarios.length, 5); // 2 + 1 + 1 + 1 per the seeded pool_spec
|
||||
assert.ok(examScenarios.some((s) => REAL_OPS.includes(s)));
|
||||
});
|
||||
|
||||
await test('exam: student sees own active assessment with a live timer', async () => {
|
||||
const { body } = await bob.request('GET', '/api/assessments/active');
|
||||
assert.equal(body.active, true);
|
||||
assert.equal(body.assessment_session_id, examSessionId);
|
||||
});
|
||||
|
||||
await test('exam: hints are disabled per the definition\'s hint_policy', async () => {
|
||||
const sid = examScenarios[0];
|
||||
await bob.request('POST', `/api/scenarios/${sid}/start`, { environment_id: global.__bobEnvId });
|
||||
const { status, body } = await bob.request('POST', `/api/scenarios/${sid}/hint`, { environment_id: global.__bobEnvId });
|
||||
assert.equal(status, 403); assert.equal(body.error, 'hints_disabled');
|
||||
});
|
||||
|
||||
await test('security: a student cannot read another student\'s assessment session', async () => {
|
||||
const { status } = await alice.request('GET', `/api/assessments/${examSessionId}`);
|
||||
assert.equal(status, 404); // exists, but not owned by Alice — same "don't confirm existence" pattern as RBAC 403s
|
||||
});
|
||||
|
||||
await test('two students: distinct sessions, distinct draws, no cross-contamination', async () => {
|
||||
assert.notEqual(examSessionId, undefined);
|
||||
const aliceActive = await alice.request('GET', '/api/assessments/active');
|
||||
assert.equal(aliceActive.body.active, false); // Alice already finished her capstone
|
||||
assert.deepEqual([...examScenarios].sort(), [...new Set(examScenarios)].sort()); // no duplicate scenario ids in Bob's draw
|
||||
});
|
||||
|
||||
await test('exam: completing every drawn scenario finalizes the session', async () => {
|
||||
for (const sid of examScenarios) await complete(bob, sid, global.__bobEnvId);
|
||||
const { body } = await bob.request('GET', `/api/assessments/${examSessionId}`);
|
||||
assert.equal(body.status, 'completed');
|
||||
assert.equal(body.result.scenarios_passed, examScenarios.length);
|
||||
const ach = await bob.request('GET', '/api/achievements');
|
||||
assert.ok(ach.body.some((a) => a.achievement_type === 'assessment_completed'));
|
||||
});
|
||||
|
||||
// ── Timer integrity: a fresh short exam, verify Lab reset doesn't touch the timer ─────────
|
||||
let shortExamId, shortExamExpiry;
|
||||
await test('timer: Lab reset mid-assessment does not change expires_at', async () => {
|
||||
await owner.request('POST', '/api/admin/assessments', {
|
||||
id: `e2e-timer-${RUN_ID}`, type: 'exam', title: 'Timer Test', duration_minutes: 30,
|
||||
hint_policy: 'free', pool_spec: { required: [FOUNDATION[2]], random: [] },
|
||||
});
|
||||
await owner.request('POST', `/api/admin/assessments/e2e-timer-${RUN_ID}/assign`, { cohort_id: COHORT_ID });
|
||||
const start = await bob.request('POST', `/api/assessments/e2e-timer-${RUN_ID}/start`);
|
||||
shortExamId = start.body.assessment_session_id; shortExamExpiry = start.body.expires_at;
|
||||
await bob.request('POST', '/api/environment/reset');
|
||||
const { body } = await bob.request('GET', `/api/assessments/${shortExamId}`);
|
||||
assert.equal(body.expires_at, shortExamExpiry, 'timer must be Platform-authoritative, unaffected by Lab recreation');
|
||||
assert.equal(body.status, 'in_progress');
|
||||
});
|
||||
|
||||
// ── Session persistence across a real restart ────────────────────────────────────────────
|
||||
if (!SKIP_RESTART_TEST) {
|
||||
await test('assessment session survives a real Platform API restart', async () => {
|
||||
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: __dirname + '/..', stdio: 'pipe' });
|
||||
let up = false;
|
||||
for (let i = 0; i < 30 && !up; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
try { up = (await fetch(`${API}/api/roster/cohorts`)).ok; } catch { /* still restarting */ }
|
||||
}
|
||||
assert.ok(up, 'Platform API did not come back up in time');
|
||||
const { body } = await bob.request('GET', `/api/assessments/${shortExamId}`);
|
||||
assert.equal(body.status, 'in_progress');
|
||||
assert.equal(body.expires_at, shortExamExpiry);
|
||||
});
|
||||
} else {
|
||||
console.log(' (skipped) assessment session survives a real Platform API restart — SKIP_RESTART_TEST=1');
|
||||
}
|
||||
|
||||
// ── Instructor analytics + RBAC ──────────────────────────────────────────────────────────
|
||||
await test('instructor: cohort learning summary reflects real completion data', async () => {
|
||||
const { body } = await owner.request('GET', `/api/admin/cohorts/${COHORT_ID}/learning`);
|
||||
assert.equal(body.student_count, 2);
|
||||
// Only Alice worked the curriculum path — Bob only ever did the exam (drawn from
|
||||
// workloads/networking/troubleshooting/real-ops, never Foundation) — so this also confirms
|
||||
// completion counts are genuinely per-student, not just "any activity happened."
|
||||
assert.equal(body.stages.find((s) => s.id === 'foundation').completedBy, 1);
|
||||
});
|
||||
|
||||
await test('instructor: student detail shows attempts, hints, assessment history', async () => {
|
||||
const { body } = await owner.request('GET', `/api/admin/students/${global.__alice}/learning`);
|
||||
assert.ok(body.attempts.length > 0);
|
||||
assert.ok(body.assessment_sessions.some((s) => s.type === 'capstone' && s.status === 'completed'));
|
||||
});
|
||||
|
||||
await test('instructor: needs-attention returns reasonable signals, no punitive labels', async () => {
|
||||
const { body } = await owner.request('GET', `/api/admin/cohorts/${COHORT_ID}/needs-attention`);
|
||||
assert.ok(Array.isArray(body));
|
||||
assert.ok(body.every((s) => !/bad|fail(ed)? student|stupid/i.test(s.label)));
|
||||
});
|
||||
|
||||
await test('RBAC: a plain instructor is scoped to their own cohort for learning routes', async () => {
|
||||
const inst = await owner.request('POST', '/api/admin/instructors', { email: `teach3b-${RUN_ID}@rootnode.test`, display_name: 'Teach 3B', role: 'instructor', password: 'TeacherPassword3B1' });
|
||||
const otherCohort = `e2e-3b-other-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/cohorts', { cohort_id: otherCohort, name: 'Other' });
|
||||
const teacher = makeSession(GW);
|
||||
await teacher.request('POST', '/api/auth/instructor/login', { email: `teach3b-${RUN_ID}@rootnode.test`, password: 'TeacherPassword3B1' });
|
||||
assert.equal((await teacher.request('GET', `/api/admin/cohorts/${otherCohort}/learning`)).status, 403);
|
||||
await owner.request('POST', `/api/admin/cohorts/${COHORT_ID}/instructors`, { instructor_id: inst.body.instructor_id });
|
||||
assert.equal((await teacher.request('GET', `/api/admin/cohorts/${COHORT_ID}/learning`)).status, 200);
|
||||
});
|
||||
|
||||
await test('regression: no Lab container publishes a host port', async () => {
|
||||
const { body: envs } = await owner.request('GET', `/api/admin/students/${global.__alice}/environment`);
|
||||
assert.ok(envs.every((e) => !e.internal_port || e.internal_port === 80));
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('Test run crashed:', e); process.exit(1); });
|
||||
@@ -2,11 +2,13 @@
|
||||
"id": "rootnode-path",
|
||||
"name": "RootNode Learning Path",
|
||||
"tagline": "The guided route through RootNode Academy — one stage at a time.",
|
||||
"version": 1,
|
||||
"stages": [
|
||||
{
|
||||
"id": "foundation",
|
||||
"name": "Foundation",
|
||||
"icon": "🌱",
|
||||
"completion_rule": { "type": "all_required" },
|
||||
"scenario_ids": [
|
||||
"pod-basics-mcq",
|
||||
"kubectl-essentials-mcq",
|
||||
@@ -34,6 +36,7 @@
|
||||
"id": "workloads",
|
||||
"name": "Workloads",
|
||||
"icon": "📦",
|
||||
"completion_rule": { "type": "all_required" },
|
||||
"scenario_ids": [
|
||||
"deploy-nginx",
|
||||
"scale-deployment",
|
||||
@@ -49,6 +52,7 @@
|
||||
"id": "networking",
|
||||
"name": "Networking",
|
||||
"icon": "🔌",
|
||||
"completion_rule": { "type": "all_required" },
|
||||
"scenario_ids": [
|
||||
"services-mcq",
|
||||
"expose-service-basics"
|
||||
@@ -58,6 +62,7 @@
|
||||
"id": "configuration",
|
||||
"name": "Configuration",
|
||||
"icon": "⚙️",
|
||||
"completion_rule": { "type": "all_required" },
|
||||
"scenario_ids": [
|
||||
"configmap-volume",
|
||||
"resource-limits-task",
|
||||
@@ -70,6 +75,7 @@
|
||||
"id": "troubleshooting",
|
||||
"name": "Troubleshooting",
|
||||
"icon": "🔍",
|
||||
"completion_rule": { "type": "all_required" },
|
||||
"scenario_ids": [
|
||||
"extract-logs-basics",
|
||||
"kubectl-cp-basics",
|
||||
@@ -83,6 +89,7 @@
|
||||
"name": "RootNode: Real Ops",
|
||||
"icon": "🌳",
|
||||
"bundle_id": "rootnode-real-ops",
|
||||
"completion_rule": { "type": "all_required" },
|
||||
"scenario_ids": [
|
||||
"rootnode-runaway-monitor",
|
||||
"broken-deployment",
|
||||
@@ -94,7 +101,9 @@
|
||||
"id": "capstone",
|
||||
"name": "Capstone",
|
||||
"icon": "🏁",
|
||||
"locked": true,
|
||||
"type": "capstone",
|
||||
"capstone_assessment_id": "rootnode-capstone",
|
||||
"unlock_rule": { "type": "stages_complete", "stage_ids": ["foundation", "workloads", "networking", "configuration", "troubleshooting", "real-ops"] },
|
||||
"scenario_ids": []
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user