Compare commits

...
12 Commits
Author SHA1 Message Date
ced4568 e31b051f4e feat: update logo.svg for RootNode Academy branding 2026-06-21 03:34:56 -05:00
ced4568 f7cdb48833 feat: rebrand to RootNode Academy + add Real Ops scenario bundle
- Rename app title, header, tagline, footer, terminal tab to RootNode Academy
- Apply RootNode color palette (#39FF14 green, #00D4FF blue, GitHub-style darks)
- Add rootnode-runaway-monitor scenario (homelab NOC incident: oversized memory request)
- Add rootnode-real-ops bundle in terminal-green accent
- Update package name to rootnode-academy-ui
2026-06-21 03:27:46 -05:00
Abhinav Sinha d42aa779b1 feat(scenarios): add 10 new Kubernetes Basics scenarios
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-20 23:54:52 +05:30
Abhinav Sinha 6c3843cb90 fix(cluster): add API stability verification and k3s watchdog
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-19 22:11:43 +05:30
Abhinav Sinha ff1c61812d docs: update README.md with new screenshots
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-18 12:18:37 +05:30
Abhinav Sinha f2d535837c fix: remove reset button from scenario panel in exam mode
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-18 02:00:21 +05:30
Abhinav Sinha ec27b01531 refactor: replace window alerts with modals
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-18 01:49:10 +05:30
Abhinav Sinha 35dfeb7208 feat: add focus mode to maximize scenario panel
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-18 01:30:39 +05:30
Abhinav Sinha 09bd1b20da refactor(exam): flat sidebar list, weight badges, UI cleanup
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-18 01:11:19 +05:30
Abhinav Sinha 7e75c8ae02 fix: hide difficulty from scenario ui in exam mode
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-17 16:33:14 +05:30
Abhinav Sinha 935ab38f28 fix: ordered bundles sequence by id
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-17 16:23:44 +05:30
Abhinav Sinha 170b95aa35 fix: kubectl bash completion
Signed-off-by: Abhinav Sinha <[email protected]>
2026-06-17 15:09:39 +05:30
46 changed files with 1452 additions and 287 deletions
+106 -119
View File
@@ -1,29 +1,41 @@
<div align="center"> <div align="center">
<img src="frontend/public/logo.svg" alt="KubeKosh Logo" width="100" />
<h1>KubeKosh</h1> <img src="frontend/public/logo.svg" alt="KubeKosh" height="96" />
<p><strong>Self-hosted Kubernetes Lab for Hands-on Learning</strong></p> # KubeKosh — RootNode Academy Edition
**A browser-based Kubernetes lab for hands-on learners.**
Real K3s. Real `kubectl`. Instant validation. No cloud account, no local cluster.
[![License](https://img.shields.io/badge/license-Apache%202.0-blue?style=flat-square)](LICENSE)
[![Platforms](https://img.shields.io/badge/platforms-amd64%20%7C%20arm64-lightgrey?style=flat-square)](#)
[![Kubernetes](https://img.shields.io/badge/Kubernetes-K3s-326CE5?style=flat-square&logo=kubernetes&logoColor=white)](https://k3s.io)
[![Docker](https://img.shields.io/badge/Docker-required-2496ED?style=flat-square&logo=docker&logoColor=white)](https://docs.docker.com/get-docker/)
[![Maintained by ced4568](https://img.shields.io/badge/maintained%20by-ced4568-181717?style=flat-square&logo=github)](https://github.com/ced4568)
<p>
<a href="https://hub.docker.com/r/zeborg/kubekosh"><img src="https://img.shields.io/docker/pulls/zeborg/kubekosh?style=flat-square&logo=docker&label=Docker%20Hub" alt="Docker Hub" /></a>
<img src="https://img.shields.io/badge/license-Apache%202.0-blue?style=flat-square" alt="License" />
<img src="https://img.shields.io/badge/platforms-amd64%20%7C%20arm64-lightgrey?style=flat-square" alt="Platforms" />
</p>
</div> </div>
--- ---
KubeKosh runs a real [K3s](https://k3s.io/) Kubernetes cluster inside a single Docker container and pairs it with a browser-based terminal and automated scenario validation — no cloud account or local cluster required. > **Attribution.** This is a branded, modified fork of the excellent
> [`zeborg/kubekosh`](https://github.com/zeborg/kubekosh), licensed under Apache 2.0.
> RootNode Academy adds custom scenarios, branding, and multi-student deployment tooling on
> top of the upstream project. Original copyright and license notices are preserved in
> [`LICENSE`](LICENSE). Modified files are marked per Apache 2.0 §4. Full credit to
> the original author for the core platform.
## Screenshots ---
| | | ## What It Is
|---|---|
| ![Scenario browser with live terminal](screenshots/1.png) | ![Task scenario with problem statement](screenshots/2.png) | KubeKosh runs a real single-node [K3s](https://k3s.io) cluster inside one Docker
| ![Contextual hints with copy-ready commands](screenshots/3.png) | ![Automated validation — all checks passed](screenshots/4.png) | container and pairs it with a browser terminal and automated scenario validation.
| ![Exam mode — start with custom duration](screenshots/5.png) | ![Exam mode - live exam with timer](screenshots/6.png) | A learner opens a tab, gets a live cluster, runs real `kubectl`, and clicks **Validate**
| ![Multiple-choice question view](screenshots/7.png) | ![MCQ with correct answer and explanation](screenshots/8.png) | to check their work against actual cluster state.
In the RootNode Academy curriculum this is the **capstone rung** — the Kubernetes & DevOps
track that sits on top of the Linux / Git / Docker foundations. It is aimed at the
advanced learner (roughly 14+), not the absolute beginner.
--- ---
@@ -32,23 +44,25 @@ KubeKosh runs a real [K3s](https://k3s.io/) Kubernetes cluster inside a single D
**Prerequisite:** [Docker](https://docs.docker.com/get-docker/) **Prerequisite:** [Docker](https://docs.docker.com/get-docker/)
```bash ```bash
docker run -itd --name kubekosh --privileged -p 7554:80 zeborg/kubekosh:latest docker run -itd --name kubekosh --privileged -p 7554:80 ced4568/kubekosh:latest
``` ```
Open **http://localhost:7554** wait ~30 seconds for the *Cluster Ready* indicator to turn green. Open **http://localhost:7554** and wait ~30s for the *Cluster Ready* indicator to turn green.
> `--privileged` is required — K3s needs access to kernel namespaces and cgroups. > `--privileged` is required — K3s needs kernel namespaces and cgroups.
> **Do not expose this container directly to the public internet.** It is a teaching
> **Security Warning:** Do **not** expose this container publicly. Use it only on your local machine as it is meant for educational purposes only. > sandbox. Multi-student exposure is handled by the deployment layer below, not by
> publishing the raw container.
### Persist Progress ### Persist Progress
```bash ```bash
docker run -itd --name kubekosh --privileged -p 7554:80 \ docker run -itd --name kubekosh --privileged -p 7554:80 \
-v <your_custom_directory>:/data zeborg/kubekosh:latest -v <host_dir>:/data ced4568/kubekosh:latest
``` ```
Progress is stored in SQLite at `/data/progress.db` inside the container. Mount a local directory to `/data` to keep progress across container restarts. Progress is stored in SQLite at `/data/progress.db`. Mount a host directory to `/data`
to keep progress across restarts — one volume per student.
### Build From Source ### Build From Source
@@ -63,53 +77,72 @@ docker buildx build --platform linux/amd64,linux/arm64 -t kubekosh .
## What's Inside ## What's Inside
| Bundle | Focus | Exam Mode | | Bundle | Focus | Exam Mode |
|---|---|---| | ------ | ----- | --------- |
| 🌱 Kubernetes Basics | Core concepts | 60 min | | 🌱 Kubernetes Basics | Core concepts | 60 min |
| 🧑‍✈️ Kubernetes Administrator | CKA | 120 min | | 🧑‍✈️ Kubernetes Administrator | CKA | 120 min |
| 🛠️ Kubernetes Developer | CKAD | 120 min | | 🛠️ Kubernetes Developer | CKAD | 120 min |
| 🛡️ Kubernetes Security | CKS | 120 min | | 🛡️ Kubernetes Security | CKS | 120 min |
**Scenario types:** **Scenario types**
- **Task** — Hands-on challenge in the live terminal. Click **Validate** for automated cluster-state checking.
- **MCQ** — Multiple-choice question with a detailed explanation on submission.
### Shell Aliases - **Task** — hands-on challenge in the live terminal; **Validate** auto-checks cluster state.
- **MCQ** — multiple choice with a detailed explanation on submission.
The terminal comes pre-configured with: Custom RootNode Academy scenarios live alongside upstream ones in `scenarios/data/` and are
grouped into bundles in `scenarios/bundles/`. See [`scenarios/SCHEMA.md`](scenarios/SCHEMA.md).
| Alias | Expands to |
|---|---|
| `k` | `kubectl` |
| `kg` | `kubectl get` |
| `kd` | `kubectl describe` |
| `krm` | `kubectl delete` |
| `kgp` | `kubectl get pods` |
| `kga` | `kubectl get pods --all-namespaces` |
| `kgd` | `kubectl get deployments` |
| `kgs` | `kubectl get services` |
| `kgn` | `kubectl get nodes` |
| `kgns` | `kubectl get namespaces` |
| `kdp` | `kubectl describe pod` |
| `kaf` | `kubectl apply -f` |
| `kdf` | `kubectl delete -f` |
| `kex` | `kubectl exec -it` |
| `klogs` | `kubectl logs` |
| `kns <ns>` | `kubectl config set-context --current --namespace=<ns>` |
| `kctx <ctx>` | `kubectl config use-context <ctx>` |
--- ---
## Architecture ## Architecture
```mermaid
flowchart TD
subgraph Container["Single Docker Image (--privileged)"]
NGINX["nginx :80 — reverse proxy"]
FE["Frontend<br/>React + Vite + xterm.js"]
BE["Backend<br/>Node / Express + node-pty (WebSocket PTY)"]
K3S["K3s<br/>single-node in-container cluster"]
DB[("SQLite<br/>/data/progress.db")]
NGINX --> FE
NGINX --> BE
BE -->|kubectl / PTY| K3S
BE --> DB
end
Student["Learner browser"] -->|:7554| NGINX
```
| Component | Technology | | Component | Technology |
|---|---| | --------- | ---------- |
| Frontend | React + Vite, `xterm.js` | | Frontend | React + Vite, `xterm.js` |
| Backend | Node.js / Express, `node-pty` WebSocket PTY | | Backend | Node.js / Express, `node-pty` WebSocket PTY |
| Cluster | K3s (single-node, in-container) | | Cluster | K3s (single-node, in-container) |
| Proxy | nginx on container port `80`, mapped to host port `7554` | | Proxy | nginx on port `80`, mapped to host `7554` |
| Storage | SQLite (`better-sqlite3`) at `/data/progress.db` | | Storage | SQLite (`better-sqlite3`) at `/data/progress.db` |
Everything runs inside a **single Docker image** managed by `scripts/entrypoint.sh`. Everything runs inside one image managed by `scripts/entrypoint.sh`.
---
## Multi-Student Deployment (RootNode Academy addition)
The upstream project assumes one learner, one container. RootNode Academy runs it across three
delivery modes. **Never publish the raw privileged container** — front it with isolation.
| Mode | Provisioning | Isolation |
| ---- | ------------ | --------- |
| In-person camp | Pre-spun container per seat, torn down after | Lab VLAN, no public ingress |
| Live cohort | Container per enrolled student for the session window | Per-student auth at the proxy |
| Self-paced | On-demand spin-up per session, idle-timeout teardown | Per-user volume + auth + resource limits |
Hardening checklist for shared infra:
- One container **per student** — never share a cluster between learners.
- Apply CPU/memory limits so one runaway pod can't starve a host.
- Put authentication in front (proxy-level), never expose `:7554` to the internet.
- Persist each student's `/data` to a dedicated volume.
- Idle-timeout + auto-teardown for self-paced sessions to control cost.
--- ---
@@ -117,88 +150,42 @@ Everything runs inside a **single Docker image** managed by `scripts/entrypoint.
``` ```
scenarios/ scenarios/
├── data/ # One JSON file per scenario -> <scenario-id>.json ├── data/ # one JSON file per scenario -> <scenario-id>.json
├── bundles/ # One JSON file per bundle -> <bundle-id>.json ├── bundles/ # one JSON file per bundle -> <bundle-id>.json
└── SCHEMA.md # Full schema reference └── SCHEMA.md # full schema reference
backend/ backend/ server.js # Express API + WebSocket PTY
└── server.js # Express API + WebSocket PTY frontend/ src/ # React + Vite SPA
scripts/ entrypoint.sh # container startup (k3s -> API -> nginx)
frontend/ nginx.conf # reverse-proxy config
└── src/ # React + Vite SPA
scripts/
├── entrypoint.sh # Container startup (k3s -> API -> nginx)
└── nginx.conf # Reverse-proxy config
``` ```
--- ---
## Contributing ## Authoring Scenarios
Contributions are what make open-source projects like this one grow — and every contribution counts, big or small. Whether you're fixing a typo, polishing a scenario description, or building a completely new exercise from scratch, you're helping the next person learn Kubernetes in the best way possible. **Thank you for taking the time!** Each scenario is one JSON file in `scenarios/data/`; each bundle is one JSON file in
`scenarios/bundles/`. See [`scenarios/SCHEMA.md`](scenarios/SCHEMA.md) for the full schema.
### Adding Scenarios **Task checklist**
- `validation.commands` — idempotent `kubectl` only
- `setup_commands` / `teardown_commands``kubectl` or native Ubuntu only
Each scenario is a single JSON file in `scenarios/data` directory; each bundle is a single JSON file in `scenarios/bundles` directory. See [`scenarios/SCHEMA.md`](scenarios/SCHEMA.md) for the full schema. **MCQ checklist**
- `correct_option` must match an `options[].id`
- always include an `explanation`
**Task checklist:** Hot-reload while authoring (no rebuild):
- `validation.commands` — idempotent `kubectl` commands only
- `setup_commands` / `teardown_commands``kubectl` or native Ubuntu commands only
**MCQ checklist:**
- `correct_option` must match one of the `options[].id` values
- Always include an `explanation`
### In-Memory Cache & Hot Reloading
To ensure high performance and zero disk-I/O bottlenecking, scenarios and bundles are cached in memory on backend startup. When developing or updating scenarios, you can hot-reload the definitions without rebuilding the image or restarting the container:
1. **Mount Scenarios Directory:** Run the container with the local `scenarios/` directory mounted to `/app/scenarios`:
```bash
docker run --rm -itd --privileged -p 7554:80 --name kubekosh -v <path_to_scenarios_directory>:/app/scenarios zeborg/kubekosh:latest
```
2. **Reload Cache:** Click the **Reload Scenario Cache** (↻) button in the top right corner of the header in the web user interface, or send an API request:
```bash ```bash
docker run --rm -itd --privileged -p 7554:80 --name kubekosh \
-v $PWD/scenarios:/app/scenarios ced4568/kubekosh:latest
curl -X POST http://localhost:7554/api/cache/reload curl -X POST http://localhost:7554/api/cache/reload
``` ```
> **NOTE:**
> The content in `<path_to_scenarios_directory>` should be the path to the local `scenarios/` directory of the cloned repository with your updates, i.e., it should contain the updated `scenarios/data` and `scenarios/bundles` directories.
### Workflow
```bash
# 1. Fork the repo on GitHub, then clone your fork
git clone https://github.com/<your-username>/kubekosh.git
cd kubekosh
# 2. Create a branch
git checkout -b feat/my-scenario
# 3. Add a new scenario file (copy an existing scenario as a template or create a new one)
cp scenarios/data/deploy-nginx.json scenarios/data/my-new-scenario.json
vim scenarios/data/my-new-scenario.json # edit the new scenario as per [SCHEMA.md](scenarios/SCHEMA.md)
# 4. Add the scenario ID to the relevant bundle
vim scenarios/bundles/k8s-basics.json # edit the bundle to include the new scenario ID
# 5. Build and test locally
# Run the built container directly:
docker build -t kubekosh . && docker run --rm -itd --privileged -p 7554:80 --name kubekosh kubekosh
# Or mount the scenarios folder for hot-reloading:
docker run --rm -itd --privileged -p 7554:80 -v $PWD/scenarios:/app/scenarios --name kubekosh zeborg/kubekosh:dev
# 6. Commit and push to your fork (example for adding `my-new-scenario` to `k8s-basics` bundle)
git add scenarios/data/my-new-scenario.json scenarios/bundles/k8s-basics.json
git commit -m "feat: add my-new-scenario to k8s-basics bundle"
git push -u origin feat/my-new-scenario
```
Open a Pull Request from your fork's branch against `main`.
--- ---
## License ## License
Apache 2.0 License — see [LICENSE](LICENSE). Apache 2.0 — see [`LICENSE`](LICENSE). Original work © the `zeborg/kubekosh` authors.
Modifications © RootNode Academy / Chase Dumphord (ced4568).
+72 -20
View File
@@ -64,10 +64,21 @@ function getDb() {
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
exam_minutes INTEGER NOT NULL DEFAULT 120, exam_minutes INTEGER NOT NULL DEFAULT 120,
duration_secs INTEGER, duration_secs INTEGER,
snapshot TEXT snapshot TEXT,
scenario_ids TEXT
) )
`); `);
// Migrate: add scenario_ids column if missing
try {
const cols = _db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name);
if (!cols.includes('scenario_ids')) {
_db.exec(`ALTER TABLE sessions ADD COLUMN scenario_ids TEXT`);
}
} catch (e) {
console.error('Failed to migrate sessions table:', e.message);
}
// Separate exam-session progress table — tracks completions per session // Separate exam-session progress table — tracks completions per session
_db.exec(` _db.exec(`
CREATE TABLE IF NOT EXISTS exam_progress ( CREATE TABLE IF NOT EXISTS exam_progress (
@@ -249,20 +260,35 @@ app.post('/api/progress/reset', (req, res) => {
// ── Exam sessions ───────────────────────────────────────────────────────────── // ── Exam sessions ─────────────────────────────────────────────────────────────
// Fisher-Yates shuffle (returns a new array)
function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// POST /api/sessions — start a new exam session // POST /api/sessions — start a new exam session
app.post('/api/sessions', (req, res) => { app.post('/api/sessions', (req, res) => {
const { bundleId, examMinutes } = req.body; const { bundleId, examMinutes, scenarioCount } = req.body;
const bundle = loadBundles().find(b => b.id === bundleId); const bundle = loadBundles().find(b => b.id === bundleId);
if (!bundle) return res.status(404).json({ error: 'Bundle not found' }); if (!bundle) return res.status(404).json({ error: 'Bundle not found' });
const db = getDb(); const db = getDb();
const mins = Math.max(5, Math.min(300, Number(examMinutes) || bundle.exam_minutes || 120)); const mins = Math.max(5, Math.min(300, Number(examMinutes) || bundle.exam_minutes || 120));
// Shuffle and optionally slice scenario IDs
const allIds = bundle.scenario_ids || [];
const count = Math.max(1, Math.min(allIds.length, Number(scenarioCount) || allIds.length));
const sessionScenarioIds = shuffle(allIds).slice(0, count);
// Abandon any existing active session // Abandon any existing active session
db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now') db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now')
WHERE status='active'`).run(); WHERE status='active'`).run();
const id = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const id = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes) db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes, scenario_ids)
VALUES (?, ?, datetime('now'), 'active', ?)`).run(id, bundleId, mins); VALUES (?, ?, datetime('now'), 'active', ?, ?)`)
res.json({ id, bundleId, status: 'active', exam_minutes: mins }); .run(id, bundleId, mins, JSON.stringify(sessionScenarioIds));
res.json({ id, bundleId, status: 'active', exam_minutes: mins, scenario_ids: sessionScenarioIds });
}); });
// GET /api/sessions/active — get the current active session // GET /api/sessions/active — get the current active session
@@ -270,13 +296,18 @@ app.get('/api/sessions/active', (req, res) => {
const db = getDb(); const db = getDb();
const session = db.prepare(`SELECT * FROM sessions WHERE status='active' ORDER BY started_at DESC LIMIT 1`).get(); const session = db.prepare(`SELECT * FROM sessions WHERE status='active' ORDER BY started_at DESC LIMIT 1`).get();
if (!session) return res.json(null); if (!session) return res.json(null);
// Use session-specific scenario_ids (shuffled/sliced at start time)
let scenarioIds = [];
try { scenarioIds = session.scenario_ids ? JSON.parse(session.scenario_ids) : []; } catch (_) {}
if (!scenarioIds.length) {
// Fallback for sessions created before this feature
const bundle = loadBundles().find(b => b.id === session.bundle_id); const bundle = loadBundles().find(b => b.id === session.bundle_id);
const scenarioIds = bundle?.scenario_ids || []; scenarioIds = bundle?.scenario_ids || [];
// Count completions from exam_progress (exam-specific), not global progress }
const completed = db.prepare( const completed = db.prepare(
`SELECT COUNT(*) as cnt FROM exam_progress WHERE session_id=? AND status='completed'` `SELECT COUNT(*) as cnt FROM exam_progress WHERE session_id=? AND status='completed'`
).get(session.id)?.cnt || 0; ).get(session.id)?.cnt || 0;
res.json({ ...session, scenarioCount: scenarioIds.length, completedCount: completed }); res.json({ ...session, scenario_ids: scenarioIds, scenarioCount: scenarioIds.length, completedCount: completed });
}); });
// GET /api/sessions/:id/exam-progress — return per-scenario progress for an exam session // GET /api/sessions/:id/exam-progress — return per-scenario progress for an exam session
@@ -337,7 +368,11 @@ app.post('/api/sessions/:id/submit', (req, res) => {
if (!session) return res.status(404).json({ error: 'Session not found' }); if (!session) return res.status(404).json({ error: 'Session not found' });
const bundle = loadBundles().find(b => b.id === session.bundle_id); const bundle = loadBundles().find(b => b.id === session.bundle_id);
const scenarios = loadScenarios(); const scenarios = loadScenarios();
const bundleScenarios = scenarios.filter(s => bundle?.scenario_ids?.includes(s.id)); // Use session-specific scenario IDs (preserves shuffle order)
let sessionIds = [];
try { sessionIds = session.scenario_ids ? JSON.parse(session.scenario_ids) : []; } catch (_) {}
if (!sessionIds.length) sessionIds = bundle?.scenario_ids || [];
const bundleScenarios = sessionIds.map(id => scenarios.find(s => s.id === id)).filter(Boolean);
// Build snapshot from exam_progress (exam-specific), falling back to 'not_started' // Build snapshot from exam_progress (exam-specific), falling back to 'not_started'
const examProgressRows = db.prepare(`SELECT * FROM exam_progress WHERE session_id=?`).all(req.params.id); const examProgressRows = db.prepare(`SELECT * FROM exam_progress WHERE session_id=?`).all(req.params.id);
@@ -449,17 +484,34 @@ app.get('/api/bundles', (req, res) => {
res.json(result); res.json(result);
}); });
// GET /api/scenarios — list scenarios; optional ?bundle=<id> filter // GET /api/scenarios — list scenarios; optional ?bundle=<id> and ?session=<id> filter
app.get('/api/scenarios', (req, res) => { app.get('/api/scenarios', (req, res) => {
const scenarios = loadScenarios(); const scenarios = loadScenarios()
const progress = loadProgress(); const progress = loadProgress()
const { bundle } = req.query; const { bundle, session: sessionId } = req.query
let filtered = scenarios; // Session-scoped: return only session scenario_ids in their shuffled order
if (sessionId) {
const db = getDb()
const session = db.prepare(`SELECT scenario_ids FROM sessions WHERE id=?`).get(sessionId)
let sessionIds = []
try { sessionIds = session?.scenario_ids ? JSON.parse(session.scenario_ids) : [] } catch (_) {}
const list = sessionIds
.map(id => scenarios.find(s => s.id === id))
.filter(Boolean)
.map(s => ({
id: s.id, title: s.title, category: s.category,
difficulty: s.difficulty, type: s.type, weight: s.weight,
progress: progress[s.id] || { status: 'not_started', attempts: 0 }
}))
return res.json(list)
}
let filtered = scenarios
if (bundle) { if (bundle) {
const bundles = loadBundles(); const bundles = loadBundles()
const b = bundles.find(x => x.id === bundle); const b = bundles.find(x => x.id === bundle)
if (b) filtered = scenarios.filter(s => b.scenario_ids.includes(s.id)); if (b) filtered = scenarios.filter(s => b.scenario_ids.includes(s.id))
} }
const list = filtered.map(s => ({ const list = filtered.map(s => ({
@@ -470,9 +522,9 @@ app.get('/api/scenarios', (req, res) => {
type: s.type, type: s.type,
weight: s.weight, weight: s.weight,
progress: progress[s.id] || { status: 'not_started', attempts: 0 } progress: progress[s.id] || { status: 'not_started', attempts: 0 }
})); }))
res.json(list); res.json(list)
}); })
// GET /api/scenarios/:id — full scenario detail // GET /api/scenarios/:id — full scenario detail
app.get('/api/scenarios/:id', (req, res) => { app.get('/api/scenarios/:id', (req, res) => {
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KubeKosh — Interactive Kubernetes Playground</title> <title>RootNode Academy — Build From the Root</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" /> <link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Epilogue:wght@400;500;600;700;800;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Epilogue:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "kubekosh-ui", "name": "rootnode-academy-ui",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
+17 -55
View File
@@ -1,59 +1,21 @@
<svg height="470" width="480" viewBox="100 95 480 470" role="img" xmlns="http://www.w3.org/2000/svg" style=""> <svg width="64" height="64" viewBox="0 0 64 64" role="img" xmlns="http://www.w3.org/2000/svg">
<title style="fill:rgb(0, 0, 0);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">KubeKosh icon</title> <title>RootNode Academy</title>
<desc style="fill:rgb(0, 0, 0);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">KubeKosh heptagon logo mark in blue with a white innermost layer and dark terminal prompt icon.</desc> <desc>A filled root node with branching connections to three open network nodes — a root-and-network mark.</desc>
<!-- <!-- Connections (drawn first, sit under the nodes) -->
Heptagon, cx=340, cy=340, angle offset=-90°, step=51.4286° <g stroke="#39FF14" stroke-width="3.4" stroke-linecap="round" fill="none">
r=220 (outer): <line x1="32" y1="38" x2="32" y2="15" />
k0: (340, 120) <line x1="32" y1="38" x2="14" y2="47" />
k1: (340+220cos(-38.57°), 340+220sin(-38.57°)) = (340+171.9, 340-137.1) = (511.9, 202.9) <line x1="32" y1="38" x2="50" y2="47" />
k2: (340+220cos(12.86°), 340+220sin(12.86°)) = (340+214.4, 340+48.9) = (554.4, 388.9) </g>
k3: (340+220cos(64.29°), 340+220sin(64.29°)) = (340+95.0, 340+198.3) = (435.0, 538.3)
k4: (340-95.0, 538.3) = (245.0, 538.3)
k5: (340-214.4, 388.9) = (125.6, 388.9)
k6: (340-171.9, 202.9) = (168.1, 202.9)
r=178 (mid ring), factor=178/220=0.8091: <!-- Outer nodes — open circles, electric blue -->
delta from center scaled by 0.8091: <g stroke="#00D4FF" stroke-width="3.2" fill="none">
k0: (340, 340+(120-340)*0.8091)=(340, 161.9) <circle cx="32" cy="13" r="6" />
k1: (340+171.9*0.8091, 340-137.1*0.8091)=(479.1, 228.9) <circle cx="13" cy="48" r="5.5" />
k2: (340+214.4*0.8091, 340+48.9*0.8091)=(513.5, 379.6) <circle cx="51" cy="48" r="5.5" />
k3: (340+95.0*0.8091, 340+198.3*0.8091)=(416.9, 500.5) </g>
k4: (263.1, 500.5)
k5: (166.5, 379.6)
k6: (200.9, 228.9)
r=132 (inner screen), factor=132/220=0.6: <!-- Root node — filled, terminal green -->
k0: (340, 340+(120-340)*0.6)=(340, 208) <circle cx="32" cy="38" r="8.5" fill="#39FF14" />
k1: (340+171.9*0.6, 340-137.1*0.6)=(443.1, 257.7)
k2: (340+214.4*0.6, 340+48.9*0.6)=(468.6, 369.3)
k3: (340+95.0*0.6, 340+198.3*0.6)=(397.0, 458.9)
k4: (283.0, 458.9)
k5: (340-214.4*0.6, 369.3)=(211.4, 369.3)
k6: (340-171.9*0.6, 257.7)=(236.9, 257.7)
-->
<!-- Outer heptagon — mid blue -->
<polygon points="340,120 511.9,202.9 554.4,388.9 435.0,538.3 245.0,538.3 125.6,388.9 168.1,202.9" fill="#378ADD" style="fill:rgb(55, 138, 221);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<!-- Mid ring — darker blue -->
<polygon points="340,161.9 479.1,228.9 513.5,379.6 416.9,500.5 263.1,500.5 166.5,379.6 200.9,228.9" fill="#185FA5" style="fill:rgb(24, 95, 165);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<!-- Inner heptagon — white -->
<polygon points="340,208 443.1,257.7 468.6,369.3 397.0,458.9 283.0,458.9 211.4,369.3 236.9,257.7" fill="#FFFFFF" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<!-- Terminal prompt, centered at (340, 333) -->
<!-- Caret > -->
<polyline points="296,308 340,333 296,358" fill="none" stroke="#0C447C" stroke-width="480" stroke-linecap="round" stroke-linejoin="round" style="fill:none;stroke:rgb(12, 68, 124);color:rgb(251, 251, 254);stroke-width:18px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<!-- Cursor underscore -->
<line x1="352" y1="384" x2="404" y2="384" stroke="#185FA5" stroke-width="480" stroke-linecap="round" style="fill:rgb(0, 0, 0);stroke:rgb(24, 95, 165);color:rgb(251, 251, 254);stroke-width:13px;stroke-linecap:round;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<!-- Vertex dots -->
<circle cx="340" cy="120" r="10" fill="#B5D4F4" style="fill:rgb(181, 212, 244);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<circle cx="511.9" cy="202.9" r="10" fill="#B5D4F4" style="fill:rgb(181, 212, 244);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<circle cx="554.4" cy="388.9" r="10" fill="#B5D4F4" style="fill:rgb(181, 212, 244);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<circle cx="435.0" cy="538.3" r="10" fill="#B5D4F4" style="fill:rgb(181, 212, 244);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<circle cx="245.0" cy="538.3" r="10" fill="#B5D4F4" style="fill:rgb(181, 212, 244);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<circle cx="125.6" cy="388.9" r="10" fill="#B5D4F4" style="fill:rgb(181, 212, 244);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<circle cx="168.1" cy="202.9" r="10" fill="#B5D4F4" style="fill:rgb(181, 212, 244);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 865 B

+58 -11
View File
@@ -54,6 +54,36 @@ export default function App() {
const tmDragY0 = useRef(0) const tmDragY0 = useRef(0)
const tmDragH0 = useRef(0) const tmDragH0 = useRef(0)
// Focus mode — collapses sidebar, bundles nav, terminal for distraction-free reading
const [focusMode, setFocusMode] = useState(false)
const focusSavedState = useRef(null)
const toggleFocusMode = useCallback(() => {
if (!focusMode) {
// Save current states then collapse everything
focusSavedState.current = { sidebarCollapsed, bundlesCollapsed, termCollapsed }
setSidebarCollapsed(true)
setBundlesCollapsed(true)
setTermCollapsed(true)
setFocusMode(true)
} else {
// Restore previous states
const saved = focusSavedState.current || {}
setSidebarCollapsed(saved.sidebarCollapsed ?? false)
setBundlesCollapsed(saved.bundlesCollapsed ?? false)
setTermCollapsed(saved.termCollapsed ?? false)
setFocusMode(false)
}
}, [focusMode, sidebarCollapsed, bundlesCollapsed, termCollapsed])
// If the user manually opens any panel while in focus mode, exit focus mode
// so the button icon reverts to "expand" (next click re-collapses everything)
useEffect(() => {
if (focusMode && (!sidebarCollapsed || !bundlesCollapsed || !termCollapsed)) {
setFocusMode(false)
}
}, [sidebarCollapsed, bundlesCollapsed, termCollapsed, focusMode])
// Track previous scenario id to teardown on switch // Track previous scenario id to teardown on switch
const prevActiveIdRef = useRef(null) const prevActiveIdRef = useRef(null)
@@ -103,7 +133,10 @@ export default function App() {
setLoading(true) setLoading(true)
setActiveId(null) setActiveId(null)
setScenario(null) setScenario(null)
const url = `/api/scenarios?bundle=${activeBundleId}` // If an exam session is active, fetch only that session's scenarios (pre-shuffled)
const url = examSession?.id
? `/api/scenarios?session=${examSession.id}`
: `/api/scenarios?bundle=${activeBundleId}`
fetch(url) fetch(url)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
@@ -112,7 +145,7 @@ export default function App() {
}) })
.catch(console.error) .catch(console.error)
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [activeBundleId]) }, [activeBundleId, examSession?.id])
// ── Load full scenario when selected — teardown previous, load new ───────── // ── Load full scenario when selected — teardown previous, load new ─────────
useEffect(() => { useEffect(() => {
@@ -137,9 +170,12 @@ export default function App() {
}, [activeId]) }, [activeId])
const refreshProgress = useCallback(async () => { const refreshProgress = useCallback(async () => {
const scenarioUrl = examSession?.id
? `/api/scenarios?session=${examSession.id}`
: `/api/scenarios?bundle=${activeBundleId}`
const [bundleData, scenarioData] = await Promise.all([ const [bundleData, scenarioData] = await Promise.all([
fetch('/api/bundles').then(r => r.json()), fetch('/api/bundles').then(r => r.json()),
fetch(`/api/scenarios?bundle=${activeBundleId}`).then(r => r.json()), fetch(scenarioUrl).then(r => r.json()),
]) ])
setBundles(bundleData) setBundles(bundleData)
setScenarios(scenarioData) setScenarios(scenarioData)
@@ -160,13 +196,13 @@ export default function App() {
}, [activeBundleId, activeId, examSession]) }, [activeBundleId, activeId, examSession])
// ── Exam actions ────────────────────────────────────────────────────────── // ── Exam actions ──────────────────────────────────────────────────────────
const startExam = useCallback(async (bundleId, customMinutes) => { const startExam = useCallback(async (bundleId, customMinutes, customScenarioCount) => {
const res = await fetch('/api/sessions', { const res = await fetch('/api/sessions', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bundleId, examMinutes: customMinutes }), body: JSON.stringify({ bundleId, examMinutes: customMinutes, scenarioCount: customScenarioCount }),
}).then(r => r.json()) }).then(r => r.json())
// refresh to get scenarioCount // refresh to get scenario_ids, scenarioCount
const active = await fetch('/api/sessions/active').then(r => r.json()) const active = await fetch('/api/sessions/active').then(r => r.json())
setExamSession(active) setExamSession(active)
setActiveBundleId(bundleId) setActiveBundleId(bundleId)
@@ -257,6 +293,13 @@ export default function App() {
const activeBundle = bundles.find(b => b.id === activeBundleId) || null const activeBundle = bundles.find(b => b.id === activeBundleId) || null
const isMcq = scenario?.type === 'mcq' const isMcq = scenario?.type === 'mcq'
// In exam mode: scenarios are already pre-filtered and ordered by the session API
const examScenarios = scenarios
// Total weight of the exam's selected scenarios (for % display in ScenarioPanel)
const totalExamWeight = (!!examSession ? examScenarios : scenarios)
.reduce((sum, s) => sum + (s.weight || 0), 0)
return ( return (
<div className={styles.app}> <div className={styles.app}>
<Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} /> <Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} />
@@ -290,7 +333,7 @@ export default function App() {
<div className={styles.body}> <div className={styles.body}>
{/* Sidebar */} {/* Sidebar */}
<Sidebar <Sidebar
scenarios={scenarios} scenarios={!!examSession ? examScenarios : scenarios}
activeId={activeId} activeId={activeId}
onSelect={setActiveId} onSelect={setActiveId}
loading={loading} loading={loading}
@@ -301,6 +344,7 @@ export default function App() {
onProgressUpdate={refreshProgress} onProgressUpdate={refreshProgress}
isExamMode={!!examSession} isExamMode={!!examSession}
examProgress={examProgress} examProgress={examProgress}
totalExamWeight={totalExamWeight}
/> />
{/* Sidebar resize handle */} {/* Sidebar resize handle */}
@@ -315,6 +359,9 @@ export default function App() {
onScenarioStart={handleScenarioStart} onScenarioStart={handleScenarioStart}
isExamMode={!!examSession} isExamMode={!!examSession}
examProgress={examProgress} examProgress={examProgress}
totalExamWeight={totalExamWeight}
focusMode={focusMode}
onToggleFocus={toggleFocusMode}
/> />
</div> </div>
@@ -350,9 +397,9 @@ export default function App() {
{examModalBundle && ( {examModalBundle && (
<ExamStartModal <ExamStartModal
bundle={examModalBundle} bundle={examModalBundle}
onStart={mins => { onStart={(mins, count) => {
setExamModalBundle(null) setExamModalBundle(null)
startExam(examModalBundle.id, mins) startExam(examModalBundle.id, mins, count)
}} }}
onCancel={() => setExamModalBundle(null)} onCancel={() => setExamModalBundle(null)}
/> />
@@ -363,9 +410,9 @@ export default function App() {
)} )}
<footer className={styles.footer}> <footer className={styles.footer}>
<div>&copy; {new Date().getFullYear()} The KubeKosh Project &bull; All rights reserved</div> <div>&copy; {new Date().getFullYear()} RootNode Academy &bull; All rights reserved</div>
<div> <div>
Made with <span className={styles.heart}></span> by <a href="https://github.com/zeborg" target="_blank" rel="noopener noreferrer" className={styles.footerLink}>zeborg</a> Built on the open-source <a href="https://github.com/zeborg/kubekosh" target="_blank" rel="noopener noreferrer" className={styles.footerLink}>KubeKosh</a> project by zeborg
</div> </div>
</footer> </footer>
</div> </div>
+10 -1
View File
@@ -1,5 +1,6 @@
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import styles from './BundleNav.module.css' import styles from './BundleNav.module.css'
import { useConfirm } from './useConfirm'
async function resetProgress(scope, opts) { async function resetProgress(scope, opts) {
await fetch('/api/progress/reset', { await fetch('/api/progress/reset', {
@@ -17,6 +18,7 @@ export default function BundleNav({
const [startX, setStartX] = useState(0) const [startX, setStartX] = useState(0)
const [scrollLeft, setScrollLeft] = useState(0) const [scrollLeft, setScrollLeft] = useState(0)
const [dragDist, setDragDist] = useState(0) const [dragDist, setDragDist] = useState(0)
const { confirm, ConfirmUI } = useConfirm()
const handleMouseDown = (e) => { const handleMouseDown = (e) => {
if (!trackRef.current) return if (!trackRef.current) return
@@ -41,6 +43,7 @@ export default function BundleNav({
return ( return (
<> <>
{ConfirmUI}
<nav className={`${styles.nav} ${collapsed ? styles.collapsed : ''}`} aria-label="Scenario bundles"> <nav className={`${styles.nav} ${collapsed ? styles.collapsed : ''}`} aria-label="Scenario bundles">
{!collapsed && ( {!collapsed && (
<div <div
@@ -117,7 +120,13 @@ export default function BundleNav({
title={`Reset all progress in "${b.name}"`} title={`Reset all progress in "${b.name}"`}
onClick={async e => { onClick={async e => {
e.stopPropagation() e.stopPropagation()
if (!window.confirm(`Reset all progress in "${b.name}"?`)) return const ok = await confirm({
title: 'Reset Bundle Progress',
message: `Reset all progress in "${b.name}"?`,
confirmLabel: 'Reset',
danger: true,
})
if (!ok) return
await resetProgress('bundle', { bundleId: b.id }) await resetProgress('bundle', { bundleId: b.id })
onProgressUpdate?.() onProgressUpdate?.()
}} }}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useRef } from 'react'
import styles from './ConfirmModal.module.css'
/**
* ConfirmModal — drop-in replacement for window.confirm.
*
* Usage via the useConfirm hook (see useConfirm.js).
* Props:
* open boolean
* title string
* message string
* confirmLabel string (default "Confirm")
* cancelLabel string (default "Cancel")
* danger boolean (red confirm button)
* onConfirm () => void
* onCancel () => void
*/
export default function ConfirmModal({
open, title, message,
confirmLabel = 'Confirm', cancelLabel = 'Cancel',
danger = false,
onConfirm, onCancel,
}) {
const confirmRef = useRef(null)
useEffect(() => {
if (open) confirmRef.current?.focus()
}, [open])
useEffect(() => {
if (!open) return
const onKey = e => {
if (e.key === 'Escape') onCancel()
if (e.key === 'Enter') onConfirm()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onConfirm, onCancel])
if (!open) return null
return (
<div className={styles.overlay} onMouseDown={e => e.target === e.currentTarget && onCancel()}>
<div className={styles.modal} role="dialog" aria-modal="true">
{title && <div className={styles.header}>{title}</div>}
{message && <div className={styles.body}>{message}</div>}
<div className={styles.actions}>
<button className={styles.cancelBtn} onClick={onCancel}>{cancelLabel}</button>
<button
ref={confirmRef}
className={`${styles.confirmBtn} ${danger ? styles.danger : ''}`}
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,87 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(3px);
animation: fadeIn 0.12s ease;
}
.modal {
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-lg);
width: 400px;
max-width: 90vw;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: slideUp 0.15s ease;
overflow: hidden;
}
.header {
padding: 18px 20px 0;
font-size: 15px;
font-weight: 700;
color: var(--text);
letter-spacing: -0.2px;
}
.body {
padding: 10px 20px 18px;
font-size: 13px;
color: var(--text-2);
line-height: 1.65;
white-space: pre-line;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--border);
background: var(--surface2);
}
.cancelBtn {
background: none;
border: 1px solid var(--border2);
border-radius: 6px;
color: var(--text-2);
padding: 7px 16px;
font-size: 13px;
font-weight: 600;
font-family: var(--sans);
cursor: pointer;
transition: all 0.12s;
}
.cancelBtn:hover {
color: var(--text);
border-color: var(--text-3);
background: var(--surface3);
}
.confirmBtn {
background: var(--green);
color: #000;
border: none;
border-radius: 6px;
padding: 7px 18px;
font-size: 13px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
transition: opacity 0.12s;
}
.confirmBtn:hover { opacity: 0.85; }
.confirmBtn.danger {
background: var(--red);
color: #fff;
}
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes slideUp { from { opacity: 0; transform: translateY(12px) } to { opacity: 1; transform: none } }
+49 -5
View File
@@ -2,11 +2,18 @@ import { useState, useEffect, useRef } from 'react'
import styles from './ExamStartModal.module.css' import styles from './ExamStartModal.module.css'
export default function ExamStartModal({ bundle, onStart, onCancel }) { export default function ExamStartModal({ bundle, onStart, onCancel }) {
const totalScenarios = bundle?.scenario_ids?.length || 0
const [minutes, setMinutes] = useState(bundle?.exam_minutes || 120) const [minutes, setMinutes] = useState(bundle?.exam_minutes || 120)
const [scenarioCount, setScenarioCount] = useState(totalScenarios)
const inputRef = useRef(null) const inputRef = useRef(null)
// Keep scenarioCount in sync if bundle changes
useEffect(() => { useEffect(() => {
// Focus input on open setScenarioCount(bundle?.scenario_ids?.length || 0)
}, [bundle])
useEffect(() => {
// Focus duration input on open
const t = setTimeout(() => inputRef.current?.select(), 60) const t = setTimeout(() => inputRef.current?.select(), 60)
return () => clearTimeout(t) return () => clearTimeout(t)
}, []) }, [])
@@ -14,11 +21,14 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
if (!bundle) return null if (!bundle) return null
const numMinutes = Number(minutes) const numMinutes = Number(minutes)
const isValid = numMinutes >= 5 && numMinutes <= 300 const numScenarios = Number(scenarioCount)
const isMinutesValid = numMinutes >= 5 && numMinutes <= 300
const isScenariosValid = numScenarios >= 1 && numScenarios <= totalScenarios
const isValid = isMinutesValid && isScenariosValid
const handleStart = () => { const handleStart = () => {
if (!isValid) return if (!isValid) return
onStart(numMinutes) onStart(numMinutes, numScenarios)
} }
const presets = [ const presets = [
@@ -42,9 +52,10 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
<div className={styles.body}> <div className={styles.body}>
<div className={styles.info}> <div className={styles.info}>
<span>📋</span> <span>📋</span>
<span>{bundle.scenario_ids?.length || '?'} scenarios · Recommended: <strong>{bundle.exam_minutes} min</strong></span> <span>{totalScenarios} scenarios · Recommended: <strong>{bundle.exam_minutes} min</strong></span>
</div> </div>
{/* Duration field */}
<div className={styles.field}> <div className={styles.field}>
<label className={styles.label}>Exam Duration</label> <label className={styles.label}>Exam Duration</label>
<div className={styles.presets}> <div className={styles.presets}>
@@ -73,13 +84,46 @@ export default function ExamStartModal({ bundle, onStart, onCancel }) {
<span className={styles.unit}>minutes</span> <span className={styles.unit}>minutes</span>
</div> </div>
<div className={styles.hint}> <div className={styles.hint}>
{!isValid ? ( {!isMinutesValid ? (
<span style={{ color: 'var(--red)' }}> Duration must be between 5 and 300 minutes</span> <span style={{ color: 'var(--red)' }}> Duration must be between 5 and 300 minutes</span>
) : numMinutes < 60 ? '⚡ Speed run mode' : ) : numMinutes < 60 ? '⚡ Speed run mode' :
numMinutes <= 120 ? '🎯 Realistic exam timing' : numMinutes <= 120 ? '🎯 Realistic exam timing' :
'🧘 Relaxed practice pace'} '🧘 Relaxed practice pace'}
</div> </div>
</div> </div>
{/* Scenario count field */}
<div className={styles.field}>
<label className={styles.label}>Number of Scenarios</label>
<div className={styles.scenarioRow}>
<input
type="range"
className={styles.slider}
value={numScenarios}
min={1}
max={totalScenarios}
step={1}
onChange={e => setScenarioCount(Number(e.target.value))}
style={{ '--bcolor': bundle.color }}
/>
<input
type="number"
className={styles.input}
value={scenarioCount}
min={1}
max={totalScenarios}
onChange={e => setScenarioCount(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleStart()}
/>
</div>
<div className={styles.hint}>
{!isScenariosValid ? (
<span style={{ color: 'var(--red)' }}> Must be between 1 and {totalScenarios}</span>
) : numScenarios === totalScenarios
? `📚 Full exam — all ${totalScenarios} scenarios`
: `🎯 ${numScenarios} randomly selected scenario${numScenarios > 1 ? 's' : ''}`}
</div>
</div>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
@@ -116,6 +116,24 @@
gap: 8px; gap: 8px;
} }
.scenarioRow {
display: flex;
align-items: center;
gap: 10px;
}
.slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 2px;
background: var(--border2);
outline: none;
cursor: pointer;
accent-color: var(--bcolor, var(--blue));
}
.input { .input {
width: 90px; width: 90px;
padding: 8px 12px; padding: 8px 12px;
+24 -7
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import styles from './ExamTimer.module.css' import styles from './ExamTimer.module.css'
import { useConfirm } from './useConfirm'
function formatTime(secs) { function formatTime(secs) {
if (secs < 0) secs = 0 if (secs < 0) secs = 0
@@ -41,19 +42,33 @@ export default function ExamTimer({ session, bundle, onSubmit, onAbandon }) {
const pct = Math.min(100, (elapsed / durationSecs) * 100) const pct = Math.min(100, (elapsed / durationSecs) * 100)
const urgent = remaining < 600 // < 10 min const urgent = remaining < 600 // < 10 min
const handleSubmit = useCallback(async () => { const { confirm, ConfirmUI } = useConfirm()
if (!window.confirm(`Submit exam now?\n\n${session.completedCount || 0} of ${session.scenarioCount || '?'} scenarios completed.`)) return
onSubmit()
}, [session, onSubmit])
const handleAbandon = useCallback(() => { const handleSubmit = useCallback(async () => {
if (!window.confirm('Abandon this exam?\n\nYour progress will be saved but no score report will be generated.')) return const ok = await confirm({
title: 'Submit Exam',
message: `Submit exam now?\n\n${session.completedCount || 0} of ${session.scenarioCount || '?'} scenarios completed.`,
confirmLabel: 'Submit',
})
if (!ok) return
onSubmit()
}, [session, onSubmit, confirm])
const handleAbandon = useCallback(async () => {
const ok = await confirm({
title: 'Abandon Exam',
message: 'Abandon this exam?\n\nYour progress will be saved but no score report will be generated.',
confirmLabel: 'Abandon',
danger: true,
})
if (!ok) return
onAbandon() onAbandon()
}, [onAbandon]) }, [onAbandon, confirm])
if (!session) return null if (!session) return null
return ( return (
<>
<div className={`${styles.timer} ${urgent ? styles.urgent : ''}`}> <div className={`${styles.timer} ${urgent ? styles.urgent : ''}`}>
<div className={styles.left}> <div className={styles.left}>
<span className={styles.icon}></span> <span className={styles.icon}></span>
@@ -87,5 +102,7 @@ export default function ExamTimer({ session, bundle, onSubmit, onAbandon }) {
</button> </button>
</div> </div>
</div> </div>
{ConfirmUI}
</>
) )
} }
+4 -4
View File
@@ -98,18 +98,18 @@ export default function Header({ clusterReady, onShowHistory }) {
<header className={styles.header}> <header className={styles.header}>
<div className={styles.brand}> <div className={styles.brand}>
<div className={styles.logo}> <div className={styles.logo}>
<img src="/logo.svg" alt="KubeKosh Logo" className={styles.logoImage} /> <img src="/logo.svg" alt="RootNode Academy Logo" className={styles.logoImage} />
<span className={styles.logoText}>KubeKosh</span> <span className={styles.logoText}>RootNode Academy</span>
<span className={styles.version}>{import.meta.env.VITE_APP_VERSION}</span> <span className={styles.version}>{import.meta.env.VITE_APP_VERSION}</span>
</div> </div>
<span className={styles.tagline}>Interactive Kubernetes Playground</span> <span className={styles.tagline}>Build From the Root</span>
</div> </div>
<div className={styles.right}> <div className={styles.right}>
{/* GitHub link */} {/* GitHub link */}
<div className={styles.githubBtnContainer}> <div className={styles.githubBtnContainer}>
<a <a
href="https://github.com/zeborg/kubekosh" href="https://github.com/ced4568/kubekosh"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={styles.githubBtn} className={styles.githubBtn}
+64 -4
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import styles from './ScenarioPanel.module.css' import styles from './ScenarioPanel.module.css'
import { useConfirm } from './useConfirm'
// Inline markdown: renders without a wrapping <p> — safe for buttons/spans // Inline markdown: renders without a wrapping <p> — safe for buttons/spans
const inlineComponents = { const inlineComponents = {
@@ -24,10 +25,11 @@ async function resetProgress(scope, opts) {
}) })
} }
export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioStart, isExamMode, examProgress }) { export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioStart, isExamMode, examProgress, totalExamWeight, focusMode, onToggleFocus }) {
const [tab, setTab] = useState('problem') const [tab, setTab] = useState('problem')
const [setupState, setSetupState] = useState('idle') // idle | running | done | error const [setupState, setSetupState] = useState('idle') // idle | running | done | error
const [validating, setValidating] = useState(false) const [validating, setValidating] = useState(false)
const { confirm, ConfirmUI } = useConfirm()
const [validResult, setValidResult] = useState(null) const [validResult, setValidResult] = useState(null)
const [selectedOption, setSelectedOption] = useState(null) const [selectedOption, setSelectedOption] = useState(null)
const [mcqResult, setMcqResult] = useState(null) const [mcqResult, setMcqResult] = useState(null)
@@ -197,18 +199,49 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
return ( return (
<div className={styles.panel}> <div className={styles.panel}>
{ConfirmUI}
{/* Scenario header */} {/* Scenario header */}
<div className={styles.scenarioHeader}> <div className={styles.scenarioHeader}>
<div className={styles.scenarioMeta}> <div className={styles.scenarioMeta}>
<span className={styles.category}>{scenario.category}</span> <span className={styles.category}>{scenario.category}</span>
{!isExamMode && (
<span className={`${styles.diff} ${styles[scenario.difficulty?.toLowerCase()]}`}> <span className={`${styles.diff} ${styles[scenario.difficulty?.toLowerCase()]}`}>
{scenario.difficulty} {scenario.difficulty}
</span> </span>
)}
<span className={styles.typeTag}>{scenario.type === 'mcq' ? 'Multiple Choice' : 'Hands-on Task'}</span> <span className={styles.typeTag}>{scenario.type === 'mcq' ? 'Multiple Choice' : 'Hands-on Task'}</span>
{!isExamMode && <span className={styles.weight}>{scenario.weight} pts</span>} {!isExamMode && <span className={styles.weight}>{scenario.weight} pts</span>}
{isExamMode && totalExamWeight > 0 && (
<span className={styles.examWeight}>
{Math.round((scenario.weight / totalExamWeight) * 100)}% weight
</span>
)}
</div> </div>
<div className={styles.titleRow}> <div className={styles.titleRow}>
<div className={styles.scenarioTitle}>{scenario.title}</div> <div className={styles.scenarioTitle}>{scenario.title}</div>
{onToggleFocus && !isExamMode && (
<button
className={focusMode ? styles.focusBtnActive : styles.focusBtn}
onClick={onToggleFocus}
title={focusMode ? 'Exit focus mode' : 'Focus mode — hide sidebar, terminal & nav'}
>
{focusMode ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20" />
<polyline points="20 10 14 10 14 4" />
<line x1="10" y1="14" x2="3" y2="21" />
<line x1="21" y1="3" x2="14" y2="10" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
)}
</button>
)}
{isExamMode && scenario.progress?.started_at && ( {isExamMode && scenario.progress?.started_at && (
<div className={styles.progressStats}> <div className={styles.progressStats}>
<div className={styles.statItem}> <div className={styles.statItem}>
@@ -231,15 +264,42 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
</div> </div>
</div> </div>
)} )}
{scenario.progress?.status !== 'not_started' && scenario.progress?.attempts > 0 && ( {onToggleFocus && isExamMode && (
<button
className={focusMode ? styles.focusBtnActive : styles.focusBtn}
onClick={onToggleFocus}
title={focusMode ? 'Exit focus mode' : 'Focus mode — hide sidebar, terminal & nav'}
>
{focusMode ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20" />
<polyline points="20 10 14 10 14 4" />
<line x1="10" y1="14" x2="3" y2="21" />
<line x1="21" y1="3" x2="14" y2="10" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
)}
</button>
)}
{!isExamMode && scenario.progress?.status !== 'not_started' && scenario.progress?.attempts > 0 && (
<button <button
className={styles.resetBtn} className={styles.resetBtn}
title="Reset progress for this scenario" title="Reset progress for this scenario"
onClick={async () => { onClick={async () => {
const msg = scenario.type === 'task' const title = scenario.type === 'task'
? 'Reset Scenario & Environment'
: 'Reset Scenario Progress'
const message = scenario.type === 'task'
? `Reset progress and cluster state for "${scenario.title}"?\n\nThis will run teardown to clean the environment.` ? `Reset progress and cluster state for "${scenario.title}"?\n\nThis will run teardown to clean the environment.`
: `Reset progress for "${scenario.title}"?` : `Reset progress for "${scenario.title}"?`
if (!window.confirm(msg)) return const ok = await confirm({ title, message, confirmLabel: 'Reset', danger: true })
if (!ok) return
await resetProgress('scenario', { scenarioId: scenario.id }) await resetProgress('scenario', { scenarioId: scenario.id })
setSelectedOption(null) setSelectedOption(null)
setMcqResult(null) setMcqResult(null)
@@ -76,6 +76,19 @@
margin-left: auto; margin-left: auto;
} }
.examWeight {
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
color: var(--teal);
background: var(--teal-dim);
padding: 2px 7px;
border-radius: 3px;
border: 1px solid color-mix(in srgb, var(--teal) 30%, transparent);
margin-left: auto;
}
.scenarioTitle { .scenarioTitle {
font-size: 19px; font-size: 19px;
font-weight: 800; font-weight: 800;
@@ -115,6 +128,34 @@
background: var(--red-dim); background: var(--red-dim);
} }
.focusBtn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: 6px;
color: var(--text-3);
width: 28px;
height: 28px;
cursor: pointer;
align-self: center;
transition: color 0.12s, border-color 0.12s, background 0.12s;
}
.focusBtn:hover {
color: var(--green);
border-color: color-mix(in srgb, var(--green) 40%, transparent);
background: var(--green-dim);
}
.focusBtnActive {
composes: focusBtn;
color: var(--green);
border-color: color-mix(in srgb, var(--green) 40%, transparent);
background: var(--green-dim);
}
.completedBanner { .completedBanner {
margin-top: 8px; margin-top: 8px;
font-size: 12px; font-size: 12px;
+64 -21
View File
@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import styles from './Sidebar.module.css' import styles from './Sidebar.module.css'
import { useConfirm } from './useConfirm'
const DIFF_COLOR = { Easy: 'green', Medium: 'amber', Hard: 'red' } const DIFF_COLOR = { Easy: 'green', Medium: 'amber', Hard: 'red' }
const TYPE_ICON = { task: '⚙', mcq: '◉' } const TYPE_ICON = { task: '⚙', mcq: '◉' }
@@ -16,10 +17,11 @@ export default function Sidebar({
scenarios, activeId, onSelect, loading, scenarios, activeId, onSelect, loading,
collapsed, onToggleCollapse, width, collapsed, onToggleCollapse, width,
activeBundleId, onProgressUpdate, activeBundleId, onProgressUpdate,
isExamMode, examProgress, isExamMode, examProgress, totalExamWeight,
}) { }) {
const [filterDiff, setFilterDiff] = useState('All') const [filterDiff, setFilterDiff] = useState('All')
const [filterType, setFilterType] = useState('All') const [filterType, setFilterType] = useState('All')
const { confirm, ConfirmUI } = useConfirm()
const filteredScenarios = useMemo(() => { const filteredScenarios = useMemo(() => {
return scenarios.filter(s => { return scenarios.filter(s => {
@@ -72,19 +74,33 @@ export default function Sidebar({
const handleCategoryReset = async (e, cat) => { const handleCategoryReset = async (e, cat) => {
e.stopPropagation() e.stopPropagation()
if (!window.confirm(`Reset all progress in "${cat}"?`)) return const ok = await confirm({
title: 'Reset Category Progress',
message: `Reset all progress in "${cat}"?`,
confirmLabel: 'Reset',
danger: true,
})
if (!ok) return
await resetProgress('category', { category: cat }) await resetProgress('category', { category: cat })
onProgressUpdate?.() onProgressUpdate?.()
} }
const handleScenarioReset = async (e, scenarioId, title) => { const handleScenarioReset = async (e, scenarioId, title) => {
e.stopPropagation() e.stopPropagation()
if (!window.confirm(`Reset progress for "${title}"?`)) return const ok = await confirm({
title: 'Reset Scenario Progress',
message: `Reset progress for "${title}"?`,
confirmLabel: 'Reset',
danger: true,
})
if (!ok) return
await resetProgress('scenario', { scenarioId }) await resetProgress('scenario', { scenarioId })
onProgressUpdate?.() onProgressUpdate?.()
} }
return ( return (
<>
{ConfirmUI}
<aside <aside
className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`} className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}
style={{ width, minWidth: width }} style={{ width, minWidth: width }}
@@ -106,8 +122,8 @@ export default function Sidebar({
</button> </button>
</div> </div>
{/* Filter bar */} {/* Filter bar — hidden in exam mode */}
{!collapsed && ( {!collapsed && !isExamMode && (
<div className={styles.filterBar}> <div className={styles.filterBar}>
<select <select
value={filterDiff} value={filterDiff}
@@ -142,7 +158,42 @@ export default function Sidebar({
</div> </div>
)} )}
{!loading && Object.entries(groups).map(([cat, items]) => { {!loading && isExamMode && (
/* ── Exam mode: flat numbered list ─────────────────── */
<div className={styles.flatList}>
{scenarios.map((s, idx) => {
const examDone = examProgress?.[s.id]?.status === 'completed'
const active = s.id === activeId
const hasAttempts = (examProgress?.[s.id]?.attempts || 0) > 0
return (
<button
key={s.id}
className={`${styles.item} ${active ? styles.active : ''} ${examDone ? styles.done : ''}`}
onClick={() => onSelect(s.id)}
>
<div className={styles.itemTop}>
<span className={styles.itemNum}>{idx + 1}</span>
<span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span>
<span className={styles.itemTitle}>{s.title}</span>
</div>
<div className={styles.itemMeta}>
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
{totalExamWeight > 0 && (
<span className={styles.examWeightBadge}>
{Math.round((s.weight / totalExamWeight) * 100)}% wt
</span>
)}
{examDone && (
<span className={styles.examCompletedTag}> Completed</span>
)}
</div>
</button>
)
})}
</div>
)}
{!loading && !isExamMode && Object.entries(groups).map(([cat, items]) => {
const catDone = items.filter(s => s.progress?.status === 'completed').length const catDone = items.filter(s => s.progress?.status === 'completed').length
const isOpen = open[cat] !== false const isOpen = open[cat] !== false
const hasCatProgress = items.some(s => s.progress?.attempts > 0) const hasCatProgress = items.some(s => s.progress?.attempts > 0)
@@ -156,9 +207,7 @@ export default function Sidebar({
</div> </div>
<div className={styles.accordionRight}> <div className={styles.accordionRight}>
<span className={styles.catCount}> <span className={styles.catCount}>
{isExamMode {`${catDone}/${items.length}`}
? `${items.filter(s => examProgress?.[s.id]?.status === 'completed').length}/${items.length}`
: `${catDone}/${items.length}`}
</span> </span>
{hasCatProgress && ( {hasCatProgress && (
<button <button
@@ -178,24 +227,20 @@ export default function Sidebar({
{isOpen && ( {isOpen && (
<div className={styles.itemsBox}> <div className={styles.itemsBox}>
{items.map(s => { {items.map(s => {
const examDone = isExamMode && examProgress?.[s.id]?.status === 'completed' const done = s.progress?.status === 'completed'
const done = !isExamMode && s.progress?.status === 'completed'
const active = s.id === activeId const active = s.id === activeId
const hasAttempts = isExamMode const hasAttempts = s.progress?.attempts > 0
? (examProgress?.[s.id]?.attempts || 0) > 0
: s.progress?.attempts > 0
return ( return (
<button <button
key={s.id} key={s.id}
className={`${styles.item} ${active ? styles.active : ''} ${(done || examDone) ? styles.done : ''}`} className={`${styles.item} ${active ? styles.active : ''} ${done ? styles.done : ''}`}
onClick={() => onSelect(s.id)} onClick={() => onSelect(s.id)}
> >
<div className={styles.itemTop}> <div className={styles.itemTop}>
<span className={styles.itemNum}>{scenarioIndex[s.id]}</span> <span className={styles.itemNum}>{scenarioIndex[s.id]}</span>
<span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span> <span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span>
<span className={styles.itemTitle}>{s.title}</span> <span className={styles.itemTitle}>{s.title}</span>
{(done || examDone) && <span className={styles.checkmark}></span>} {done && <span className={styles.checkmark}></span>}
{/* Per-scenario reset — shown when item has attempts */}
{hasAttempts && ( {hasAttempts && (
<button <button
className={styles.itemResetBtn} className={styles.itemResetBtn}
@@ -214,10 +259,7 @@ export default function Sidebar({
{s.difficulty} {s.difficulty}
</span> </span>
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span> <span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
{!isExamMode && <span className={styles.weight}>{s.weight}pt</span>} <span className={styles.weight}>{s.weight}pt</span>
{isExamMode && examDone && (
<span className={styles.examCompletedTag}> Completed</span>
)}
</div> </div>
</button> </button>
) )
@@ -230,5 +272,6 @@ export default function Sidebar({
</div> </div>
)} )}
</aside> </aside>
</>
) )
} }
@@ -70,6 +70,22 @@
padding: 8px 0; padding: 8px 0;
} }
.flatList {
overflow-y: auto;
flex: 1;
padding: 6px 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.flatList .item {
border: 1px solid var(--border);
border-radius: var(--radius);
border-bottom: 1px solid var(--border);
}
/* Filters */ /* Filters */
.filterBar { .filterBar {
display: flex; display: flex;
@@ -338,6 +354,18 @@
@keyframes slideIn { from{opacity:0;transform:translateX(-4px)} to{opacity:1;transform:translateX(0)} } @keyframes slideIn { from{opacity:0;transform:translateX(-4px)} to{opacity:1;transform:translateX(0)} }
.examWeightBadge {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
border-radius: 3px;
background: var(--teal-dim);
color: var(--teal);
border: 1px solid color-mix(in srgb, var(--teal) 30%, transparent);
letter-spacing: 0.3px;
}
.examCompletedTag { .examCompletedTag {
font-family: var(--mono); font-family: var(--mono);
font-size: 10px; font-size: 10px;
+1 -1
View File
@@ -147,7 +147,7 @@ export default function TerminalComponent({ collapsed, onToggleCollapse }) {
</div> </div>
<span className={styles.barTitle}> <span className={styles.barTitle}>
<span className={styles.barIcon}>$_</span> <span className={styles.barIcon}>$_</span>
bash kubekosh bash rootnode
</span> </span>
</div> </div>
<div className={styles.barRight}> <div className={styles.barRight}>
+57
View File
@@ -0,0 +1,57 @@
import { useState, useCallback, useRef } from 'react'
import ConfirmModal from './ConfirmModal'
/**
* useConfirm — returns { confirm, ConfirmUI }
*
* Usage:
* const { confirm, ConfirmUI } = useConfirm()
*
* // Inside JSX:
* {ConfirmUI}
*
* // Imperatively (awaitable):
* const ok = await confirm({
* title: 'Delete item',
* message: 'This cannot be undone.',
* confirmLabel: 'Delete',
* danger: true,
* })
* if (ok) { ... }
*/
export function useConfirm() {
const [state, setState] = useState({ open: false })
const resolveRef = useRef(null)
const confirm = useCallback((options) => {
return new Promise(resolve => {
resolveRef.current = resolve
setState({ open: true, ...options })
})
}, [])
const handleConfirm = useCallback(() => {
setState(s => ({ ...s, open: false }))
resolveRef.current?.(true)
}, [])
const handleCancel = useCallback(() => {
setState(s => ({ ...s, open: false }))
resolveRef.current?.(false)
}, [])
const ConfirmUI = (
<ConfirmModal
open={state.open}
title={state.title}
message={state.message}
confirmLabel={state.confirmLabel}
cancelLabel={state.cancelLabel}
danger={state.danger}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
)
return { confirm, ConfirmUI }
}
+17 -13
View File
@@ -2,27 +2,29 @@
/* ── Dark theme (default) ────────────────────────────────────────────────── */ /* ── Dark theme (default) ────────────────────────────────────────────────── */
:root { :root {
--bg: #0c0f14; --bg: #0D1117;
--surface: #131720; --surface: #161B22;
--surface2: #1a2030; --surface2: #1C232E;
--surface3: #212840; --surface3: #232C3A;
--border: #252d42; --border: #21262D;
--border2: #2e3a55; --border2: #30363D;
--green: #39d98a; --green: #39FF14;
--green-dim: rgba(57,217,138,0.12); --green-dim: rgba(57,255,20,0.12);
--blue: #4dabf7; --blue: #00D4FF;
--blue-dim: rgba(77,171,247,0.10); --blue-dim: rgba(0,212,255,0.10);
--amber: #fcc419; --amber: #fcc419;
--amber-dim: rgba(252,196,25,0.10); --amber-dim: rgba(252,196,25,0.10);
--red: #ff6b6b; --red: #ff6b6b;
--red-dim: rgba(255,107,107,0.10); --red-dim: rgba(255,107,107,0.10);
--purple: #9775fa; --purple: #9775fa;
--purple-dim: rgba(151,117,250,0.10); --purple-dim: rgba(151,117,250,0.10);
--teal: #2dd4bf;
--teal-dim: rgba(45,212,191,0.11);
--text: #e2e8f4; --text: #E6EDF3;
--text-2: #8899b8; --text-2: #8B949E;
--text-3: #4d5f80; --text-3: #586069;
--mono: 'IBM Plex Mono', monospace; --mono: 'IBM Plex Mono', monospace;
--sans: 'Epilogue', sans-serif; --sans: 'Epilogue', sans-serif;
@@ -52,6 +54,8 @@
--red-dim: rgba(220,38,38,0.10); --red-dim: rgba(220,38,38,0.10);
--purple: #7c3aed; --purple: #7c3aed;
--purple-dim: rgba(124,58,237,0.10); --purple-dim: rgba(124,58,237,0.10);
--teal: #0d9488;
--teal-dim: rgba(13,148,136,0.11);
--text: #111827; --text: #111827;
--text-2: #374151; --text-2: #374151;
@@ -1,5 +1,5 @@
{ {
"id": "k8s-basics", "id": "1-k8s-basics",
"name": "Kubernetes Basics", "name": "Kubernetes Basics",
"icon": "🌱", "icon": "🌱",
"tagline": "Core concepts for beginners", "tagline": "Core concepts for beginners",
@@ -36,6 +36,16 @@
"annotate-resource-basics", "annotate-resource-basics",
"init-container-task-basics", "init-container-task-basics",
"restart-policy-mcq", "restart-policy-mcq",
"kubectl-output-format-mcq" "kubectl-output-format-mcq",
"kubectl-explain-basics",
"kubectl-patch-basics",
"field-selector-basics",
"sort-by-basics",
"kubectl-diff-basics",
"node-cordon-basics",
"static-pods-mcq",
"deployment-strategy-mcq",
"pod-node-assignment-mcq",
"pod-env-vars-inline"
] ]
} }
@@ -1,5 +1,5 @@
{ {
"id": "k8s-appdev", "id": "2-k8s-appdev",
"name": "Kubernetes Developer", "name": "Kubernetes Developer",
"icon": "🛠️", "icon": "🛠️",
"tagline": "CKAD exam — application development", "tagline": "CKAD exam — application development",
@@ -1,5 +1,5 @@
{ {
"id": "k8s-admin", "id": "3-k8s-admin",
"name": "Kubernetes Administrator", "name": "Kubernetes Administrator",
"icon": "🧑‍✈️", "icon": "🧑‍✈️",
"tagline": "CKA exam — cluster administration", "tagline": "CKA exam — cluster administration",
@@ -1,5 +1,5 @@
{ {
"id": "k8s-security", "id": "4-k8s-security",
"name": "Kubernetes Security", "name": "Kubernetes Security",
"icon": "🛡", "icon": "🛡",
"tagline": "CKS exam — hardening and threats", "tagline": "CKS exam — hardening and threats",
+13
View File
@@ -0,0 +1,13 @@
{
"id": "rootnode-real-ops",
"name": "RootNode: Real Ops",
"icon": "🌳",
"tagline": "Real-world cluster failures, drawn from a working homelab NOC.",
"color": "#39FF14",
"colorDim": "rgba(57,255,20,0.12)",
"exam_minutes": 60,
"scenario_ids": [
"rootnode-runaway-monitor",
"broken-deployment"
]
}
@@ -0,0 +1,44 @@
{
"id": "deployment-strategy-mcq",
"title": "Deployment Update Strategies",
"category": "Workloads",
"difficulty": "Medium",
"type": "mcq",
"weight": 3,
"description": "## Deployment Update Strategies\n\nA production Deployment is being updated. The team requires that **at least 3 pods are always available** during the rollout, and at most **5 pods can exist at any one time** (the Deployment has 4 replicas).\n\nWhich `strategy` configuration achieves this?\n\n```yaml\nspec:\n replicas: 4\n strategy:\n type: RollingUpdate\n rollingUpdate:\n maxUnavailable: ???\n maxSurge: ???\n```",
"options": [
{
"id": "a",
"text": "`maxUnavailable: 1`, `maxSurge: 1` — at most 1 pod unavailable (3 always up), at most 5 pods total"
},
{
"id": "b",
"text": "`maxUnavailable: 0`, `maxSurge: 0` — ensures zero disruption with no extra pods"
},
{
"id": "c",
"text": "`maxUnavailable: 2`, `maxSurge: 2` — allows 2 pods down (2 available) and 6 total pods"
},
{
"id": "d",
"text": "`maxUnavailable: 4`, `maxSurge: 1` — replaces all pods at once before adding new ones"
}
],
"correct_option": "a",
"explanation": "With 4 replicas, `maxUnavailable: 1` means at most 1 pod can be unavailable → minimum 3 pods always running. `maxSurge: 1` means at most 1 extra pod can be created → maximum 5 pods total (4+1). This satisfies both constraints. Option B (`maxUnavailable: 0, maxSurge: 0`) is invalid — at least one of them must be non-zero. Option C allows only 2 available pods (violates the minimum-3 requirement). Option D would take all existing pods offline before any new ones are ready.",
"hints": [
{
"title": "Understanding maxUnavailable and maxSurge",
"body": "maxUnavailable: how many pods can be unavailable during an update (can be a count or %). maxSurge: how many extra pods above the desired count can exist simultaneously.",
"command": "kubectl explain deployment.spec.strategy.rollingUpdate"
},
{
"title": "Check the current strategy on a deployment",
"body": "Use jsonpath to inspect the rolling update config of any existing deployment.",
"command": "kubectl get deployment <name> -o jsonpath='{.spec.strategy.rollingUpdate}'"
}
],
"setup_commands": [],
"default_namespace": "default",
"teardown_commands": []
}
+60
View File
@@ -0,0 +1,60 @@
{
"id": "field-selector-basics",
"title": "Filtering Resources with Field Selectors",
"category": "Core Concepts",
"difficulty": "Easy",
"type": "task",
"weight": 4,
"description": "## Filtering Resources with Field Selectors\n\nField selectors let you filter Kubernetes resources by the **value of specific object fields** — similar to how label selectors work but targeting any field in the resource spec or status.\n\nCommon use cases:\n- Find all pods in a specific phase: `kubectl get pods --field-selector=status.phase=Running`\n- Find resources in a specific namespace: `kubectl get pods --field-selector=metadata.namespace=kube-system`\n\n**Your task:**\n\nSeveral pods have been created for you — some Running, some in a Failed state.\n\n1. Use a field selector to list only the **Running** pods and count them. Save the count to `/tmp/running-count.txt`\n2. Use a field selector to list all pods **not** in the `default` namespace (filter by `metadata.namespace!=default`)\n\n```bash\n# Count running pods:\nkubectl get pods --field-selector=status.phase=Running --no-headers | wc -l\n```",
"hints": [
{
"title": "Field selector syntax",
"body": "Use `--field-selector=field.path=value` to filter. Multiple conditions are comma-separated. Supported operators: `=`, `==`, `!=`.",
"command": "kubectl get pods --field-selector=status.phase=Running"
},
{
"title": "Save running pod count to a file",
"body": "Pipe the result through wc -l and redirect to the file.",
"command": "kubectl get pods --field-selector=status.phase=Running --no-headers | wc -l | tr -d ' ' > /tmp/running-count.txt"
}
],
"setup_commands": [
{
"command": "kubectl run alpha --image=nginx:alpine 2>/dev/null || true"
},
{
"command": "kubectl run beta --image=nginx:alpine 2>/dev/null || true"
},
{
"command": "kubectl run crash-pod --image=busybox:1.36 --restart=Never -- /bin/false 2>/dev/null || true"
},
{
"command": "kubectl rollout status deployment/alpha --timeout=30s 2>/dev/null || true"
}
],
"validation": {
"commands": [
{
"description": "File /tmp/running-count.txt exists and has a numeric value",
"command": "cat /tmp/running-count.txt",
"expected_output": "^[0-9]+$",
"match": "regex"
},
{
"description": "The saved count matches the actual number of Running pods",
"command": "LIVE=$(kubectl get pods --field-selector=status.phase=Running --no-headers 2>/dev/null | wc -l | tr -d ' '); SAVED=$(cat /tmp/running-count.txt 2>/dev/null | tr -d ' '); [ \"$LIVE\" = \"$SAVED\" ] && echo 'match' || echo 'mismatch'",
"expected_output": "match",
"match": "exact"
}
]
},
"default_namespace": "default",
"teardown_commands": [
{
"command": "kubectl delete pod alpha beta crash-pod --ignore-not-found --grace-period=0 --force"
},
{
"command": "rm -f /tmp/running-count.txt"
}
]
}
+65
View File
@@ -0,0 +1,65 @@
{
"id": "kubectl-diff-basics",
"title": "Previewing Changes with kubectl diff",
"category": "Core Concepts",
"difficulty": "Medium",
"type": "task",
"weight": 4,
"description": "## Previewing Changes with `kubectl diff`\n\n`kubectl diff` compares a local manifest file against the **live state** of a resource in the cluster, showing exactly what would change if you applied it — without actually making any changes. This is a safe way to review updates before rolling them out.\n\n**Your task:**\n\nA Deployment named `diffme` is running with 1 replica and image `nginx:1.24`.\n\n1. Write a updated manifest for the same Deployment with **3 replicas** and image `nginx:1.25` to `/tmp/diffme-updated.yaml`\n2. Run `kubectl diff` against it to preview the changes\n3. Then **apply** the updated manifest to make the changes live\n\n```bash\n# Preview changes:\nkubectl diff -f /tmp/diffme-updated.yaml\n\n# Apply changes:\nkubectl apply -f /tmp/diffme-updated.yaml\n```",
"hints": [
{
"title": "Write the updated manifest",
"body": "Create a YAML file with the updated replicas and image values.",
"command": "cat <<EOF > /tmp/diffme-updated.yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: diffme\nspec:\n replicas: 3\n selector:\n matchLabels:\n app: diffme\n template:\n metadata:\n labels:\n app: diffme\n spec:\n containers:\n - name: app\n image: nginx:1.25\nEOF"
},
{
"title": "Run kubectl diff",
"body": "kubectl diff exits with code 1 if there are differences (expected), code 0 if nothing changed.",
"command": "kubectl diff -f /tmp/diffme-updated.yaml; echo \"Exit code: $?\""
},
{
"title": "Apply the manifest",
"body": "Once you have reviewed the diff output, apply the manifest to update the live cluster state.",
"command": "kubectl apply -f /tmp/diffme-updated.yaml"
}
],
"setup_commands": [
{
"command": "kubectl create deployment diffme --image=nginx:1.24 --replicas=1 2>/dev/null || true"
},
{
"command": "kubectl rollout status deployment/diffme --timeout=60s"
}
],
"validation": {
"commands": [
{
"description": "Deployment 'diffme' has 3 replicas",
"command": "kubectl get deployment diffme -o jsonpath='{.spec.replicas}'",
"expected_output": "3",
"match": "exact"
},
{
"description": "Deployment uses nginx:1.25 image",
"command": "kubectl get deployment diffme -o jsonpath='{.spec.template.spec.containers[0].image}'",
"expected_output": "nginx:1.25",
"match": "exact"
},
{
"description": "Manifest file exists at /tmp/diffme-updated.yaml",
"command": "test -f /tmp/diffme-updated.yaml && echo 'exists'",
"expected_output": "exists",
"match": "exact"
}
]
},
"default_namespace": "default",
"teardown_commands": [
{
"command": "kubectl delete deployment diffme --ignore-not-found"
},
{
"command": "rm -f /tmp/diffme-updated.yaml"
}
]
}
@@ -0,0 +1,50 @@
{
"id": "kubectl-explain-basics",
"title": "Exploring the API with kubectl explain",
"category": "Core Concepts",
"difficulty": "Easy",
"type": "task",
"weight": 3,
"description": "## Exploring the API with `kubectl explain`\n\n`kubectl explain` is a built-in reference tool that describes the fields of any Kubernetes resource — directly from the live API server. It is invaluable during exams when you need to recall the exact field name or understand what a field accepts.\n\n**Your task:**\n\nA ConfigMap has been created for you. Use `kubectl explain` to answer the following, then:\n\n1. Create a Pod named `explain-demo` using image `nginx:alpine`\n2. Set the pod's `spec.terminationGracePeriodSeconds` to **5** (use `kubectl explain pod.spec.terminationGracePeriodSeconds` to understand the field)\n\n```bash\n# Explore the pod spec:\nkubectl explain pod.spec\n\n# Inspect a specific field:\nkubectl explain pod.spec.terminationGracePeriodSeconds\n```",
"hints": [
{
"title": "kubectl explain syntax",
"body": "Use dot-notation to drill into nested fields. For example: `kubectl explain pod.spec.containers.resources`.",
"command": "kubectl explain pod.spec.terminationGracePeriodSeconds"
},
{
"title": "Create the pod with the field set",
"body": "Use a heredoc to pipe a YAML manifest with terminationGracePeriodSeconds set to 5 directly to kubectl apply.",
"command": "cat <<EOF | kubectl apply -f -\napiVersion: v1\nkind: Pod\nmetadata:\n name: explain-demo\nspec:\n terminationGracePeriodSeconds: 5\n containers:\n - name: app\n image: nginx:alpine\nEOF"
}
],
"setup_commands": [],
"validation": {
"commands": [
{
"description": "Pod 'explain-demo' exists",
"command": "kubectl get pod explain-demo -o jsonpath='{.metadata.name}'",
"expected_output": "explain-demo",
"match": "exact"
},
{
"description": "Pod uses nginx:alpine image",
"command": "kubectl get pod explain-demo -o jsonpath='{.spec.containers[0].image}'",
"expected_output": "nginx:alpine",
"match": "exact"
},
{
"description": "terminationGracePeriodSeconds is 5",
"command": "kubectl get pod explain-demo -o jsonpath='{.spec.terminationGracePeriodSeconds}'",
"expected_output": "5",
"match": "exact"
}
]
},
"default_namespace": "default",
"teardown_commands": [
{
"command": "kubectl delete pod explain-demo --ignore-not-found --grace-period=0 --force"
}
]
}
+51
View File
@@ -0,0 +1,51 @@
{
"id": "kubectl-patch-basics",
"title": "Patching Resources with kubectl patch",
"category": "Core Concepts",
"difficulty": "Medium",
"type": "task",
"weight": 5,
"description": "## Patching Resources with `kubectl patch`\n\n`kubectl patch` lets you surgically update specific fields of a live resource without editing the full manifest. It supports three patch strategies: `merge`, `json`, and `strategic`.\n\n**Your task:**\n\nA Deployment named `patchme` is running in the `default` namespace with 1 replica.\n\n1. Use `kubectl patch` to update its replica count to **3**\n2. Use `kubectl patch` to add a label `env=staging` to the Deployment's **pod template** (`.spec.template.metadata.labels`)\n\n```bash\n# Verify:\nkubectl get deploy patchme -o jsonpath='{.spec.replicas}'\nkubectl get deploy patchme -o jsonpath='{.spec.template.metadata.labels}' \n```",
"hints": [
{
"title": "Patch replicas using merge patch",
"body": "Use `--type=merge` with a JSON snippet targeting the field you want to change.",
"command": "kubectl patch deployment patchme --type=merge -p '{\"spec\":{\"replicas\":3}}'"
},
{
"title": "Patch pod template labels",
"body": "To add labels to the pod template, target spec.template.metadata.labels in your patch body.",
"command": "kubectl patch deployment patchme --type=merge -p '{\"spec\":{\"template\":{\"metadata\":{\"labels\":{\"env\":\"staging\"}}}}}'"
}
],
"setup_commands": [
{
"command": "kubectl create deployment patchme --image=nginx:alpine --replicas=1 2>/dev/null || true"
},
{
"command": "kubectl rollout status deployment/patchme --timeout=60s"
}
],
"validation": {
"commands": [
{
"description": "Deployment 'patchme' has 3 replicas",
"command": "kubectl get deployment patchme -o jsonpath='{.spec.replicas}'",
"expected_output": "3",
"match": "exact"
},
{
"description": "Pod template has label env=staging",
"command": "kubectl get deployment patchme -o jsonpath='{.spec.template.metadata.labels.env}'",
"expected_output": "staging",
"match": "exact"
}
]
},
"default_namespace": "default",
"teardown_commands": [
{
"command": "kubectl delete deployment patchme --ignore-not-found"
}
]
}
+44
View File
@@ -0,0 +1,44 @@
{
"id": "node-cordon-basics",
"title": "Cordoning and Uncordoning a Node",
"category": "Cluster Management",
"difficulty": "Easy",
"type": "task",
"weight": 4,
"description": "## Cordoning and Uncordoning a Node\n\n`kubectl cordon` marks a node as **unschedulable** — the node continues running existing workloads but the scheduler will not place new pods on it. This is useful before maintenance windows.\n\n`kubectl uncordon` reverses the cordon, making the node schedulable again.\n\n**Your task:**\n\n1. Find the name of the cluster node using `kubectl get nodes`\n2. **Cordon** the node\n3. Verify the node shows `SchedulingDisabled` in its status\n4. **Uncordon** the node to restore it to normal\n\n```bash\n# Check node status:\nkubectl get nodes\n\n# Cordon:\nkubectl cordon <node-name>\n\n# Uncordon:\nkubectl uncordon <node-name>\n```",
"hints": [
{
"title": "Find the node name",
"body": "In this single-node cluster, there is exactly one node. Use kubectl get nodes to find its name.",
"command": "kubectl get nodes -o jsonpath='{.items[0].metadata.name}'"
},
{
"title": "Cordon and uncordon using a variable",
"body": "Store the node name in a variable for convenience, then cordon and uncordon it.",
"command": "NODE=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}') && kubectl cordon $NODE && kubectl uncordon $NODE"
}
],
"setup_commands": [],
"validation": {
"commands": [
{
"description": "Node is schedulable (not cordoned)",
"command": "kubectl get nodes -o jsonpath='{.items[0].spec.unschedulable}' 2>/dev/null || echo 'false'",
"expected_output": "false",
"match": "contains"
},
{
"description": "Node is in Ready state",
"command": "kubectl get nodes -o jsonpath='{.items[0].status.conditions[?(@.type==\"Ready\")].status}'",
"expected_output": "True",
"match": "exact"
}
]
},
"default_namespace": "default",
"teardown_commands": [
{
"command": "kubectl uncordon $(kubectl get nodes -o jsonpath='{.items[0].metadata.name}') 2>/dev/null || true"
}
]
}
+50
View File
@@ -0,0 +1,50 @@
{
"id": "pod-env-vars-inline",
"title": "Pod with Inline Environment Variables",
"category": "Configuration",
"difficulty": "Easy",
"type": "task",
"weight": 4,
"description": "## Pod with Inline Environment Variables\n\nContainers can receive configuration through environment variables from three sources:\n1. **Inline literals** — hardcoded directly in the pod spec (`env[].value`)\n2. **ConfigMap** — loaded from a ConfigMap key\n3. **Secret** — loaded from a Secret key\n\nThis scenario focuses on source **#1**: setting environment variables directly in the pod spec.\n\n**Your task:**\n\nCreate a Pod named `env-demo` using image `busybox:1.36` with command `sleep 3600` and the following environment variables set **inline** (not from a ConfigMap or Secret):\n\n| Name | Value |\n|---|---|\n| `APP_COLOR` | `blue` |\n| `APP_MODE` | `production` |\n\n```bash\n# Verify the env vars are set inside the container:\nkubectl exec env-demo -- env | grep APP_\n```",
"hints": [
{
"title": "Setting env vars with kubectl run",
"body": "The quickest way: use `kubectl run` with one `--env` flag per variable.",
"command": "kubectl run env-demo --image=busybox:1.36 --command --env=APP_COLOR=blue --env=APP_MODE=production -- sleep 3600"
},
{
"title": "Setting env vars in a manifest",
"body": "In a YAML manifest, use the `env` array under the container spec. Each entry has a `name` and `value` field.",
"command": "cat <<EOF | kubectl apply -f -\napiVersion: v1\nkind: Pod\nmetadata:\n name: env-demo\nspec:\n containers:\n - name: app\n image: busybox:1.36\n command: [\"sleep\", \"3600\"]\n env:\n - name: APP_COLOR\n value: \"blue\"\n - name: APP_MODE\n value: \"production\"\nEOF"
}
],
"setup_commands": [],
"validation": {
"commands": [
{
"description": "Pod 'env-demo' is Running",
"command": "kubectl get pod env-demo -o jsonpath='{.status.phase}'",
"expected_output": "Running",
"match": "exact"
},
{
"description": "APP_COLOR is set to 'blue'",
"command": "kubectl get pod env-demo -o jsonpath='{.spec.containers[0].env[?(@.name==\"APP_COLOR\")].value}'",
"expected_output": "blue",
"match": "exact"
},
{
"description": "APP_MODE is set to 'production'",
"command": "kubectl get pod env-demo -o jsonpath='{.spec.containers[0].env[?(@.name==\"APP_MODE\")].value}'",
"expected_output": "production",
"match": "exact"
}
]
},
"default_namespace": "default",
"teardown_commands": [
{
"command": "kubectl delete pod env-demo --ignore-not-found --grace-period=0 --force"
}
]
}
@@ -0,0 +1,44 @@
{
"id": "pod-node-assignment-mcq",
"title": "Assigning Pods to Nodes",
"category": "Scheduling",
"difficulty": "Medium",
"type": "mcq",
"weight": 3,
"description": "## Assigning Pods to Nodes\n\nA data-processing Pod **must** run on a specific node named `worker-gpu` that has GPU resources. You want to ensure the Pod is **always** scheduled on that exact node — regardless of node labels.\n\nWhich field in the Pod spec achieves this most directly?",
"options": [
{
"id": "a",
"text": "`spec.nodeSelector: {kubernetes.io/hostname: worker-gpu}` — schedules on nodes matching the label"
},
{
"id": "b",
"text": "`spec.nodeName: worker-gpu` — directly assigns the Pod to the named node, bypassing the scheduler"
},
{
"id": "c",
"text": "`spec.affinity.nodeAffinity` with `requiredDuringSchedulingIgnoredDuringExecution` targeting the node name"
},
{
"id": "d",
"text": "`spec.tolerations` with a toleration matching the node's taint"
}
],
"correct_option": "b",
"explanation": "`spec.nodeName` is the most direct method — the Pod is **directly bound** to the named node, bypassing the Kubernetes scheduler entirely. The pod will only run on that node and will stay in `Pending` if that node is unavailable. `nodeSelector` requires the node to have a matching label (e.g., `kubernetes.io/hostname` is auto-assigned, so option A would also work, but is less direct). `nodeAffinity` is more flexible and preferred for production. `tolerations` allow pods to be scheduled on tainted nodes but do not restrict them to a specific node.",
"hints": [
{
"title": "nodeName vs nodeSelector",
"body": "nodeName bypasses the scheduler and pins the pod to a named node. nodeSelector uses labels for a more flexible approach. For production, prefer nodeAffinity.",
"command": "kubectl explain pod.spec.nodeName"
},
{
"title": "Get the node's auto-assigned hostname label",
"body": "The kubernetes.io/hostname label is automatically set on every node and matches the node name.",
"command": "kubectl get nodes --show-labels"
}
],
"setup_commands": [],
"default_namespace": "default",
"teardown_commands": []
}
@@ -0,0 +1,58 @@
{
"id": "rootnode-runaway-monitor",
"title": "Recover the Monitoring Stack",
"category": "Observability & Ops",
"difficulty": "Medium",
"type": "task",
"weight": 8,
"description": "## Recover the Monitoring Stack\n\nA monitoring agent named `node-metrics` was deployed to the `monitoring` namespace, but its pod never came up. Right now it's stuck **Pending**.\n\nThis is modeled on a real homelab incident: a monitoring component requested far more memory than any node could give it. In production that same class of mistake — an unbounded or oversized monitoring workload — is a classic way to exhaust a node and take the *whole observability stack* down with it.\n\n**Your task:** Diagnose why the pod won't schedule, then **right-size** the resource request so the deployment reaches **1/1 Ready**.\n\n> Important: the goal is to *right-size* the request, not to delete resource governance entirely. A monitoring workload with no requests at all is how you got here in the first place — it should still declare a sane memory request.",
"hints": [
{
"title": "1. See the failure",
"body": "List pods in the monitoring namespace. You'll see node-metrics stuck Pending. Describe the pod and read the Events at the bottom — the scheduler tells you exactly why it can't place the pod.",
"command": "kubectl get pods -n monitoring\nkubectl describe pod -n monitoring -l app=node-metrics"
},
{
"title": "2. Find the bad request",
"body": "A FailedScheduling / Insufficient memory event means the pod is asking for more memory than the node has. Inspect the deployment's resource requests to find the absurd value.",
"command": "kubectl get deploy node-metrics -n monitoring -o jsonpath='{.spec.template.spec.containers[0].resources.requests}'"
},
{
"title": "3. Right-size it",
"body": "Lower the memory request to something a small node can actually satisfy (for an nginx stand-in, 64Mi is plenty). kubectl set resources patches the live deployment and triggers a new rollout. Keep a request set — just make it sane.",
"command": "kubectl set resources deployment node-metrics -n monitoring --requests=memory=64Mi"
}
],
"setup_commands": [
{ "command": "kubectl create namespace monitoring" },
{ "command": "kubectl create deployment node-metrics --image=nginx:1.25 --replicas=1 -n monitoring" },
{ "command": "kubectl set resources deployment node-metrics -n monitoring --requests=memory=64Gi" }
],
"validation": {
"description": "Checks that node-metrics is right-sized: scheduled, 1/1 Ready, and still declaring a memory request.",
"commands": [
{
"description": "Deployment 'node-metrics' has 1 ready replica",
"command": "kubectl get deployment node-metrics -n monitoring -o jsonpath='{.status.readyReplicas}' 2>/dev/null | grep -v '^$' || echo 0",
"expected_output": "1",
"match": "exact"
},
{
"description": "No pods left in Pending state",
"command": "kubectl get pods -n monitoring --no-headers 2>/dev/null | awk '{print $3}' | grep -c 'Pending' || true",
"expected_output": "0",
"match": "exact"
},
{
"description": "Container still declares a memory request (governance not removed)",
"command": "kubectl get deployment node-metrics -n monitoring -o jsonpath='{.spec.template.spec.containers[0].resources.requests.memory}' 2>/dev/null | grep -c '.' || echo 0",
"expected_output": "1",
"match": "exact"
}
]
},
"default_namespace": "monitoring",
"teardown_commands": [
{ "command": "kubectl delete namespace monitoring --ignore-not-found --wait=false" }
]
}
+63
View File
@@ -0,0 +1,63 @@
{
"id": "sort-by-basics",
"title": "Sorting kubectl Output",
"category": "Core Concepts",
"difficulty": "Easy",
"type": "task",
"weight": 3,
"description": "## Sorting `kubectl` Output\n\nThe `--sort-by` flag on `kubectl get` allows you to sort output by any JSONPath expression — extremely useful for finding the newest pod, the heaviest resource consumer, or the oldest event.\n\n**Your task:**\n\nSeveral pods have been created for you. Complete the following:\n\n1. List all pods in the `default` namespace **sorted by their creation timestamp** (oldest first) and save the output to `/tmp/pods-sorted.txt`\n2. List all pods sorted by name and save to `/tmp/pods-by-name.txt`\n\n```bash\n# Sort by creation time:\nkubectl get pods --sort-by=.metadata.creationTimestamp\n\n# Sort by name:\nkubectl get pods --sort-by=.metadata.name\n```",
"hints": [
{
"title": "--sort-by flag syntax",
"body": "Pass any JSONPath expression to --sort-by. The expression must point to a comparable field (string, number, or timestamp).",
"command": "kubectl get pods --sort-by=.metadata.creationTimestamp"
},
{
"title": "Save output to a file",
"body": "Redirect kubectl output using the > operator.",
"command": "kubectl get pods --sort-by=.metadata.creationTimestamp > /tmp/pods-sorted.txt && kubectl get pods --sort-by=.metadata.name > /tmp/pods-by-name.txt"
}
],
"setup_commands": [
{
"command": "kubectl run sort-pod-c --image=nginx:alpine 2>/dev/null || true && sleep 1"
},
{
"command": "kubectl run sort-pod-b --image=nginx:alpine 2>/dev/null || true && sleep 0.5"
},
{
"command": "kubectl run sort-pod-a --image=nginx:alpine 2>/dev/null || true && sleep 0.5"
}
],
"validation": {
"commands": [
{
"description": "File /tmp/pods-sorted.txt exists and contains pod output",
"command": "cat /tmp/pods-sorted.txt",
"expected_output": "sort-pod",
"match": "contains"
},
{
"description": "File /tmp/pods-by-name.txt exists and contains pod output",
"command": "cat /tmp/pods-by-name.txt",
"expected_output": "sort-pod",
"match": "contains"
},
{
"description": "pods-by-name.txt is sorted alphabetically (sort-pod-a appears before sort-pod-c)",
"command": "grep -n 'sort-pod-a' /tmp/pods-by-name.txt | cut -d: -f1",
"expected_output": "^[0-9]+$",
"match": "regex"
}
]
},
"default_namespace": "default",
"teardown_commands": [
{
"command": "kubectl delete pod sort-pod-a sort-pod-b sort-pod-c --ignore-not-found --grace-period=0 --force"
},
{
"command": "rm -f /tmp/pods-sorted.txt /tmp/pods-by-name.txt"
}
]
}
+44
View File
@@ -0,0 +1,44 @@
{
"id": "static-pods-mcq",
"title": "Static Pods in Kubernetes",
"category": "Core Concepts",
"difficulty": "Medium",
"type": "mcq",
"weight": 3,
"description": "## Static Pods in Kubernetes\n\nA cluster administrator wants to run a monitoring agent on every node without relying on the Kubernetes scheduler or API server. The agent must start automatically even if the API server is unavailable.\n\nWhich approach should they use, and where should the manifest be placed?",
"options": [
{
"id": "a",
"text": "Create a DaemonSet — the scheduler places one pod per node automatically"
},
{
"id": "b",
"text": "Create a Static Pod by placing a manifest in the kubelet's static pod directory (typically `/etc/kubernetes/manifests/`)"
},
{
"id": "c",
"text": "Use a CronJob with `--concurrencyPolicy=Forbid` to schedule one pod per node"
},
{
"id": "d",
"text": "Annotate a Deployment with `node-placement: static` to pin it to every node"
}
],
"correct_option": "b",
"explanation": "Static Pods are managed directly by the kubelet daemon on a specific node, **without the API server scheduling them**. The kubelet watches a directory (e.g., `/etc/kubernetes/manifests/`) and automatically creates any pods defined there — even if the API server is down. They are ideal for bootstrapping control-plane components (etcd, kube-apiserver, etc.) and for workloads that must survive API server failures. DaemonSets are managed by the scheduler/API server and won't work if the API server is unavailable. There is no `node-placement: static` annotation.",
"hints": [
{
"title": "What makes a pod 'static'?",
"body": "Static pods are defined as YAML files on the node's filesystem. The kubelet monitors the staticPodPath directory and reconciles the pods itself — no scheduler, no API server required.",
"command": "ls /etc/kubernetes/manifests/ 2>/dev/null || echo 'Static pod dir may differ per distro'"
},
{
"title": "Identifying static pods in the cluster",
"body": "Static pods always have the node name appended to their pod name (e.g., `kube-apiserver-controlplane`). You can also look at the pod's ownerReferences — static pods have no ownerReference.",
"command": "kubectl get pods -n kube-system"
}
],
"setup_commands": [],
"default_namespace": "default",
"teardown_commands": []
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 KiB

After

Width:  |  Height:  |  Size: 477 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 593 KiB

After

Width:  |  Height:  |  Size: 578 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

After

Width:  |  Height:  |  Size: 517 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 KiB

After

Width:  |  Height:  |  Size: 564 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 956 KiB

After

Width:  |  Height:  |  Size: 988 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 595 KiB

After

Width:  |  Height:  |  Size: 565 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 599 KiB

After

Width:  |  Height:  |  Size: 630 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 640 KiB

After

Width:  |  Height:  |  Size: 894 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

+57 -4
View File
@@ -92,7 +92,23 @@ done
kubectl wait --for=condition=Ready nodes --all --timeout=120s kubectl wait --for=condition=Ready nodes --all --timeout=120s
OK "Cluster node is Ready" OK "Cluster node is Ready"
# Phase 3: wait for flannel CNI to write its subnet config. # Phase 3: on resource-constrained hosts k3s can briefly flap right after
# node Ready. Poll for 10s to confirm the API stays up.
LOG "Verifying API server stability..."
for i in $(seq 1 5); do
sleep 2
if ! kubectl get nodes &>/dev/null; then
LOG "API server not yet stable (attempt $i/5), waiting..."
fi
done
if ! kubectl get nodes &>/dev/null; then
ERR "API server became unavailable after node Ready. Aborting."
tail -20 /var/log/k3s.log >&2
exit 1
fi
OK "API server is stable"
# Phase 4: wait for flannel CNI to write its subnet config.
# Pods scheduled before flannel is ready get FailedCreatePodSandBox warnings # Pods scheduled before flannel is ready get FailedCreatePodSandBox warnings
# (missing /run/flannel/subnet.env). Waiting here avoids that noise. # (missing /run/flannel/subnet.env). Waiting here avoids that noise.
for i in $(seq 1 30); do for i in $(seq 1 30); do
@@ -137,6 +153,9 @@ alias klogs='kubectl logs'
kns() { kubectl config set-context --current --namespace="$1"; } kns() { kubectl config set-context --current --namespace="$1"; }
kctx() { kubectl config use-context "$1"; } kctx() { kubectl config use-context "$1"; }
# Load bash-completion framework
[ -f /usr/share/bash-completion/bash_completion ] && source /usr/share/bash-completion/bash_completion
source <(kubectl completion bash) 2>/dev/null || true source <(kubectl completion bash) 2>/dev/null || true
complete -F __start_kubectl k 2>/dev/null || true complete -F __start_kubectl k 2>/dev/null || true
@@ -153,17 +172,50 @@ BASHRC
OK "Shell configured" OK "Shell configured"
# ── 5. Start Node.js API server ────────────────────────────────────────────── # ── 5. k3s watchdog — restart k3s if it crashes ─────────────────────────────
# This loop detects if k3s has crashed and restarts it, keeping the cluster available.
watchdog_k3s() {
while true; do
sleep 15
if ! kill -0 "$K3S_PID" 2>/dev/null; then
LOG "⚠ k3s process died (PID $K3S_PID), restarting..."
k3s server \
--disable=traefik \
--disable=servicelb \
--write-kubeconfig-mode=644 \
--node-name=k8s-lab \
--snapshotter=native \
--kubelet-arg=cgroups-per-qos=false \
--kubelet-arg=enforce-node-allocatable="" \
&>>/var/log/k3s.log &
K3S_PID=$!
LOG "k3s restarted (new PID $K3S_PID), waiting for API..."
for i in $(seq 1 30); do
sleep 2
if kubectl get nodes &>/dev/null; then
# Re-sync the kubeconfig in case it was regenerated
cp /etc/rancher/k3s/k3s.yaml /root/.kube/config 2>/dev/null || true
OK "k3s recovered successfully"
break
fi
done
fi
done
}
watchdog_k3s &
WATCHDOG_PID=$!
# ── 6. Start Node.js API server ──────────────────────────────────────────────
LOG "Starting API server..." LOG "Starting API server..."
cd /app/backend && node server.js &>/var/log/api.log & cd /app/backend && node server.js &>/var/log/api.log &
OK "API server started (port 4000)" OK "API server started (port 4000)"
# ── 6. Browser terminal ────────────────────────────────────────────────────── # ── 7. Browser terminal ──────────────────────────────────────────────────────
# Terminal is served via WebSocket at /shell-ws by the Node.js API server # Terminal is served via WebSocket at /shell-ws by the Node.js API server
# using node-pty — no external ttyd binary needed. # using node-pty — no external ttyd binary needed.
# ── 7. Start nginx reverse proxy ──────────────────────────────────────────── # ── 8. Start nginx reverse proxy ────────────────────────────────────────────
LOG "Starting nginx proxy..." LOG "Starting nginx proxy..."
nginx -g 'daemon off;' &>/var/log/nginx.log & nginx -g 'daemon off;' &>/var/log/nginx.log &
OK "nginx started (port 80)" OK "nginx started (port 80)"
@@ -171,6 +223,7 @@ OK "nginx started (port 80)"
# ── 8. Keep Alive & Graceful Shutdown ──────────────────────────────────────── # ── 8. Keep Alive & Graceful Shutdown ────────────────────────────────────────
cleanup() { cleanup() {
LOG "Caught signal, shutting down KubeKosh..." LOG "Caught signal, shutting down KubeKosh..."
kill -TERM "$WATCHDOG_PID" 2>/dev/null || true
kill -TERM "$K3S_PID" 2>/dev/null || true kill -TERM "$K3S_PID" 2>/dev/null || true
kill $(jobs -p) 2>/dev/null || true kill $(jobs -p) 2>/dev/null || true
exit 0 exit 0