Initial commit

This commit is contained in:
Abhinav Sinha
2026-05-26 20:18:08 +05:30
committed by GitHub
commit c26c0122ce
34 changed files with 8866 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
ENV KUBECONFIG=/etc/rancher/k3s/k3s.yaml
ENV PROGRESS_FILE=/data/progress.json
# k3s writes its kubeconfig here; make kubectl pick it up automatically
ENV K3S_KUBECONFIG_MODE=644
# ── System deps ─────────────────────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y \
curl wget git vim nano jq bash bash-completion \
ca-certificates gnupg lsb-release \
nginx \
iproute2 iptables iputils-ping \
procps htop \
mount kmod \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
# ── Node.js 20 ────────────────────────────────────────────────────────────────
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# ── k3s (bundles kubectl, containerd, everything) ────────────────────────────
# We download the binary and the airgap images so the cluster starts offline.
RUN set -eux && \
case "$(uname -m)" in \
x86_64) K3S_BIN="k3s" ;; \
aarch64) K3S_BIN="k3s-arm64" ;; \
*) echo "Unsupported arch: $(uname -m)" && exit 1 ;; \
esac && \
curl -fsSL "https://github.com/k3s-io/k3s/releases/latest/download/${K3S_BIN}" \
-o /usr/local/bin/k3s && \
chmod +x /usr/local/bin/k3s && \
ln -sf /usr/local/bin/k3s /usr/local/bin/kubectl && \
ln -sf /usr/local/bin/k3s /usr/local/bin/crictl
# ── App files ─────────────────────────────────────────────────────────────────
WORKDIR /app
# Install backend dependencies
COPY backend/package.json ./backend/
RUN cd backend && npm install --production
# Build frontend
COPY frontend/package.json frontend/vite.config.js ./frontend/
RUN cd frontend && npm install
COPY frontend/ ./frontend/
RUN cd frontend && npm run build
# Copy everything else
COPY backend/ ./backend/
COPY scenarios/ ./scenarios/
COPY scripts/entrypoint.sh /entrypoint.sh
COPY scripts/nginx.conf /etc/nginx/nginx.conf
# Strip any Windows-style \r from the entrypoint so heredocs inside it
# don't produce scripts with \r in shebang lines (causes execvp ENOENT).
RUN sed -i 's/\r//' /entrypoint.sh && chmod +x /entrypoint.sh
# ── Directories & k3s static config ─────────────────────────────────────────
RUN mkdir -p /root/.kube /data /var/log /tmp/k8s-state \
&& mkdir -p /var/log/nginx \
&& mkdir -p /etc/rancher/k3s \
&& mkdir -p /var/lib/rancher/k3s
# Tell k3s to use the native snapshotter.
# We rely on k3s to generate its full default containerd config.toml
# (which includes CNI paths, runtimes, etc.) and only override the snapshotter.
# Do NOT place a custom config.toml.tmpl here — a minimal template breaks
# node registration by omitting the CNI and internal-opt sections.
RUN printf 'snapshotter: "native"\nwrite-kubeconfig-mode: "644"\n' \
> /etc/rancher/k3s/config.yaml
# ── Expose ────────────────────────────────────────────────────────────────────
# Single port - nginx proxies everything
EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"]
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 The KubeKosh Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+137
View File
@@ -0,0 +1,137 @@
<div align="center">
<img src="frontend/public/logo.svg" alt="KubeKosh Logo" width="100" />
<h1>KubeKosh</h1>
<p><strong>Self-hosted Kubernetes Lab for Hands-on Learning</strong></p>
<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>
---
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.
## Quick Start
**Prerequisite:** [Docker](https://docs.docker.com/get-docker/)
```bash
docker run -itd --name kubekosh --privileged -p 7554:80 zeborg/kubekosh:latest
```
Open **http://localhost:7554** — wait ~30 seconds for the *Cluster Ready* indicator to turn green.
> `--privileged` is required — K3s needs access to kernel namespaces and cgroups.
### Persist Progress
```bash
docker run -itd --name kubekosh --privileged -p 7554:80 \
-v <your_custom_directory>:/data zeborg/kubekosh:latest
```
Progress is stored in SQLite at `/data/progress.db` inside the container. You may mount your own custom directory to `/data` to persist the progress across container restarts.
### Build From Source
```bash
docker build -t kubekosh .
# multi-platform
docker buildx build --platform linux/amd64,linux/arm64 -t kubekosh .
```
---
## What's Inside
| Bundle | Focus | Exam Mode |
|---|---|---|
| 🌱 Kubernetes Basics | Core concepts | 60 min |
| 🧑‍✈️ Kubernetes Administrator | CKA | 120 min |
| 🛠️ Kubernetes Developer | CKAD | 120 min |
| 🛡️ Kubernetes Security | CKS | 120 min |
**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
The terminal comes pre-configured with:
| Alias | Expands to |
|---|---|
| `k` | `kubectl` |
| `kgp` | `kubectl get pods` |
| `kga` | `kubectl get pods --all-namespaces` |
| `kgd` | `kubectl get deployments` |
| `kgs` | `kubectl get services` |
| `kaf` | `kubectl apply -f` |
| `kex` | `kubectl exec -it` |
| `kns <ns>` | `kubectl config set-context --current --namespace=<ns>` |
---
## Architecture
| Component | Technology |
|---|---|
| Frontend | React + Vite, `xterm.js` |
| Backend | Node.js / Express, `node-pty` WebSocket PTY |
| Cluster | K3s (single-node, in-container) |
| Proxy | nginx on container port `80`, mapped to host port `7554` |
| Storage | SQLite (`better-sqlite3`) at `/data/progress.db` |
Everything runs inside a **single Docker image** managed by `scripts/entrypoint.sh`.
---
## Contributing
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!**
### Adding Scenarios
Scenarios live in `scenarios/scenarios.json`; bundles in `scenarios/bundles.json`. See [`scenarios/SCHEMA.md`](scenarios/SCHEMA.md) for the full schema.
**Task checklist:**
- `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`
### 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. Edit scenarios/scenarios.json (and/or bundles.json)
# 4. Build and test locally
docker build -t kubekosh . && docker run --rm -itd --privileged -p 7554:80 kubekosh
# 5. Commit and push to your fork
git add scenarios/scenarios.json
git commit -m "feat: add <scenario-name> scenario"
git push -u origin feat/my-scenario
```
Open a Pull Request from your fork's branch against `main`.
---
## License
Apache 2.0 License — see [LICENSE](LICENSE).
+14
View File
@@ -0,0 +1,14 @@
{
"name": "kubekosh-api",
"version": "0.1.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"better-sqlite3": "^9.4.3",
"node-pty": "^1.0.0",
"ws": "^8.17.0"
}
}
+537
View File
@@ -0,0 +1,537 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const { execSync, exec } = require('child_process');
const Database = require('better-sqlite3');
const app = express();
const PORT = 4000;
app.use(express.json());
app.use(express.static(path.join(__dirname, '../frontend/dist')));
const SCENARIOS_FILE = path.join(__dirname, '../scenarios/scenarios.json');
const BUNDLES_FILE = path.join(__dirname, '../scenarios/bundles.json');
const DB_FILE = process.env.PROGRESS_DB || '/data/progress.db';
// ── SQLite progress store ─────────────────────────────────────────────────────
let _db = null;
function getDb() {
if (_db) return _db;
const dir = path.dirname(DB_FILE);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
_db = new Database(DB_FILE);
_db.exec(`
CREATE TABLE IF NOT EXISTS progress (
scenario_id TEXT PRIMARY KEY,
status TEXT,
attempts INTEGER DEFAULT 0,
last_validated TEXT,
completed_at TEXT
)
`);
_db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
bundle_id TEXT NOT NULL,
started_at TEXT NOT NULL,
submitted_at TEXT,
status TEXT NOT NULL DEFAULT 'active',
exam_minutes INTEGER NOT NULL DEFAULT 120,
duration_secs INTEGER,
snapshot TEXT
)
`);
return _db;
}
function loadProgress() {
try {
const rows = getDb().prepare('SELECT * FROM progress').all();
return Object.fromEntries(rows.map(r => [r.scenario_id, {
status: r.status,
attempts: r.attempts,
last_validated: r.last_validated,
completed_at: r.completed_at,
}]));
} catch { return {}; }
}
function saveProgress(progress) {
try {
const db = getDb();
const upsert = db.prepare(`
INSERT INTO progress (scenario_id, status, attempts, last_validated, completed_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(scenario_id) DO UPDATE SET
status = excluded.status,
attempts = excluded.attempts,
last_validated = excluded.last_validated,
completed_at = excluded.completed_at
`);
const tx = db.transaction((entries) => {
for (const [id, p] of entries) {
upsert.run(id, p.status, p.attempts || 0, p.last_validated || null, p.completed_at || null);
}
});
tx(Object.entries(progress));
} catch (e) {
console.error('Failed to save progress:', e.message);
}
}
function loadScenarios() {
return JSON.parse(fs.readFileSync(SCENARIOS_FILE, 'utf8'));
}
function loadBundles() {
return JSON.parse(fs.readFileSync(BUNDLES_FILE, 'utf8'));
}
function runCommand(cmd, timeoutMs = 15000) {
try {
const output = execSync(cmd, {
timeout: timeoutMs,
encoding: 'utf8',
env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG || '/root/.kube/config' }
}).trim();
return { success: true, output };
} catch (e) {
return { success: false, output: (e.stdout || '').trim(), error: (e.stderr || e.message || '').trim() };
}
}
function checkMatch(actual, expected, matchType) {
const a = actual.trim();
const e = expected.trim();
if (matchType === 'exact') return a === e;
if (matchType === 'contains') return a.includes(e);
if (matchType === 'not_contains') return !a.includes(e);
if (matchType === 'regex') return new RegExp(e).test(a);
return a === e;
}
// Active WebSocket terminal clients — write output directly (NOT as shell input)
const activeWsClients = new Set()
// Active PTY shells — used only to write '\r' and trigger PS1 prompt repaint
const activeShells = new Set()
// Inject text directly into all terminals as output (never touches shell stdin)
function injectToTerminal(text) {
for (const ws of activeWsClients) {
try {
if (ws.readyState === WebSocket.OPEN) ws.send(text)
} catch (_) {}
}
}
// Write a carriage-return to every active shell so bash repaints its PS1 prompt
function refreshPrompt(delayMs = 80) {
setTimeout(() => {
for (const shell of activeShells) {
try { shell.write('\r') } catch (_) {}
}
}, delayMs)
}
// ── Routes ────────────────────────────────────────────────────────────────────
// POST /api/progress/reset — reset progress for scenario | category | bundle
app.post('/api/progress/reset', (req, res) => {
const { scope, scenarioId, category, bundleId } = req.body;
const db = getDb();
const del = db.prepare('DELETE FROM progress WHERE scenario_id = ?');
try {
if (scope === 'scenario') {
del.run(scenarioId);
} else if (scope === 'category') {
const scenarios = loadScenarios();
const ids = scenarios.filter(s => s.category === category).map(s => s.id);
const tx = db.transaction(ids => ids.forEach(id => del.run(id)));
tx(ids);
} else if (scope === 'bundle') {
const bundle = loadBundles().find(b => b.id === bundleId);
if (!bundle) return res.status(404).json({ error: 'Bundle not found' });
const tx = db.transaction(ids => ids.forEach(id => del.run(id)));
tx(bundle.scenario_ids);
} else {
return res.status(400).json({ error: 'Invalid scope' });
}
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── Exam sessions ─────────────────────────────────────────────────────────────
// POST /api/sessions — start a new exam session
app.post('/api/sessions', (req, res) => {
const { bundleId, examMinutes } = req.body;
const bundle = loadBundles().find(b => b.id === bundleId);
if (!bundle) return res.status(404).json({ error: 'Bundle not found' });
const db = getDb();
const mins = Math.max(5, Math.min(300, Number(examMinutes) || bundle.exam_minutes || 120));
// Abandon any existing active session
db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now')
WHERE status='active'`).run();
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)
VALUES (?, ?, datetime('now'), 'active', ?)`).run(id, bundleId, mins);
res.json({ id, bundleId, status: 'active', exam_minutes: mins });
});
// GET /api/sessions/active — get the current active session
app.get('/api/sessions/active', (req, res) => {
const db = getDb();
const session = db.prepare(`SELECT * FROM sessions WHERE status='active' ORDER BY started_at DESC LIMIT 1`).get();
if (!session) return res.json(null);
const bundle = loadBundles().find(b => b.id === session.bundle_id);
const progress = loadProgress();
const scenarioIds = bundle?.scenario_ids || [];
const completed = scenarioIds.filter(id => progress[id]?.status === 'completed').length;
res.json({ ...session, scenarioCount: scenarioIds.length, completedCount: completed });
});
// POST /api/sessions/:id/submit — submit the exam session
app.post('/api/sessions/:id/submit', (req, res) => {
const db = getDb();
const session = db.prepare(`SELECT * FROM sessions WHERE id=?`).get(req.params.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
const bundle = loadBundles().find(b => b.id === session.bundle_id);
const progress = loadProgress();
const scenarios = loadScenarios();
const bundleScenarios = scenarios.filter(s => bundle?.scenario_ids?.includes(s.id));
const snapshot = bundleScenarios.map(s => ({
id: s.id, title: s.title, weight: s.weight,
category: s.category, type: s.type, difficulty: s.difficulty,
status: progress[s.id]?.status || 'not_started',
completed_at: progress[s.id]?.completed_at || null,
attempts: progress[s.id]?.attempts || 0,
}));
const startedAt = new Date(session.started_at + 'Z');
const durationSecs = Math.round((Date.now() - startedAt.getTime()) / 1000);
db.prepare(`UPDATE sessions SET status='submitted', submitted_at=datetime('now'),
duration_secs=?, snapshot=? WHERE id=?`)
.run(durationSecs, JSON.stringify(snapshot), req.params.id);
res.json({ ok: true, snapshot, durationSecs });
});
// POST /api/sessions/:id/abandon — forfeit the exam without a score report
app.post('/api/sessions/:id/abandon', (req, res) => {
const db = getDb();
const result = db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now')
WHERE id=? AND status='active'`).run(req.params.id);
res.json({ ok: true, changed: result.changes });
});
// POST /api/scenarios/:id/teardown — run teardown_commands
app.post('/api/scenarios/:id/teardown', (req, res) => {
const scenarios = loadScenarios();
const scenario = scenarios.find(s => s.id === req.params.id);
if (!scenario) return res.status(404).json({ error: 'Scenario not found' });
const results = [];
for (const item of (scenario.teardown_commands || [])) {
const cmd = item.command;
const result = runCommand(cmd, 30000);
results.push({ command: cmd, ...result });
}
res.json({ ok: true, results });
});
// ── Context sync (Feature 3) ──────────────────────────────────────────────────
// POST /api/scenarios/:id/context — inject namespace + banner into active terminals
app.post('/api/scenarios/:id/context', (req, res) => {
const scenarios = loadScenarios();
const scenario = scenarios.find(s => s.id === req.params.id);
if (!scenario) return res.status(404).json({ error: 'Scenario not found' });
const ns = scenario.default_namespace || 'default';
// VT sequences: clear visible screen + scrollback, then move cursor to top-left
const clearScreen = '\x1b[2J\x1b[3J\x1b[H';
const line = '\u2500'.repeat(54);
const banner = [
`\x1b[2m# ${line}\x1b[0m\r\n`,
`\x1b[1m\x1b[36m\u2388 Scenario : \x1b[0m\x1b[1m\x1b[97m${scenario.title}\x1b[0m\r\n`,
`\x1b[2m Namespace: \x1b[0m\x1b[33m${ns}\x1b[0m`,
` \x1b[2mDifficulty: \x1b[0m${scenario.difficulty === 'Easy' ? '\x1b[32m' : scenario.difficulty === 'Hard' ? '\x1b[31m' : '\x1b[33m'}${scenario.difficulty}\x1b[0m\r\n`,
`\x1b[2m# ${line}\x1b[0m\r\n`,
].join('');
// Set kubectl context namespace silently
runCommand(`kubectl config set-context --current --namespace=${ns}`, 5000);
// 1) Clear screen and write banner as terminal output (never touches shell stdin)
injectToTerminal(clearScreen + banner);
// 2) After banner renders, write \r to each PTY so bash repaints its PS1 prompt
refreshPrompt(80);
res.json({ ok: true, namespace: ns });
});
// GET /api/bundles — list bundles with per-bundle progress stats
app.get('/api/bundles', (req, res) => {
const bundles = loadBundles();
const scenarios = loadScenarios();
const progress = loadProgress();
const result = bundles.map(b => {
const total = b.scenario_ids.length;
const completed = b.scenario_ids.filter(id => progress[id]?.status === 'completed').length;
return { ...b, stats: { total, completed } };
});
res.json(result);
});
// GET /api/scenarios — list scenarios; optional ?bundle=<id> filter
app.get('/api/scenarios', (req, res) => {
const scenarios = loadScenarios();
const progress = loadProgress();
const { bundle } = req.query;
let filtered = scenarios;
if (bundle) {
const bundles = loadBundles();
const b = bundles.find(x => x.id === bundle);
if (b) filtered = scenarios.filter(s => b.scenario_ids.includes(s.id));
}
const list = filtered.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 }
}));
res.json(list);
});
// GET /api/scenarios/:id — full scenario detail
app.get('/api/scenarios/:id', (req, res) => {
const scenarios = loadScenarios();
const scenario = scenarios.find(s => s.id === req.params.id);
if (!scenario) return res.status(404).json({ error: 'Scenario not found' });
const progress = loadProgress();
res.json({
...scenario,
progress: progress[scenario.id] || { status: 'not_started', attempts: 0 }
});
});
// POST /api/scenarios/:id/setup — run setup_commands for a scenario
app.post('/api/scenarios/:id/setup', (req, res) => {
const scenarios = loadScenarios();
const scenario = scenarios.find(s => s.id === req.params.id);
if (!scenario) return res.status(404).json({ error: 'Scenario not found' });
const results = [];
for (const item of (scenario.setup_commands || [])) {
const cmd = item.command;
const result = runCommand(cmd, 30000);
results.push({ command: cmd, ...result });
if (!result.success) {
// Non-fatal: setup commands like "kubectl create namespace" fail if already exists
console.warn(`Setup command warning: ${cmd} -> ${result.error}`);
}
}
// Mark scenario as in-progress
const progress = loadProgress();
if (!progress[scenario.id] || progress[scenario.id].status === 'not_started') {
progress[scenario.id] = {
...progress[scenario.id],
status: 'in_progress',
attempts: (progress[scenario.id]?.attempts || 0),
started_at: progress[scenario.id]?.started_at || new Date().toISOString()
};
saveProgress(progress);
}
res.json({ setup_results: results });
});
// POST /api/scenarios/:id/validate — validate task-based scenario
app.post('/api/scenarios/:id/validate', (req, res) => {
const scenarios = loadScenarios();
const scenario = scenarios.find(s => s.id === req.params.id);
if (!scenario) return res.status(404).json({ error: 'Scenario not found' });
if (scenario.type !== 'task') return res.status(400).json({ error: 'Not a task scenario' });
const checks = [];
let allPassed = true;
for (const check of (scenario.validation?.commands || [])) {
const result = runCommand(check.command, 5000);
// Prefer stdout. Only fall back to stderr for non-API errors:
// - kubectl auth can-i prints "yes"/"no" to stdout → already in result.output.
// - kubectl get <missing-resource> prints "Error from server (NotFound):" to stderr
// and nothing to stdout → suppress, return '' so the check fails cleanly.
const isKubectlApiError = result.error &&
/^Error from server|^error:|^Error:/i.test(result.error.trim());
const actual = result.output || (isKubectlApiError ? '' : result.error) || '';
const passed = checkMatch(actual, check.expected_output, check.match);
checks.push({
description: check.description,
command: check.command,
expected: check.expected_output,
actual,
passed
});
if (!passed) allPassed = false;
}
// Update progress
const progress = loadProgress();
const prev = progress[scenario.id] || { attempts: 0 };
progress[scenario.id] = {
...prev,
status: allPassed ? 'completed' : 'in_progress',
attempts: (prev.attempts || 0) + 1,
last_validated: new Date().toISOString(),
completed_at: allPassed ? new Date().toISOString() : prev.completed_at
};
saveProgress(progress);
res.json({ passed: allPassed, checks, attempts: progress[scenario.id].attempts });
});
// POST /api/scenarios/:id/answer — submit MCQ answer
app.post('/api/scenarios/:id/answer', (req, res) => {
const { selected } = req.body;
const scenarios = loadScenarios();
const scenario = scenarios.find(s => s.id === req.params.id);
if (!scenario) return res.status(404).json({ error: 'Scenario not found' });
if (scenario.type !== 'mcq') return res.status(400).json({ error: 'Not an MCQ scenario' });
const correct = selected === scenario.correct_option;
const progress = loadProgress();
const prev = progress[scenario.id] || { attempts: 0 };
progress[scenario.id] = {
...prev,
status: correct ? 'completed' : 'in_progress',
attempts: (prev.attempts || 0) + 1,
last_answer: selected,
last_validated: new Date().toISOString(),
completed_at: correct ? new Date().toISOString() : prev.completed_at
};
saveProgress(progress);
res.json({
correct,
correct_option: scenario.correct_option,
explanation: scenario.explanation,
attempts: progress[scenario.id].attempts
});
});
// GET /api/progress — full progress summary
app.get('/api/progress', (req, res) => {
const scenarios = loadScenarios();
const progress = loadProgress();
const total = scenarios.length;
const completed = Object.values(progress).filter(p => p.status === 'completed').length;
const totalWeight = scenarios.reduce((sum, s) => sum + (s.weight || 0), 0);
const earnedWeight = scenarios
.filter(s => progress[s.id]?.status === 'completed')
.reduce((sum, s) => sum + (s.weight || 0), 0);
res.json({
total, completed,
score_pct: totalWeight > 0 ? Math.round((earnedWeight / totalWeight) * 100) : 0,
details: progress
});
});
// POST /api/progress/reset/:id — reset a scenario
app.post('/api/progress/reset/:id', (req, res) => {
const progress = loadProgress();
delete progress[req.params.id];
saveProgress(progress);
res.json({ ok: true });
});
// GET /api/health
app.get('/api/health', (req, res) => {
const kube = runCommand('kubectl cluster-info --request-timeout=3s 2>&1 | head -1');
res.json({ api: 'ok', cluster: kube.success ? 'ready' : 'not_ready', cluster_info: kube.output });
});
// Fallback to frontend SPA
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
});
// ── WebSocket PTY terminal ────────────────────────────────────────────────────
const http = require('http');
const WebSocket = require('ws');
const pty = require('node-pty');
const server = http.createServer(app);
const wss = new WebSocket.Server({ noServer: true });
wss.on('connection', (ws) => {
activeWsClients.add(ws);
const shell = pty.spawn('/bin/bash', [], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: '/root',
env: {
...process.env,
KUBECONFIG: '/root/.kube/config',
HOME: '/root',
TERM: 'xterm-256color',
},
});
activeShells.add(shell);
// Forward PTY output → browser
shell.onData((data) => {
if (ws.readyState === WebSocket.OPEN) ws.send(data);
});
shell.onExit(() => {
activeWsClients.delete(ws);
activeShells.delete(shell);
if (ws.readyState === WebSocket.OPEN) ws.close();
});
// Forward browser input → PTY
ws.on('message', (msg) => {
try {
const parsed = JSON.parse(msg);
if (parsed.type === 'resize') {
shell.resize(Number(parsed.cols) || 80, Number(parsed.rows) || 24);
return;
}
} catch (_) { /* not JSON → raw input */ }
shell.write(typeof msg === 'string' ? msg : msg.toString());
});
ws.on('close', () => { activeWsClients.delete(ws); activeShells.delete(shell); try { shell.kill(); } catch (_) {} });
ws.on('error', () => { activeWsClients.delete(ws); activeShells.delete(shell); try { shell.kill(); } catch (_) {} });
});
// Handle WebSocket upgrades only for /shell-ws
server.on('upgrade', (req, socket, head) => {
if (req.url === '/shell-ws') {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
} else {
socket.destroy();
}
});
server.listen(PORT, () => console.log(`API server running on :${PORT}`));
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KubeKosh — Interactive Kubernetes Playground</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<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">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "kubekosh-ui",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8"
}
}
+59
View File
@@ -0,0 +1,59 @@
<svg height="470" width="480" viewBox="100 95 480 470" role="img" xmlns="http://www.w3.org/2000/svg" style="">
<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>
<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>
<!--
Heptagon, cx=340, cy=340, angle offset=-90°, step=51.4286°
r=220 (outer):
k0: (340, 120)
k1: (340+220cos(-38.57°), 340+220sin(-38.57°)) = (340+171.9, 340-137.1) = (511.9, 202.9)
k2: (340+220cos(12.86°), 340+220sin(12.86°)) = (340+214.4, 340+48.9) = (554.4, 388.9)
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:
delta from center scaled by 0.8091:
k0: (340, 340+(120-340)*0.8091)=(340, 161.9)
k1: (340+171.9*0.8091, 340-137.1*0.8091)=(479.1, 228.9)
k2: (340+214.4*0.8091, 340+48.9*0.8091)=(513.5, 379.6)
k3: (340+95.0*0.8091, 340+198.3*0.8091)=(416.9, 500.5)
k4: (263.1, 500.5)
k5: (166.5, 379.6)
k6: (200.9, 228.9)
r=132 (inner screen), factor=132/220=0.6:
k0: (340, 340+(120-340)*0.6)=(340, 208)
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>

After

Width:  |  Height:  |  Size: 7.0 KiB

+346
View File
@@ -0,0 +1,346 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import Sidebar from './components/Sidebar'
import ScenarioPanel from './components/ScenarioPanel'
import Terminal from './components/Terminal'
import Header from './components/Header'
import BundleNav from './components/BundleNav'
import ExamTimer from './components/ExamTimer'
import ExamReport from './components/ExamReport'
import ExamStartModal from './components/ExamStartModal'
import styles from './App.module.css'
const MIN_SIDEBAR_W = 180
const MAX_SIDEBAR_W = 560
const DEFAULT_SIDEBAR_W = 280
const SIDEBAR_COLLAPSE_PX = 100
const SIDEBAR_COLLAPSED_W = 40
const MIN_TERM_H = 36
const MAX_TERM_H = 600
const DEFAULT_TERM_H = 280
const TERM_COLLAPSE_PX = 60
export default function App() {
const [bundles, setBundles] = useState([])
const [activeBundleId, setActiveBundleId] = useState(null)
const [scenarios, setScenarios] = useState([])
const [activeId, setActiveId] = useState(null)
const [scenario, setScenario] = useState(null)
const [progress, setProgress] = useState({})
const [clusterReady, setClusterReady] = useState(false)
const [loading, setLoading] = useState(true)
// ── Exam mode ─────────────────────────────────────────────────────────────
const [examSession, setExamSession] = useState(null) // active session object
const [examReport, setExamReport] = useState(null) // submitted report
const [examModalBundle, setExamModalBundle] = useState(null) // bundle for start/retry modal
// Sidebar resize / collapse
const [sidebarW, setSidebarW] = useState(DEFAULT_SIDEBAR_W)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const sbDragging = useRef(false)
const sbDragX0 = useRef(0)
const sbDragW0 = useRef(0)
// Terminal resize / collapse
const [termH, setTermH] = useState(300)
const [termCollapsed, setTermCollapsed] = useState(false)
const [bundlesCollapsed, setBundlesCollapsed] = useState(false)
const tmDragging = useRef(false)
const tmDragY0 = useRef(0)
const tmDragH0 = useRef(0)
// Track previous scenario id to teardown on switch
const prevActiveIdRef = useRef(null)
// ── Cluster health ────────────────────────────────────────────────────────
useEffect(() => {
async function check() {
try {
const d = await fetch('/api/health').then(r => r.json())
setClusterReady(d.cluster === 'ready')
} catch { setClusterReady(false) }
}
check()
const t = setInterval(check, 8000)
return () => clearInterval(t)
}, [])
// ── Load bundles (once) ───────────────────────────────────────────────────
useEffect(() => {
fetch('/api/bundles')
.then(r => r.json())
.then(data => {
setBundles(data)
if (data.length > 0) setActiveBundleId(data[0].id)
})
.catch(console.error)
}, [])
// ── Restore active exam session on load ───────────────────────────────────
useEffect(() => {
fetch('/api/sessions/active')
.then(r => r.json())
.then(s => { if (s) setExamSession(s) })
.catch(() => { })
}, [])
// ── Load scenarios for active bundle ─────────────────────────────────────
useEffect(() => {
if (!activeBundleId) return
setLoading(true)
setActiveId(null)
setScenario(null)
const url = `/api/scenarios?bundle=${activeBundleId}`
fetch(url)
.then(r => r.json())
.then(data => {
setScenarios(data)
setProgress(Object.fromEntries(data.map(s => [s.id, s.progress])))
})
.catch(console.error)
.finally(() => setLoading(false))
}, [activeBundleId])
// ── Load full scenario when selected — teardown previous, load new ─────────
useEffect(() => {
if (!activeId) return
// Teardown the previously active scenario's cluster state on switch
const prevId = prevActiveIdRef.current
if (prevId && prevId !== activeId) {
fetch(`/api/scenarios/${prevId}/teardown`, { method: 'POST' }).catch(() => { })
}
prevActiveIdRef.current = activeId
setScenario(null)
fetch(`/api/scenarios/${activeId}`)
.then(r => r.json())
.then(s => {
setScenario(s)
// Feature 3: sync terminal context when scenario selected
fetch(`/api/scenarios/${activeId}/context`, { method: 'POST' }).catch(() => { })
})
.catch(console.error)
}, [activeId])
const refreshProgress = useCallback(async () => {
const [bundleData, scenarioData] = await Promise.all([
fetch('/api/bundles').then(r => r.json()),
fetch(`/api/scenarios?bundle=${activeBundleId}`).then(r => r.json()),
])
setBundles(bundleData)
setScenarios(scenarioData)
setProgress(Object.fromEntries(scenarioData.map(s => [s.id, s.progress])))
if (activeId) {
const d2 = await fetch(`/api/scenarios/${activeId}`).then(r => r.json())
setScenario(d2)
}
// Refresh exam session completion count
if (examSession) {
const updated = await fetch('/api/sessions/active').then(r => r.json()).catch(() => null)
if (updated) setExamSession(updated)
}
}, [activeBundleId, activeId, examSession])
// ── Exam actions ──────────────────────────────────────────────────────────
const startExam = useCallback(async (bundleId, customMinutes) => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bundleId, examMinutes: customMinutes }),
}).then(r => r.json())
// refresh to get scenarioCount
const active = await fetch('/api/sessions/active').then(r => r.json())
setExamSession(active)
setActiveBundleId(bundleId)
setActiveId(null)
setScenario(null)
}, [])
const submitExam = useCallback(async () => {
if (!examSession) return
const result = await fetch(`/api/sessions/${examSession.id}/submit`, { method: 'POST' })
.then(r => r.json())
const bundle = bundles.find(b => b.id === examSession.bundle_id)
setExamReport({ ...result, bundle })
setExamSession(null)
}, [examSession, bundles])
const abandonExam = useCallback(async () => {
if (!examSession) return
await fetch(`/api/sessions/${examSession.id}/abandon`, { method: 'POST' }).catch(() => { })
setExamSession(null)
}, [examSession])
// ── Teardown when restarting a scenario (Feature 2) ───────────────────────
const handleScenarioStart = useCallback(async (scenarioId) => {
// Run teardown first to clean cluster state
await fetch(`/api/scenarios/${scenarioId}/teardown`, { method: 'POST' }).catch(() => { })
// Then setup will be called by ScenarioPanel as before
}, [])
// ── Sidebar drag ─────────────────────────────────────────────────────────
const onSidebarDragDown = useCallback((e) => {
e.preventDefault()
sbDragging.current = true
sbDragX0.current = e.clientX
sbDragW0.current = sidebarCollapsed ? SIDEBAR_COLLAPSED_W : sidebarW
function onMove(ev) {
if (!sbDragging.current) return
const newW = sbDragW0.current + (ev.clientX - sbDragX0.current)
if (newW < SIDEBAR_COLLAPSE_PX) {
setSidebarCollapsed(true)
} else {
setSidebarCollapsed(false)
setSidebarW(Math.min(MAX_SIDEBAR_W, Math.max(MIN_SIDEBAR_W, newW)))
}
}
function onUp() {
sbDragging.current = false
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}, [sidebarCollapsed, sidebarW])
// ── Terminal drag ────────────────────────────────────────────────────────
const onTermDragDown = useCallback((e) => {
e.preventDefault()
tmDragging.current = true
tmDragY0.current = e.clientY
tmDragH0.current = termCollapsed ? MIN_TERM_H : termH
function onMove(ev) {
if (!tmDragging.current) return
const newH = tmDragH0.current + (tmDragY0.current - ev.clientY)
if (newH < TERM_COLLAPSE_PX) {
setTermCollapsed(true)
} else {
setTermCollapsed(false)
setTermH(Math.min(MAX_TERM_H, Math.max(MIN_TERM_H + 40, newH)))
}
}
function onUp() {
tmDragging.current = false
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}, [termCollapsed, termH])
const currentSidebarW = sidebarCollapsed ? SIDEBAR_COLLAPSED_W : sidebarW
const currentTermH = termCollapsed ? MIN_TERM_H : termH
const activeBundle = bundles.find(b => b.id === activeBundleId) || null
const isMcq = scenario?.type === 'mcq'
return (
<div className={styles.app}>
<Header clusterReady={clusterReady} />
{/* Bundle navigation bar */}
<BundleNav
bundles={bundles}
activeBundleId={activeBundleId}
examSession={examSession}
onSelect={id => {
// In exam mode, only allow switching within the exam bundle
if (examSession && id !== examSession.bundle_id) return
setActiveBundleId(id); setActiveId(null); setScenario(null)
}}
onProgressUpdate={refreshProgress}
onStartExam={setExamModalBundle}
collapsed={bundlesCollapsed}
onToggleCollapse={() => setBundlesCollapsed(c => !c)}
/>
{/* Exam timer bar */}
{examSession && (
<ExamTimer
session={examSession}
bundle={activeBundle}
onSubmit={submitExam}
onAbandon={abandonExam}
/>
)}
<div className={styles.body}>
{/* Sidebar */}
<Sidebar
scenarios={scenarios}
activeId={activeId}
onSelect={setActiveId}
loading={loading}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(c => !c)}
width={currentSidebarW}
activeBundleId={activeBundleId}
onProgressUpdate={refreshProgress}
/>
{/* Sidebar resize handle */}
<div className={styles.sidebarHandle} onMouseDown={onSidebarDragDown} />
{/* Main area */}
<div className={styles.main}>
<div className={styles.scenarioWrap}>
<ScenarioPanel
scenario={scenario}
onProgressUpdate={refreshProgress}
onScenarioStart={handleScenarioStart}
isExamMode={!!examSession}
/>
</div>
{!isMcq && (
<div className={styles.termHandle} onMouseDown={onTermDragDown} />
)}
{!isMcq && (
<div className={styles.terminalWrap} style={{ height: currentTermH }}>
<Terminal
collapsed={termCollapsed}
onToggleCollapse={() => setTermCollapsed(c => !c)}
/>
</div>
)}
</div>
</div>
{/* Exam report modal */}
{examReport && (
<ExamReport
report={examReport}
bundle={examReport.bundle}
onClose={() => setExamReport(null)}
onRetry={() => {
setExamReport(null)
setExamModalBundle(examReport.bundle)
}}
/>
)}
{/* Exam start modal */}
{examModalBundle && (
<ExamStartModal
bundle={examModalBundle}
onStart={mins => {
setExamModalBundle(null)
startExam(examModalBundle.id, mins)
}}
onCancel={() => setExamModalBundle(null)}
/>
)}
<footer className={styles.footer}>
<div>&copy; {new Date().getFullYear()} The KubeKosh Project &bull; All rights reserved</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>
</div>
</footer>
</div>
)
}
+139
View File
@@ -0,0 +1,139 @@
.app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.body {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
min-width: 0;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
/* Scenario panel */
.scenarioWrap {
flex: 1;
overflow: hidden;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
}
/* ── Sidebar resize handle ────────────────────────────────────────────────── */
.sidebarHandle {
flex-shrink: 0;
width: 5px;
cursor: ew-resize;
background: var(--border);
transition: background 0.15s;
position: relative;
z-index: 10;
user-select: none;
}
.sidebarHandle:hover,
.sidebarHandle:active {
background: var(--green);
}
/* Grip indicator for sidebar */
.sidebarHandle::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 3px;
height: 32px;
border-radius: 2px;
background: var(--border2);
pointer-events: none;
}
.sidebarHandle:hover::before,
.sidebarHandle:active::before {
background: var(--surface);
}
/* ── Terminal resize handle ───────────────────────────────────────────────── */
.termHandle {
flex-shrink: 0;
height: 5px;
cursor: ns-resize;
background: var(--border);
transition: background 0.15s;
position: relative;
z-index: 10;
user-select: none;
}
.termHandle:hover,
.termHandle:active {
background: var(--green);
}
/* Grip indicator */
.termHandle::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 32px;
height: 3px;
border-radius: 2px;
background: var(--border2);
pointer-events: none;
}
.termHandle:hover::before,
.termHandle:active::before {
background: var(--surface);
}
/* ── Terminal wrapper ─────────────────────────────────────────────────────── */
.terminalWrap {
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
transition: height 0.15s ease;
}
/* ── Footer ───────────────────────────────────────────────────────────────── */
.footer {
flex-shrink: 0;
text-align: center;
padding: 6px 12px;
font-size: 10px;
color: var(--text-3);
background: var(--surface);
border-top: 1px solid var(--border);
font-family: var(--sans);
letter-spacing: 0.3px;
line-height: 1.5;
}
.footerLink {
color: var(--text-2);
text-decoration: none;
font-weight: 600;
transition: color 0.15s;
}
.footerLink:hover {
color: var(--green);
text-decoration: underline;
}
.heart {
display: inline-block;
font-size: 9px;
margin: 0 1px;
transform: translateY(-0.5px);
}
+146
View File
@@ -0,0 +1,146 @@
import { useState, useRef } from 'react'
import styles from './BundleNav.module.css'
async function resetProgress(scope, opts) {
await fetch('/api/progress/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scope, ...opts }),
})
}
export default function BundleNav({
bundles, activeBundleId, examSession, onSelect, onProgressUpdate, onStartExam, collapsed, onToggleCollapse
}) {
const trackRef = useRef(null)
const [isDragging, setIsDragging] = useState(false)
const [startX, setStartX] = useState(0)
const [scrollLeft, setScrollLeft] = useState(0)
const [dragDist, setDragDist] = useState(0)
const handleMouseDown = (e) => {
if (!trackRef.current) return
setIsDragging(true)
setDragDist(0)
setStartX(e.pageX - trackRef.current.offsetLeft)
setScrollLeft(trackRef.current.scrollLeft)
}
const handleMouseLeaveOrUp = () => {
setIsDragging(false)
}
const handleMouseMove = (e) => {
if (!isDragging || !trackRef.current) return
e.preventDefault()
const x = e.pageX - trackRef.current.offsetLeft
const walk = x - startX
if (Math.abs(walk) > 5) setDragDist(Math.abs(walk))
trackRef.current.scrollLeft = scrollLeft - walk
}
return (
<>
<nav className={`${styles.nav} ${collapsed ? styles.collapsed : ''}`} aria-label="Scenario bundles">
{!collapsed && (
<div
className={`${styles.track} ${(isDragging && dragDist > 5) ? styles.dragging : ''}`}
ref={trackRef}
onMouseDown={handleMouseDown}
onMouseLeave={handleMouseLeaveOrUp}
onMouseUp={handleMouseLeaveOrUp}
onMouseMove={handleMouseMove}
>
{bundles.map(b => {
const active = b.id === activeBundleId
const isExamBundle = examSession?.bundle_id === b.id
const lockedByExam = examSession && !isExamBundle
const pct = b.stats.total > 0
? Math.round((b.stats.completed / b.stats.total) * 100)
: 0
return (
<button
key={b.id}
className={`${styles.tab} ${active ? styles.active : ''} ${lockedByExam ? styles.locked : ''}`}
style={{ '--bcolor': b.color, '--bdim': b.colorDim }}
onClick={(e) => {
if (dragDist > 5) {
e.preventDefault()
e.stopPropagation()
return
}
if (!lockedByExam) onSelect(b.id)
}}
aria-pressed={active}
title={lockedByExam ? 'Abandon current exam to switch bundles' : b.tagline}
>
{/* Top row: icon + text + count + actions */}
<div className={styles.tabTop}>
<span className={styles.icon}>{b.icon}</span>
<div className={styles.text}>
<span className={styles.name}>{b.name}</span>
<span className={styles.tagline}>{b.tagline}</span>
</div>
<div className={styles.countWrap}>
<span className={styles.countNum}>{b.stats.completed}/{b.stats.total}</span>
<span className={styles.countPct}>{pct}%</span>
</div>
{/* Start Exam button — shown on hover when not in exam mode */}
{!examSession && !lockedByExam && (
<button
className={styles.examBtn}
title={`Start timed exam for "${b.name}" (${b.exam_minutes ?? 120} min recommended)`}
onClick={e => {
e.stopPropagation()
onStartExam?.(b)
}}
>
Exam
</button>
)}
{/* In-exam indicator */}
{isExamBundle && (
<span className={styles.examBadge}>🏁 In Progress</span>
)}
{/* Reset button */}
{b.stats.completed > 0 && !examSession && (
<button
className={styles.bundleResetBtn}
title={`Reset all progress in "${b.name}"`}
onClick={async e => {
e.stopPropagation()
if (!window.confirm(`Reset all progress in "${b.name}"?`)) return
await resetProgress('bundle', { bundleId: b.id })
onProgressUpdate?.()
}}
>
</button>
)}
</div>
{/* Inline progress track */}
<div className={styles.progressTrack}>
<div className={styles.progressFill} style={{ width: `${pct}%` }} />
</div>
</button>
)
})}
</div>
)}
{!collapsed && <div className={styles.fadeOverlay} />}
<div
className={styles.collapseWrap}
onClick={onToggleCollapse}
title={collapsed ? "Show Bundles" : "Hide Bundles"}
>
{collapsed ? '▼' : '▲'}
</div>
</nav>
</>
)
}
@@ -0,0 +1,241 @@
.nav {
flex-shrink: 0;
background: var(--surface);
border-bottom: 1px solid var(--border);
overflow: hidden;
display: flex;
align-items: center;
justify-content: flex-end;
position: relative;
}
.fadeOverlay {
position: absolute;
top: 0;
right: 42px; /* 8px margin + 26px width + 8px gap */
height: 100%;
width: 40px;
background: linear-gradient(to right, transparent, var(--surface));
pointer-events: none;
z-index: 5;
}
/* Horizontal scroll container — no visible scrollbar */
.track {
display: flex;
flex: 1;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
gap: 4px;
padding: 8px 10px;
}
.track::-webkit-scrollbar { display: none; }
.track {
cursor: grab;
}
.track.dragging {
cursor: grabbing;
}
.track.dragging * {
pointer-events: none; /* Prevent text selection and hover states while dragging */
}
.collapseWrap {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
margin: 0 8px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
color: var(--text-3);
font-size: 11px;
transition: background 0.15s, color 0.15s;
user-select: none;
}
.collapseWrap:hover {
background: var(--surface2);
color: var(--text);
}
/* ── Tab button ────────────────────────────────────────────────────────────── */
.tab {
position: relative;
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 8px;
padding: 9px 16px 10px;
min-width: 210px;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
color: var(--text-2);
font-family: var(--sans);
text-align: left;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.tab:hover {
background: var(--surface2);
border-color: var(--border2);
}
.tab.active {
background: var(--bdim, rgba(63,185,80,0.10));
border-color: color-mix(in srgb, var(--bcolor) 45%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--bcolor) 20%, transparent) inset;
}
/* ── Top row ───────────────────────────────────────────────────────────────── */
.tabTop {
display: flex;
align-items: flex-start;
gap: 10px;
}
.icon {
font-size: 18px;
line-height: 1;
flex-shrink: 0;
margin-top: 1px;
}
.text {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.name {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.1px;
color: var(--text);
}
.tab.active .name { color: var(--bcolor, var(--green)); }
.tagline {
font-size: 11px;
color: var(--text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Count badge: stacked number + percentage */
.countWrap {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
flex-shrink: 0;
}
.countNum {
font-family: var(--mono);
font-size: 12px;
font-weight: 700;
color: var(--bcolor, var(--text-2));
opacity: 0.9;
}
.countPct {
font-family: var(--mono);
font-size: 10px;
color: var(--text-3);
}
.tab.active .countPct { color: var(--bcolor, var(--text-3)); opacity: 0.7; }
/* Bundle reset button */
.bundleResetBtn {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: var(--text-3);
padding: 2px 5px;
border-radius: 4px;
line-height: 1;
margin-left: 2px;
transition: color 0.12s, background 0.12s;
}
.bundleResetBtn:hover {
color: var(--red);
background: var(--red-dim);
}
/* ── Inline progress track ─────────────────────────────────────────────────── */
.progressTrack {
width: 100%;
height: 3px;
background: var(--surface3);
border-radius: 2px;
overflow: hidden;
}
.tab.active .progressTrack {
background: color-mix(in srgb, var(--bcolor) 25%, var(--border2));
}
.progressFill {
height: 100%;
background: var(--bcolor, var(--green));
border-radius: 2px;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Glow on active tab's fill */
.tab.active .progressFill {
box-shadow: 0 0 6px color-mix(in srgb, var(--bcolor) 60%, transparent);
}
/* Locked tab during exam */
.tab.locked {
opacity: 0.4;
cursor: not-allowed;
filter: grayscale(0.5);
}
/* Start Exam button */
.examBtn {
flex-shrink: 0;
background: var(--blue-dim);
border: 1px solid color-mix(in srgb, var(--blue) 35%, transparent);
color: var(--blue);
border-radius: 5px;
padding: 3px 8px;
font-size: 11px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s, transform 0.1s;
white-space: nowrap;
}
.tab:hover .examBtn { opacity: 1; }
.examBtn:hover { background: var(--blue); color: #fff; transform: scale(1.05); }
/* In-exam badge */
.examBadge {
flex-shrink: 0;
background: rgba(252,196,25,0.15);
border: 1px solid rgba(252,196,25,0.35);
color: var(--amber);
border-radius: 5px;
padding: 3px 8px;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
+104
View File
@@ -0,0 +1,104 @@
import styles from './ExamReport.module.css'
function formatDuration(secs) {
if (!secs) return '—'
const h = Math.floor(secs / 3600)
const m = Math.floor((secs % 3600) / 60)
const s = secs % 60
if (h > 0) return `${h}h ${m}m ${s}s`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
const DIFF_COLOR = { Easy: 'var(--green)', Medium: 'var(--amber)', Hard: 'var(--red)' }
export default function ExamReport({ report, bundle, onClose, onRetry }) {
if (!report) return null
const { snapshot, durationSecs } = report
const completed = snapshot.filter(s => s.status === 'completed')
const totalWeight = snapshot.reduce((a, s) => a + (s.weight || 0), 0)
const earnedWeight = completed.reduce((a, s) => a + (s.weight || 0), 0)
const pct = totalWeight > 0 ? Math.round((earnedWeight / totalWeight) * 100) : 0
// Group by category
const byCategory = snapshot.reduce((acc, s) => {
;(acc[s.category] = acc[s.category] || []).push(s)
return acc
}, {})
const passed = pct >= 66
return (
<div className={styles.overlay} onClick={e => e.target === e.currentTarget && onClose()}>
<div className={styles.modal}>
{/* Header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.bundleIcon}>{bundle?.icon || '🎓'}</span>
<div>
<div className={styles.examLabel}>Exam Report</div>
<div className={styles.bundleName}>{bundle?.name}</div>
</div>
</div>
<button className={styles.closeBtn} onClick={onClose}></button>
</div>
{/* Score hero */}
<div className={`${styles.hero} ${passed ? styles.passed : styles.failed}`}>
<div className={styles.scoreRing}>
<svg viewBox="0 0 80 80" className={styles.ring}>
<circle cx="40" cy="40" r="34" className={styles.ringTrack} />
<circle
cx="40" cy="40" r="34"
className={styles.ringFill}
strokeDasharray={`${2 * Math.PI * 34}`}
strokeDashoffset={`${2 * Math.PI * 34 * (1 - pct / 100)}`}
style={{ stroke: passed ? 'var(--green)' : 'var(--red)' }}
/>
</svg>
<span className={styles.pctText}>{pct}%</span>
</div>
<div className={styles.heroMeta}>
<div className={`${styles.verdict} ${passed ? styles.verdictPass : styles.verdictFail}`}>
{passed ? '✅ Passed' : '❌ Not Yet Passing'}
</div>
<div className={styles.heroStats}>
<span>{completed.length}/{snapshot.length} scenarios</span>
<span>·</span>
<span>{earnedWeight}/{totalWeight} points</span>
<span>·</span>
<span> {formatDuration(durationSecs)}</span>
</div>
<div className={styles.passMark}>Pass mark: 66%</div>
</div>
</div>
{/* Breakdown by category */}
<div className={styles.breakdown}>
{Object.entries(byCategory).map(([cat, items]) => (
<div key={cat} className={styles.catGroup}>
<div className={styles.catTitle}>{cat}</div>
{items.map(s => (
<div key={s.id} className={`${styles.row} ${s.status === 'completed' ? styles.rowDone : ''}`}>
<span className={styles.rowIcon}>{s.status === 'completed' ? '✅' : '⬜'}</span>
<span className={styles.rowTitle}>{s.title}</span>
<span className={styles.rowDiff} style={{ color: DIFF_COLOR[s.difficulty] }}>
{s.difficulty}
</span>
<span className={styles.rowPts}>{s.status === 'completed' ? s.weight : 0}/{s.weight} pts</span>
</div>
))}
</div>
))}
</div>
{/* Actions */}
<div className={styles.actions}>
<button className={styles.retryBtn} onClick={onRetry}>🔄 Start New Exam</button>
<button className={styles.closeBtn2} onClick={onClose}>Close</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,127 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.modal {
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-lg);
width: min(680px, 95vw);
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 24px 60px rgba(0,0,0,0.4);
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px 14px;
border-bottom: 1px solid var(--border);
}
.headerLeft { display: flex; align-items: center; gap: 12px; }
.bundleIcon { font-size: 28px; }
.examLabel { font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; color: var(--text-3); }
.bundleName { font-size: 16px; font-weight: 700; color: var(--text); }
.closeBtn {
background: none; border: none; cursor: pointer;
color: var(--text-3); font-size: 18px; padding: 4px 8px;
border-radius: 6px; transition: color 0.15s, background 0.15s;
}
.closeBtn:hover { color: var(--text); background: var(--surface3); }
/* Score hero */
.hero {
display: flex;
align-items: center;
gap: 24px;
padding: 24px 28px;
border-bottom: 1px solid var(--border);
}
.passed { background: var(--green-dim); }
.failed { background: var(--red-dim); }
.scoreRing { position: relative; width: 80px; height: 80px; flex-shrink: 0; }
.ring { width: 80px; height: 80px; transform: rotate(-90deg); }
.ringTrack { fill: none; stroke: var(--surface3); stroke-width: 6; }
.ringFill {
fill: none; stroke-width: 6; stroke-linecap: round;
transition: stroke-dashoffset 1s ease;
}
.pctText {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-family: var(--mono); font-size: 16px; font-weight: 700; color: var(--text);
}
.heroMeta { flex: 1; }
.verdict { font-size: 20px; font-weight: 800; margin-bottom: 6px; }
.verdictPass { color: var(--green); }
.verdictFail { color: var(--red); }
.heroStats { display: flex; gap: 8px; font-size: 13px; color: var(--text-2); margin-bottom: 4px; }
.passMark { font-size: 11px; color: var(--text-3); }
/* Breakdown */
.breakdown {
flex: 1;
overflow-y: auto;
padding: 16px 22px;
display: flex;
flex-direction: column;
gap: 18px;
}
.catGroup { display: flex; flex-direction: column; gap: 4px; }
.catTitle {
font-size: 10px; font-weight: 800; letter-spacing: 0.8px;
text-transform: uppercase; color: var(--text-3);
padding-bottom: 6px; border-bottom: 1px solid var(--border);
margin-bottom: 2px;
}
.row {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border-radius: 6px;
font-size: 13px; color: var(--text-2);
transition: background 0.1s;
}
.row:hover { background: var(--surface2); }
.rowDone { color: var(--text); }
.rowIcon { font-size: 14px; flex-shrink: 0; }
.rowTitle { flex: 1; }
.rowDiff { font-size: 11px; font-weight: 600; flex-shrink: 0; }
.rowPts { font-family: var(--mono); font-size: 12px; color: var(--text-3); flex-shrink: 0; min-width: 60px; text-align: right; }
/* Actions */
.actions {
display: flex; gap: 10px; justify-content: flex-end;
padding: 14px 22px;
border-top: 1px solid var(--border);
}
.retryBtn {
padding: 8px 18px;
background: var(--blue); color: #fff;
border: none; border-radius: 8px;
font-size: 13px; font-weight: 700; font-family: var(--sans);
cursor: pointer; transition: opacity 0.15s;
}
.retryBtn:hover { opacity: 0.85; }
.closeBtn2 {
padding: 8px 18px;
background: var(--surface3); color: var(--text);
border: 1px solid var(--border); border-radius: 8px;
font-size: 13px; font-weight: 600; font-family: var(--sans);
cursor: pointer; transition: background 0.15s;
}
.closeBtn2:hover { background: var(--border); }
@@ -0,0 +1,99 @@
import { useState, useEffect, useRef } from 'react'
import styles from './ExamStartModal.module.css'
export default function ExamStartModal({ bundle, onStart, onCancel }) {
const [minutes, setMinutes] = useState(bundle?.exam_minutes || 120)
const inputRef = useRef(null)
useEffect(() => {
// Focus input on open
const t = setTimeout(() => inputRef.current?.select(), 60)
return () => clearTimeout(t)
}, [])
if (!bundle) return null
const numMinutes = Number(minutes)
const isValid = numMinutes >= 5 && numMinutes <= 300
const handleStart = () => {
if (!isValid) return
onStart(numMinutes)
}
const presets = [
{ label: '30 min', value: 30 },
{ label: '60 min', value: 60 },
{ label: '90 min', value: 90 },
{ label: '120 min', value: 120 },
]
return (
<div className={styles.overlay} onClick={e => e.target === e.currentTarget && onCancel()}>
<div className={styles.modal}>
<div className={styles.header}>
<span className={styles.icon}>{bundle.icon}</span>
<div>
<div className={styles.title}>Start Exam</div>
<div className={styles.bundleName}>{bundle.name}</div>
</div>
</div>
<div className={styles.body}>
<div className={styles.info}>
<span>📋</span>
<span>{bundle.scenario_ids?.length || '?'} scenarios · Recommended: <strong>{bundle.exam_minutes} min</strong></span>
</div>
<div className={styles.field}>
<label className={styles.label}>Exam Duration</label>
<div className={styles.presets}>
{presets.map(p => (
<button
key={p.value}
className={`${styles.preset} ${minutes === p.value ? styles.presetActive : ''}`}
onClick={() => setMinutes(p.value)}
style={{ '--bcolor': bundle.color }}
>
{p.label}
</button>
))}
</div>
<div className={styles.customRow}>
<input
ref={inputRef}
type="number"
className={styles.input}
value={minutes}
min={5}
max={300}
onChange={e => setMinutes(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleStart()}
/>
<span className={styles.unit}>minutes</span>
</div>
<div className={styles.hint}>
{!isValid ? (
<span style={{ color: 'var(--red)' }}> Duration must be between 5 and 300 minutes</span>
) : numMinutes < 60 ? '⚡ Speed run mode' :
numMinutes <= 120 ? '🎯 Realistic exam timing' :
'🧘 Relaxed practice pace'}
</div>
</div>
</div>
<div className={styles.actions}>
<button className={styles.cancelBtn} onClick={onCancel}>Cancel</button>
<button
className={styles.startBtn}
onClick={handleStart}
disabled={!isValid}
style={{ background: bundle.color }}
>
Start Exam
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,186 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.55);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 900;
animation: fadeIn 0.15s ease;
}
.modal {
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-lg);
width: min(420px, 94vw);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
animation: slideUp 0.2s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.header {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 22px 14px;
border-bottom: 1px solid var(--border);
}
.icon { font-size: 30px; }
.title {
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-3);
}
.bundleName {
font-size: 17px;
font-weight: 800;
color: var(--text);
}
.body {
padding: 18px 22px;
display: flex;
flex-direction: column;
gap: 16px;
}
.info {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-2);
background: var(--surface2);
padding: 8px 12px;
border-radius: 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 10px;
}
.label {
font-size: 12px;
font-weight: 700;
color: var(--text-2);
letter-spacing: 0.3px;
}
.presets {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.preset {
padding: 5px 12px;
border: 1px solid var(--border);
border-radius: 20px;
background: var(--surface2);
color: var(--text-2);
font-size: 12px;
font-weight: 600;
font-family: var(--sans);
cursor: pointer;
transition: all 0.15s;
}
.preset:hover {
border-color: var(--bcolor, var(--border2));
color: var(--text);
}
.preset.presetActive {
background: color-mix(in srgb, var(--bcolor, var(--blue)) 15%, transparent);
border-color: var(--bcolor, var(--blue));
color: var(--bcolor, var(--blue));
}
.customRow {
display: flex;
align-items: center;
gap: 8px;
}
.input {
width: 90px;
padding: 8px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 16px;
font-family: var(--mono);
font-weight: 700;
text-align: center;
transition: border-color 0.15s;
outline: none;
}
.input:focus { border-color: var(--blue); }
.unit {
font-size: 13px;
color: var(--text-3);
}
.hint {
font-size: 12px;
color: var(--text-3);
font-style: italic;
}
.actions {
display: flex;
gap: 10px;
justify-content: flex-end;
padding: 14px 22px;
border-top: 1px solid var(--border);
}
.cancelBtn {
padding: 8px 18px;
background: var(--surface3);
color: var(--text-2);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
font-weight: 600;
font-family: var(--sans);
cursor: pointer;
transition: background 0.15s;
}
.cancelBtn:hover { background: var(--border); color: var(--text); }
.startBtn {
padding: 8px 22px;
border: none;
border-radius: 8px;
color: #fff;
font-size: 13px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
.startBtn:hover:not(:disabled) { opacity: 0.88; transform: scale(1.03); }
.startBtn:active:not(:disabled) { transform: scale(0.97); }
.startBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
filter: grayscale(1);
}
+91
View File
@@ -0,0 +1,91 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import styles from './ExamTimer.module.css'
function formatTime(secs) {
if (secs < 0) secs = 0
const h = Math.floor(secs / 3600)
const m = Math.floor((secs % 3600) / 60)
const s = secs % 60
if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
}
export default function ExamTimer({ session, bundle, onSubmit, onAbandon }) {
const [elapsed, setElapsed] = useState(0)
const autoSubmitted = useRef(false)
// session.exam_minutes is set at start time with the user's custom value
const durationSecs = (session?.exam_minutes || bundle?.exam_minutes || 120) * 60
useEffect(() => {
if (!session) return
autoSubmitted.current = false
const startedAt = new Date(session.started_at + (session.started_at.endsWith('Z') ? '' : 'Z'))
const tick = () => {
const el = Math.floor((Date.now() - startedAt.getTime()) / 1000)
if (el >= durationSecs && !autoSubmitted.current) {
autoSubmitted.current = true
setElapsed(durationSecs)
onSubmit()
} else if (!autoSubmitted.current) {
setElapsed(el)
}
}
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [session, durationSecs, onSubmit])
const remaining = durationSecs - elapsed
const pct = Math.min(100, (elapsed / durationSecs) * 100)
const urgent = remaining < 600 // < 10 min
const handleSubmit = useCallback(async () => {
if (!window.confirm(`Submit exam now?\n\n${session.completedCount || 0} of ${session.scenarioCount || '?'} scenarios completed.`)) return
onSubmit()
}, [session, onSubmit])
const handleAbandon = useCallback(() => {
if (!window.confirm('Abandon this exam?\n\nYour progress will be saved but no score report will be generated.')) return
onAbandon()
}, [onAbandon])
if (!session) return null
return (
<div className={`${styles.timer} ${urgent ? styles.urgent : ''}`}>
<div className={styles.left}>
<span className={styles.icon}></span>
<div className={styles.meta}>
<span className={styles.label}>EXAM MODE</span>
<span className={styles.bundleName}>{bundle?.name}</span>
</div>
</div>
<div className={styles.center}>
<div className={styles.timeDisplay}>
<span className={styles.elapsed}>{formatTime(elapsed)}</span>
<span className={styles.sep}>/</span>
<span className={styles.total}>{formatTime(durationSecs)}</span>
{urgent && <span className={styles.urgentTag}> Running out of time</span>}
</div>
<div className={styles.bar}>
<div className={styles.fill} style={{ width: `${pct}%` }} />
</div>
<div className={styles.progress}>
{session.completedCount || 0} / {session.scenarioCount || '?'} completed
</div>
</div>
<div className={styles.right}>
<button className={styles.abandonBtn} onClick={handleAbandon} title="Abandon exam (no score report)">
Abandon
</button>
<button className={styles.submitBtn} onClick={handleSubmit}>
Submit Exam
</button>
</div>
</div>
)
}
@@ -0,0 +1,146 @@
.timer {
display: flex;
align-items: center;
gap: 20px;
padding: 10px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
animation: slideIn 0.3s ease;
transition: background 0.25s;
}
.timer.urgent {
background: rgba(255, 107, 107, 0.08);
border-bottom-color: rgba(255, 107, 107, 0.35);
}
.timer.urgent .elapsed { color: var(--red); }
.timer.urgent .fill { background: var(--red); }
.left {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.icon { font-size: 20px; }
.meta {
display: flex;
flex-direction: column;
gap: 1px;
}
.label {
font-size: 9px;
font-weight: 800;
letter-spacing: 1px;
color: var(--blue);
text-transform: uppercase;
}
.bundleName {
font-size: 12px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.center {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.timeDisplay {
display: flex;
align-items: baseline;
gap: 4px;
}
.elapsed {
font-family: var(--mono);
font-size: 18px;
font-weight: 700;
color: var(--text);
line-height: 1;
}
.sep {
font-size: 14px;
color: var(--text-3);
}
.total {
font-family: var(--mono);
font-size: 13px;
color: var(--text-3);
}
.bar {
height: 4px;
background: var(--surface3);
border-radius: 2px;
overflow: hidden;
}
.fill {
height: 100%;
background: var(--blue);
border-radius: 2px;
transition: width 1s linear;
}
.progress {
font-size: 11px;
color: var(--text-3);
}
.right {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
}
.abandonBtn {
padding: 7px 14px;
background: none;
color: var(--red);
border: 1px solid color-mix(in srgb, var(--red) 40%, transparent);
border-radius: 8px;
font-size: 12px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
transition: background 0.15s, transform 0.1s;
white-space: nowrap;
}
.abandonBtn:hover { background: var(--red-dim); transform: scale(1.03); }
.abandonBtn:active { transform: scale(0.97); }
.submitBtn {
padding: 7px 18px;
background: var(--blue);
color: #fff;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
white-space: nowrap;
}
.submitBtn:hover { opacity: 0.85; transform: scale(1.03); }
.submitBtn:active { transform: scale(0.97); }
.urgentTag {
font-size: 11px;
font-weight: 700;
color: var(--red);
animation: pulse 1.5s infinite;
margin-left: 6px;
}
+60
View File
@@ -0,0 +1,60 @@
import { useState, useEffect } from 'react'
import styles from './Header.module.css'
export default function Header({ clusterReady }) {
const [theme, setTheme] = useState(
() => localStorage.getItem('kubekosh-theme') || 'dark'
)
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('kubekosh-theme', theme)
}, [theme])
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark')
return (
<header className={styles.header}>
<div className={styles.brand}>
<div className={styles.logo}>
<img src="/logo.svg" alt="KubeKosh Logo" className={styles.logoImage} />
<span className={styles.logoText}>KubeKosh</span>
<span className={styles.version}>v0.1.0</span>
</div>
<span className={styles.tagline}>Interactive Kubernetes Playground</span>
</div>
<div className={styles.right}>
{/* GitHub link */}
<a
href="https://github.com/zeborg/kubekosh"
target="_blank"
rel="noopener noreferrer"
className={styles.githubBtn}
title="Visit GitHub repository"
aria-label="GitHub Repository"
>
<svg viewBox="0 0 512 512" width="16" height="16" fill="currentColor">
<path d="M256 6.3C114.6 6.3 0 120.9 0 262.3c0 113.3 73.3 209 175 242.9 12.8 2.2 17.6-5.4 17.6-12.2 0-6.1-.3-26.2-.3-47.7-64.3 11.8-81-15.7-86.1-30.1-2.9-7.4-15.4-30.1-26.2-36.2-9-4.8-21.8-16.6-.3-17 20.2-.3 34.6 18.6 39.4 26.2 23 38.7 59.8 27.8 74.6 21.1 2.2-16.6 9-27.8 16.3-34.2-57-6.4-116.5-28.5-116.5-126.4 0-27.8 9.9-50.9 26.2-68.8-2.6-6.4-11.5-32.6 2.6-67.8 0 0 21.4-6.7 70.4 26.2 20.5-5.8 42.2-8.6 64-8.6s43.5 2.9 64 8.6c49-33.3 70.4-26.2 70.4-26.2 14.1 35.2 5.1 61.4 2.6 67.8 16.3 17.9 26.2 40.6 26.2 68.8 0 98.2-59.8 120-116.8 126.4 9.3 8 17.3 23.4 17.3 47.4 0 34.2-.3 61.8-.3 70.4 0 6.7 4.8 14.7 17.6 12.2C438.7 471.3 512 375.3 512 262.3c0-141.4-114.6-256-256-256" fillRule="evenodd" clipRule="evenodd"/>
</svg>
</a>
{/* Theme toggle */}
<button
className={styles.themeBtn}
onClick={toggleTheme}
title={theme === 'dark' ? 'Switch to Light mode' : 'Switch to Dark mode'}
aria-label="Toggle theme"
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
{/* Cluster status */}
<div className={`${styles.clusterBadge} ${clusterReady ? styles.ready : styles.notReady}`}>
<span className={styles.dot} />
<span>{clusterReady ? 'Cluster Ready' : 'Connecting…'}</span>
</div>
</div>
</header>
)
}
+145
View File
@@ -0,0 +1,145 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
height: 52px;
padding: 0 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 24px;
z-index: 100;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.logo {
display: flex;
align-items: center;
gap: 8px;
}
.logoImage {
width: 22px;
height: 22px;
display: block;
}
.logoText {
font-family: var(--sans);
font-weight: 900;
font-size: 19px;
letter-spacing: -0.5px;
color: var(--text);
}
.version {
font-size: 11px;
font-family: var(--mono);
color: var(--text-2);
background: var(--surface2);
border: 1px solid var(--border);
padding: 1px 5px;
border-radius: 4px;
margin-left: 2px;
}
.tagline {
font-size: 12px;
color: var(--text-3);
font-family: var(--mono);
border-left: 1px solid var(--border2);
padding-left: 12px;
}
.right {
flex-shrink: 0;
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
}
.githubBtn {
background: none;
border: 1px solid var(--border);
border-radius: 8px;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.2s, color 0.15s;
color: var(--text-2);
line-height: 1;
text-decoration: none;
}
.githubBtn:hover {
background: var(--surface2);
border-color: var(--border2);
transform: rotate(12deg);
color: var(--text);
}
.themeBtn {
background: none;
border: 1px solid var(--border);
border-radius: 8px;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.2s;
line-height: 1;
}
.themeBtn:hover {
background: var(--surface2);
border-color: var(--border2);
transform: rotate(12deg);
}
.clusterBadge {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 12px;
border-radius: 20px;
font-family: var(--mono);
font-size: 12px;
font-weight: 500;
border: 1px solid;
}
.clusterBadge.ready {
background: var(--green-dim);
border-color: rgba(57,217,138,0.3);
color: var(--green);
}
.clusterBadge.notReady {
background: var(--amber-dim);
border-color: rgba(252,196,25,0.3);
color: var(--amber);
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.ready .dot {
animation: pulse 2s infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
+376
View File
@@ -0,0 +1,376 @@
import { useState, useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import styles from './ScenarioPanel.module.css'
// Inline markdown: renders without a wrapping <p> — safe for buttons/spans
const inlineComponents = {
p: ({ children }) => <>{children}</>,
code: ({ children }) => <code className="inline-code">{children}</code>,
}
function InlineMd({ children }) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={inlineComponents}>
{children}
</ReactMarkdown>
)
}
async function resetProgress(scope, opts) {
await fetch('/api/progress/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scope, ...opts }),
})
}
export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioStart, isExamMode }) {
const [tab, setTab] = useState('problem')
const [setupState, setSetupState] = useState('idle') // idle | running | done | error
const [validating, setValidating] = useState(false)
const [validResult, setValidResult] = useState(null)
const [selectedOption, setSelectedOption] = useState(null)
const [mcqResult, setMcqResult] = useState(null)
const [submitting, setSubmitting] = useState(false)
const [hintsRevealed, setHintsRevealed] = useState([])
const [copiedCmd, setCopiedCmd] = useState(null)
// Reset state when scenario changes
useEffect(() => {
setTab('problem')
setSetupState('idle')
setValidResult(null)
setSelectedOption(null)
setMcqResult(null)
setHintsRevealed([])
if (scenario?.progress?.status === 'completed') {
setSetupState('done')
}
}, [scenario?.id])
async function runSetup() {
setSetupState('running')
try {
// Feature 2: teardown first to ensure clean cluster state
await fetch(`/api/scenarios/${scenario.id}/teardown`, { method: 'POST' }).catch(() => {})
await onScenarioStart?.(scenario.id)
await fetch(`/api/scenarios/${scenario.id}/setup`, { method: 'POST' })
setSetupState('done')
} catch {
setSetupState('error')
}
}
async function validate() {
setValidating(true)
setValidResult(null)
try {
const r = await fetch(`/api/scenarios/${scenario.id}/validate`, { method: 'POST' })
const d = await r.json()
setValidResult(d)
onProgressUpdate()
} catch {
setValidResult({ error: true })
}
setValidating(false)
}
async function submitMCQ() {
if (!selectedOption) return
setSubmitting(true)
try {
const r = await fetch(`/api/scenarios/${scenario.id}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selected: selectedOption })
})
const d = await r.json()
setMcqResult(d)
onProgressUpdate()
} catch {}
setSubmitting(false)
}
function copyCmd(cmd, idx) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(cmd).then(() => {
setCopiedCmd(idx)
setTimeout(() => setCopiedCmd(null), 1800)
})
} else {
const textArea = document.createElement("textarea")
textArea.value = cmd
textArea.style.position = "fixed"
textArea.style.left = "-999999px"
textArea.style.top = "-999999px"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
setCopiedCmd(idx)
setTimeout(() => setCopiedCmd(null), 1800)
} catch (err) {
console.error('Fallback copy failed', err)
}
textArea.remove()
}
}
if (!scenario) {
return (
<div className={styles.empty}>
<div className={styles.emptyIcon}></div>
<div className={styles.emptyTitle}>Select a scenario</div>
<div className={styles.emptySub}>Choose from the left panel to start practising</div>
</div>
)
}
const isCompleted = scenario.progress?.status === 'completed'
return (
<div className={styles.panel}>
{/* Scenario header */}
<div className={styles.scenarioHeader}>
<div className={styles.scenarioMeta}>
<span className={styles.category}>{scenario.category}</span>
<span className={`${styles.diff} ${styles[scenario.difficulty?.toLowerCase()]}`}>
{scenario.difficulty}
</span>
<span className={styles.typeTag}>{scenario.type === 'mcq' ? 'Multiple Choice' : 'Hands-on Task'}</span>
<span className={styles.weight}>{scenario.weight} pts</span>
</div>
<div className={styles.titleRow}>
<div className={styles.scenarioTitle}>{scenario.title}</div>
{scenario.progress?.status !== 'not_started' && scenario.progress?.attempts > 0 && (
<button
className={styles.resetBtn}
title="Reset progress for this scenario"
onClick={async () => {
if (!window.confirm(`Reset progress for "${scenario.title}"?`)) return
await resetProgress('scenario', { scenarioId: scenario.id })
setSelectedOption(null)
setMcqResult(null)
setValidResult(null)
setSetupState('idle')
setHintsRevealed([])
onProgressUpdate()
}}
>
Reset
</button>
)}
</div>
{isCompleted && (
<div className={styles.completedBanner}>
<span></span> Scenario completed
</div>
)}
</div>
{/* Tabs */}
<div className={styles.tabs}>
{['problem', ...(isExamMode ? [] : ['hints']), ...(scenario.type === 'task' && !isExamMode ? ['validate'] : [])].map(t => (
<button
key={t}
className={`${styles.tab} ${tab === t ? styles.activeTab : ''}`}
onClick={() => setTab(t)}
>
{t === 'problem' ? '📄 Problem'
: t === 'hints' ? `💡 Hints (${scenario.hints?.length || 0})`
: '✓ Validate'}
</button>
))}
</div>
{/* Tab content */}
<div className={styles.content}>
{/* PROBLEM TAB */}
{tab === 'problem' && (
<div className={styles.tabPane} style={{ animation: 'fadeIn 0.2s ease' }}>
{/* Setup section (if setup commands exist) */}
{scenario.setup_commands?.length > 0 && (
<div className={styles.setupBox}>
<div className={styles.setupHeader}>
<div className={styles.setupLabel}>
<span></span> Ready to start?
</div>
{setupState === 'idle' && (
<button className={styles.setupBtn} onClick={runSetup}>
Start Scenario
</button>
)}
{setupState === 'running' && (
<div className={styles.setupRunning}>
<span className={styles.spinner} />Setting up
</div>
)}
{setupState === 'done' && (
<span className={styles.setupDone}> Environment ready</span>
)}
{setupState === 'error' && (
<button className={styles.setupBtnRetry} onClick={runSetup}> Retry</button>
)}
</div>
<div className={styles.setupNote}>
Click <strong>Start Scenario</strong> to provision the lab environment, then solve the challenge below.
</div>
</div>
)}
{/* Problem description */}
<div className="md">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{scenario.description}</ReactMarkdown>
</div>
{/* MCQ options */}
{scenario.type === 'mcq' && (
<div className={styles.mcqSection}>
<div className={styles.mcqLabel}>Select your answer:</div>
<div className={styles.options}>
{scenario.options?.map(opt => {
const isSelected = selectedOption === opt.id
const showCorrect = mcqResult && opt.id === mcqResult.correct_option
const showWrong = mcqResult && isSelected && !mcqResult.correct
return (
<button
key={opt.id}
className={`${styles.option}
${isSelected ? styles.optionSelected : ''}
${showCorrect ? styles.optionCorrect : ''}
${showWrong ? styles.optionWrong : ''}
`}
onClick={() => !mcqResult && setSelectedOption(opt.id)}
disabled={!!mcqResult}
>
<span className={styles.optionLetter}>{opt.id.toUpperCase()}</span>
<span className={styles.optionText}>
<InlineMd>{opt.text}</InlineMd>
</span>
{showCorrect && <span className={styles.optionMark}></span>}
{showWrong && <span className={styles.optionMark}></span>}
</button>
)
})}
</div>
{!mcqResult ? (
<button
className={styles.submitBtn}
onClick={submitMCQ}
disabled={!selectedOption || submitting}
>
{submitting ? 'Checking…' : 'Submit Answer'}
</button>
) : (
<div className={`${styles.mcqResult} ${mcqResult.correct ? styles.mcqCorrect : styles.mcqWrong}`}>
<div className={styles.mcqResultTitle}>
{mcqResult.correct ? '✓ Correct!' : '✗ Incorrect — see the highlighted answer above'}
</div>
{mcqResult.explanation && (
<div className={styles.mcqExplanation}>
<InlineMd>{mcqResult.explanation}</InlineMd>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* HINTS TAB */}
{tab === 'hints' && (
<div className={styles.tabPane} style={{ animation: 'fadeIn 0.2s ease' }}>
{scenario.hints?.length === 0 && (
<div className={styles.noHints}>No hints available for this scenario.</div>
)}
{scenario.hints?.map((hint, i) => {
const revealed = hintsRevealed.includes(i)
return (
<div key={i} className={styles.hintCard}>
<div className={styles.hintHeader} onClick={() => setHintsRevealed(h => revealed ? h.filter(x => x !== i) : [...h, i])}>
<div className={styles.hintLeft}>
<span className={styles.hintNum}>Hint {i + 1}</span>
<span className={styles.hintTitle}>{hint.title}</span>
</div>
<span className={styles.hintChevron}>{revealed ? '▾' : '▸'}</span>
</div>
{revealed && (
<div className={styles.hintBody} style={{ animation: 'fadeIn 0.15s ease' }}>
<p className={styles.hintText}>
<InlineMd>{hint.body}</InlineMd>
</p>
{hint.command && (
<div className={styles.cmdBlock}>
<pre className={styles.cmdPre}>{hint.command}</pre>
<button
className={styles.copyBtn}
onClick={() => copyCmd(hint.command, i)}
>
{copiedCmd === i ? '✓ Copied' : 'Copy'}
</button>
</div>
)}
</div>
)}
</div>
)
})}
</div>
)}
{/* VALIDATE TAB */}
{tab === 'validate' && scenario.type === 'task' && (
<div className={styles.tabPane} style={{ animation: 'fadeIn 0.2s ease' }}>
<div className={styles.validateHeader}>
<div className={styles.validateDesc}>
{scenario.validation?.description}
</div>
<button
className={styles.validateBtn}
onClick={validate}
disabled={validating}
>
{validating
? <><span className={styles.spinner} /> Running checks</>
: '▶ Run Validation'}
</button>
</div>
{validResult && !validResult.error && (
<div className={styles.checks}>
<div className={`${styles.checksSummary} ${validResult.passed ? styles.allPassed : styles.someFailed}`}>
{validResult.passed
? `✓ All ${validResult.checks.length} checks passed!`
: `${validResult.checks.filter(c => !c.passed).length} of ${validResult.checks.length} checks failed`}
<span className={styles.attempts}>Attempt #{validResult.attempts}</span>
</div>
{validResult.checks.map((c, i) => (
<div key={i} className={`${styles.check} ${c.passed ? styles.checkPass : styles.checkFail}`}>
<span className={styles.checkIcon}>{c.passed ? '✓' : '✗'}</span>
<div className={styles.checkContent}>
<div className={styles.checkDesc}>{c.description}</div>
{!c.passed && (
<div className={styles.checkDetail}>
<span>Expected: <code>{c.expected}</code></span>
<span>Got: <code>{c.actual || '(empty)'}</code></span>
</div>
)}
</div>
</div>
))}
</div>
)}
{validResult?.error && (
<div className={styles.validateError}> Validation failed to run. Is the cluster reachable?</div>
)}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,516 @@
.panel {
display: flex;
flex-direction: column;
flex: 1; /* fill scenarioWrap entirely */
min-height: 0;
overflow: hidden;
background: var(--bg);
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-3);
}
.emptyIcon { font-size: 40px; opacity: 0.2; }
.emptyTitle { font-size: 16px; font-weight: 700; color: var(--text-2); }
.emptySub { font-size: 13px; }
/* Header */
.scenarioHeader {
padding: 14px 20px 10px;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
.scenarioMeta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.category {
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-3);
}
.diff {
font-family: var(--mono);
font-size: 10px;
font-weight: 600;
padding: 2px 7px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
border: 1px solid transparent;
}
.diff.easy { background: var(--green-dim); color: var(--green); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.diff.medium { background: var(--amber-dim); color: var(--amber); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.diff.hard { background: var(--red-dim); color: var(--red); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.typeTag {
font-family: var(--mono);
font-size: 10px;
color: var(--blue);
background: var(--blue-dim);
padding: 2px 7px;
border-radius: 3px;
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
}
.weight {
font-family: var(--mono);
font-size: 11px;
color: var(--text-3);
margin-left: auto;
}
.scenarioTitle {
font-size: 19px;
font-weight: 800;
color: var(--text);
letter-spacing: -0.4px;
line-height: 1.3;
flex: 1;
}
.titleRow {
display: flex;
align-items: flex-start;
gap: 12px;
margin-top: 2px;
}
.resetBtn {
flex-shrink: 0;
background: var(--amber-dim);
border: 1px solid color-mix(in srgb, var(--amber) 40%, transparent);
border-radius: 6px;
color: var(--amber);
font-size: 12px;
font-family: var(--sans);
font-weight: 700;
padding: 5px 12px;
cursor: pointer;
margin-top: 2px;
white-space: nowrap;
letter-spacing: 0.2px;
transition: color 0.12s, border-color 0.12s, background 0.12s;
}
.resetBtn:hover {
color: var(--red);
border-color: color-mix(in srgb, var(--red) 50%, transparent);
background: var(--red-dim);
}
.completedBanner {
margin-top: 8px;
font-size: 12px;
font-weight: 600;
color: var(--green);
background: var(--green-dim);
border: 1px solid rgba(57,217,138,0.25);
padding: 4px 10px;
border-radius: 5px;
display: inline-flex;
align-items: center;
gap: 6px;
}
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
padding: 0 16px;
}
.tab {
padding: 10px 14px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-family: var(--sans);
font-size: 13px;
font-weight: 600;
color: var(--text-3);
transition: all 0.12s;
margin-bottom: -1px;
}
.tab:hover { color: var(--text-2); }
.activeTab {
color: var(--text);
border-bottom-color: var(--green);
}
/* Content */
.content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.tabPane { animation: fadeIn 0.2s ease; }
/* Setup box */
.setupBox {
background: var(--amber-dim);
border: 1px solid rgba(252,196,25,0.25);
border-radius: var(--radius);
padding: 12px 16px;
margin-bottom: 20px;
}
.setupHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.setupLabel {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 700;
color: var(--amber);
}
.setupBtn {
background: var(--amber);
color: #000;
border: none;
border-radius: 6px;
padding: 6px 14px;
font-size: 12px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
transition: opacity 0.15s;
}
.setupBtn:hover { opacity: 0.85; }
.setupBtnRetry {
composes: setupBtn;
background: var(--red);
color: white;
}
.setupRunning {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--amber);
}
.setupDone { font-size: 12px; color: var(--green); font-weight: 600; }
.setupNote { font-size: 12px; color: var(--text-2); }
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
/* MCQ */
.mcqSection { margin-top: 24px; }
.mcqLabel {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--text-3);
margin-bottom: 12px;
}
.options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.option {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
font-family: var(--sans);
font-size: 14px;
color: var(--text);
text-align: left;
transition: all 0.12s;
}
.option:hover:not(:disabled) { border-color: var(--border2); background: var(--surface2); }
.option:disabled { cursor: default; }
.optionSelected { border-color: var(--blue); background: var(--blue-dim); }
.optionCorrect { border-color: var(--green) !important; background: var(--green-dim) !important; }
.optionWrong { border-color: var(--red) !important; background: var(--red-dim) !important; }
.optionLetter {
width: 26px;
height: 26px;
border-radius: 50%;
border: 1.5px solid var(--border2);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
color: var(--text-2);
flex-shrink: 0;
}
.optionSelected .optionLetter { border-color: var(--blue); color: var(--blue); background: var(--blue-dim); }
.optionCorrect .optionLetter { border-color: var(--green); color: var(--green); }
.optionWrong .optionLetter { border-color: var(--red); color: var(--red); }
.optionText { flex: 1; line-height: 1.5; color: var(--text-2); }
.optionMark { font-size: 14px; font-weight: 700; flex-shrink: 0; }
.optionCorrect .optionMark { color: var(--green); }
.optionWrong .optionMark { color: var(--red); }
.submitBtn {
background: var(--green);
color: #000;
border: none;
border-radius: var(--radius);
padding: 10px 22px;
font-size: 14px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
transition: opacity 0.15s;
}
.submitBtn:hover:not(:disabled) { opacity: 0.85; }
.submitBtn:disabled { opacity: 0.4; cursor: not-allowed; }
.mcqResult {
margin-top: 16px;
padding: 14px 16px;
border-radius: var(--radius);
border: 1px solid;
}
.mcqCorrect { background: var(--green-dim); border-color: rgba(57,217,138,0.3); }
.mcqWrong { background: var(--red-dim); border-color: rgba(255,107,107,0.3); }
.mcqResultTitle {
font-size: 14px;
font-weight: 700;
margin-bottom: 8px;
color: var(--text);
}
.mcqExplanation { font-size: 13px; color: var(--text-2); line-height: 1.6; }
/* Hints */
.hintCard {
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 8px;
overflow: hidden;
background: var(--surface);
}
.hintHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
cursor: pointer;
transition: background 0.12s;
}
.hintHeader:hover { background: var(--surface2); }
.hintLeft { display: flex; align-items: center; gap: 10px; }
.hintNum {
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
color: var(--amber);
background: var(--amber-dim);
padding: 2px 7px;
border-radius: 3px;
}
.hintTitle { font-size: 13px; font-weight: 600; color: var(--text); }
.hintChevron { color: var(--text-3); font-size: 12px; }
.hintBody {
padding: 4px 14px 14px;
border-top: 1px solid var(--border);
background: var(--bg);
}
.hintText { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-bottom: 10px; margin-top: 10px; }
.noHints { font-size: 13px; color: var(--text-3); padding: 20px 0; text-align: center; }
.cmdBlock {
position: relative;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.cmdPre {
padding: 12px 14px;
font-family: var(--mono);
font-size: 12px;
color: var(--green);
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
padding-right: 70px;
}
.copyBtn {
position: absolute;
top: 8px;
right: 8px;
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: 5px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
color: var(--text-2);
cursor: pointer;
font-family: var(--sans);
transition: all 0.12s;
}
.copyBtn:hover { color: var(--text); border-color: var(--green); }
/* Validate */
.validateHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 20px;
}
.validateDesc { font-size: 13px; color: var(--text-2); line-height: 1.6; flex: 1; }
.validateBtn {
display: flex;
align-items: center;
gap: 8px;
background: var(--green);
color: #000;
border: none;
border-radius: var(--radius);
padding: 9px 18px;
font-size: 13px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
flex-shrink: 0;
transition: opacity 0.15s;
white-space: nowrap;
}
.validateBtn:hover:not(:disabled) { opacity: 0.85; }
.validateBtn:disabled { opacity: 0.5; cursor: not-allowed; }
.checks { display: flex; flex-direction: column; gap: 6px; }
.checksSummary {
padding: 10px 14px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.allPassed { background: var(--green-dim); color: var(--green); border: 1px solid rgba(57,217,138,0.3); }
.someFailed { background: var(--red-dim); color: var(--red); border: 1px solid rgba(255,107,107,0.3); }
.attempts { font-family: var(--mono); font-size: 11px; opacity: 0.7; }
.check {
display: flex;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
align-items: flex-start;
}
.checkPass { border-color: rgba(57,217,138,0.2); }
.checkFail { border-color: rgba(255,107,107,0.2); background: rgba(255,107,107,0.03); }
.checkIcon {
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
margin-top: 1px;
}
.checkPass .checkIcon { color: var(--green); }
.checkFail .checkIcon { color: var(--red); }
.checkContent { flex: 1; min-width: 0; }
.checkDesc { font-size: 13px; color: var(--text); margin-bottom: 4px; }
.checkDetail {
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: 12px;
color: var(--text-3);
}
.checkDetail code {
font-family: var(--mono);
background: var(--surface3);
padding: 1px 5px;
border-radius: 3px;
color: var(--amber);
font-size: 11px;
}
.validateError {
padding: 12px 14px;
background: var(--amber-dim);
border: 1px solid rgba(252,196,25,0.3);
border-radius: var(--radius);
font-size: 13px;
color: var(--amber);
}
@keyframes fadeIn { from{opacity:0;transform:translateY(4px)} to{opacity:1;transform:none} }
@keyframes spin { to{transform:rotate(360deg)} }
+210
View File
@@ -0,0 +1,210 @@
import { useState, useMemo } from 'react'
import styles from './Sidebar.module.css'
const DIFF_COLOR = { Easy: 'green', Medium: 'amber', Hard: 'red' }
const TYPE_ICON = { task: '⚙', mcq: '◉' }
async function resetProgress(scope, opts) {
await fetch('/api/progress/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scope, ...opts }),
})
}
export default function Sidebar({
scenarios, activeId, onSelect, loading,
collapsed, onToggleCollapse, width,
activeBundleId, onProgressUpdate,
}) {
const [filterDiff, setFilterDiff] = useState('All')
const [filterType, setFilterType] = useState('All')
const filteredScenarios = useMemo(() => {
return scenarios.filter(s => {
if (filterDiff !== 'All' && s.difficulty !== filterDiff) return false
if (filterType !== 'All' && s.type !== filterType) return false
return true
})
}, [scenarios, filterDiff, filterType])
const groups = useMemo(() => {
const map = {}
filteredScenarios.forEach(s => {
if (!map[s.category]) map[s.category] = []
map[s.category].push(s)
})
return map
}, [filteredScenarios])
// Calculate index based on ALL scenarios so numbers stay absolute
const scenarioIndex = useMemo(() => {
const map = {}
scenarios.forEach(s => {
if (!map[s.category]) map[s.category] = []
map[s.category].push(s)
})
const idx = {}
let counter = 1
Object.values(map).forEach(items => {
items.forEach(s => { idx[s.id] = counter++ })
})
return idx
}, [scenarios])
// Number scenarios in accordion display order (category by category, then by position within category)
const [open, setOpen] = useState({})
useMemo(() => {
if (!activeId) return
const s = scenarios.find(x => x.id === activeId)
if (s) setOpen(o => ({ ...o, [s.category]: true }))
}, [activeId, scenarios])
const toggle = cat => setOpen(o => ({ ...o, [cat]: !o[cat] }))
const totalDone = scenarios.filter(s => s.progress?.status === 'completed').length
const handleCategoryReset = async (e, cat) => {
e.stopPropagation()
if (!window.confirm(`Reset all progress in "${cat}"?`)) return
await resetProgress('category', { category: cat })
onProgressUpdate?.()
}
const handleScenarioReset = async (e, scenarioId, title) => {
e.stopPropagation()
if (!window.confirm(`Reset progress for "${title}"?`)) return
await resetProgress('scenario', { scenarioId })
onProgressUpdate?.()
}
return (
<aside
className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}
style={{ width, minWidth: width }}
>
{/* Top bar */}
<div className={styles.sidebarTop}>
{!collapsed && <span className={styles.sidebarTitle}>Scenarios</span>}
{!collapsed && <span className={styles.sidebarCount}>{totalDone}/{scenarios.length}</span>}
<button
className={styles.collapseBtn}
onClick={onToggleCollapse}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? '' : ''}
</button>
</div>
{/* Filter bar */}
{!collapsed && (
<div className={styles.filterBar}>
<select
value={filterDiff}
onChange={e => setFilterDiff(e.target.value)}
className={`${styles.selectFilter} ${filterDiff !== 'All' ? styles[DIFF_COLOR[filterDiff]] : ''}`}
>
<option value="All">All Difficulties</option>
<option value="Easy">Easy</option>
<option value="Medium">Medium</option>
<option value="Hard">Hard</option>
</select>
<select
value={filterType}
onChange={e => setFilterType(e.target.value)}
className={`${styles.selectFilter} ${filterType !== 'All' ? styles[filterType] : ''}`}
>
<option value="All">All Types</option>
<option value="task">Task</option>
<option value="mcq">MCQ</option>
</select>
</div>
)}
{/* List */}
{!collapsed && (
<div className={styles.list}>
{loading && (
<div className={styles.loadingWrap}>
{[...Array(5)].map((_, i) => (
<div key={i} className={styles.skeleton} style={{ animationDelay: `${i * 0.1}s` }} />
))}
</div>
)}
{!loading && Object.entries(groups).map(([cat, items]) => {
const catDone = items.filter(s => s.progress?.status === 'completed').length
const isOpen = open[cat] !== false
const hasCatProgress = items.some(s => s.progress?.attempts > 0)
return (
<div key={cat} className={styles.group}>
<button className={styles.accordion} onClick={() => toggle(cat)}>
<div className={styles.accordionLeft}>
<span className={`${styles.chevron} ${isOpen ? styles.open : ''}`}></span>
<span className={styles.catName}>{cat}</span>
</div>
<div className={styles.accordionRight}>
<span className={styles.catCount}>{catDone}/{items.length}</span>
{hasCatProgress && (
<button
className={styles.catResetBtn}
title={`Reset all progress in "${cat}"`}
onClick={e => handleCategoryReset(e, cat)}
>
</button>
)}
</div>
</button>
{isOpen && (
<div className={styles.itemsBox}>
{items.map(s => {
const done = s.progress?.status === 'completed'
const active = s.id === activeId
const hasAttempts = s.progress?.attempts > 0
return (
<button
key={s.id}
className={`${styles.item} ${active ? styles.active : ''} ${done ? styles.done : ''}`}
onClick={() => onSelect(s.id)}
>
<div className={styles.itemTop}>
<span className={styles.itemNum}>{scenarioIndex[s.id]}</span>
<span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span>
<span className={styles.itemTitle}>{s.title}</span>
{done && <span className={styles.checkmark}></span>}
{/* Per-scenario reset — shown when item has attempts */}
{hasAttempts && (
<button
className={styles.itemResetBtn}
title="Reset this scenario's progress"
onClick={e => handleScenarioReset(e, s.id, s.title)}
>
</button>
)}
</div>
<div className={styles.itemMeta}>
<span className={`${styles.diff} ${styles[DIFF_COLOR[s.difficulty]]}`}>
{s.difficulty}
</span>
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
<span className={styles.weight}>{s.weight}pt</span>
</div>
</button>
)
})}
</div>
)}
</div>
)
})}
</div>
)}
</aside>
)
}
+339
View File
@@ -0,0 +1,339 @@
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
/* width/min-width set via inline style from App.jsx */
}
.sidebar.collapsed { overflow: hidden; }
.sidebarTop {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 10px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 8px;
}
.collapseBtn {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-3);
cursor: pointer;
font-size: 18px;
width: 24px;
height: 24px;
padding: 0;
border-radius: 4px;
flex-shrink: 0;
transition: color 0.12s, background 0.12s;
margin-left: auto;
}
.collapseBtn:hover { color: var(--text); background: var(--surface2); }
.sidebar.collapsed .sidebarTop {
padding: 14px 0 12px;
justify-content: center;
}
.sidebar.collapsed .collapseBtn {
margin-left: 0;
}
.sidebarTitle {
font-size: 11px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-3);
}
.sidebarCount {
font-family: var(--mono);
font-size: 11px;
color: var(--green);
background: var(--green-dim);
padding: 2px 8px;
border-radius: 10px;
}
.list {
overflow-y: auto;
flex: 1;
padding: 8px 0;
}
/* Filters */
.filterBar {
display: flex;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.selectFilter {
flex: 1;
background: var(--surface2);
color: var(--text-2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 6px;
font-size: 11px;
font-family: var(--sans);
outline: none;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.selectFilter:hover {
background: var(--surface3);
color: var(--text);
}
.selectFilter.green { background: var(--green-dim); color: var(--green); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.selectFilter.amber { background: var(--amber-dim); color: var(--amber); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.selectFilter.red { background: var(--red-dim); color: var(--red); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.selectFilter.task { background: var(--blue-dim); color: var(--blue); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.selectFilter.mcq { background: var(--purple-dim); color: var(--purple); border-color: color-mix(in srgb, currentColor 30%, transparent); }
/* Skeleton loading */
.loadingWrap { padding: 8px 12px; display: flex; flex-direction: column; gap: 6px; }
.skeleton {
height: 54px;
border-radius: var(--radius);
background: linear-gradient(90deg, var(--surface2) 25%, var(--surface3) 50%, var(--surface2) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
/* Accordion */
.group { margin-bottom: 2px; }
.accordion {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: none;
border: none;
cursor: pointer;
color: var(--text-2);
font-family: var(--sans);
}
.accordion:hover { background: var(--surface2); }
.accordion:hover .catResetBtn { opacity: 1; }
.accordionLeft {
display: flex;
align-items: center;
gap: 8px;
}
.chevron {
font-size: 16px;
color: var(--text-3);
transition: transform 0.15s;
display: inline-block;
line-height: 1;
}
.chevron.open { transform: rotate(90deg); }
.catName {
font-size: 13px;
font-weight: 800;
letter-spacing: 0.4px;
text-transform: uppercase;
color: var(--text);
}
.accordionRight {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.catCount {
font-family: var(--mono);
font-size: 11px;
color: var(--text-3);
}
.catResetBtn {
background: none;
border: none;
cursor: pointer;
font-size: 13px;
color: var(--text-3);
padding: 1px 4px;
border-radius: 4px;
line-height: 1;
transition: color 0.12s, background 0.12s;
flex-shrink: 0;
}
.catResetBtn:hover {
color: var(--red);
background: var(--red-dim);
}
/* Show catResetBtn on accordion hover too */
.accordion:hover .catResetBtn { color: var(--text-2); }
/* Items card — subtle rounded border wrapping each category's scenarios */
.itemsBox {
margin: 0 6px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--surface);
}
.item {
width: 100%;
text-align: left;
background: none;
border: none;
border-bottom: 1px solid var(--border);
border-radius: 0;
padding: 10px 12px;
cursor: pointer;
color: var(--text);
font-family: var(--sans);
transition: background 0.12s;
animation: slideIn 0.15s ease both;
}
.item:last-child { border-bottom: none; }
.item:hover { background: var(--surface2); }
.item.active { background: rgba(57,217,138,0.07); }
.item.done .itemTitle { color: var(--text-2); }
.itemTop {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 6px;
}
.itemNum {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
color: var(--text-3);
min-width: 18px;
text-align: right;
flex-shrink: 0;
opacity: 0.7;
}
.item.active .itemNum { color: var(--green); opacity: 1; }
.typeIcon {
font-size: 12px;
color: var(--text-3);
flex-shrink: 0;
margin-top: 1px;
}
.item.active .typeIcon { color: var(--green); }
.itemTitle {
font-size: 13px;
font-weight: 500;
line-height: 1.4;
flex: 1;
color: var(--text);
}
.checkmark {
color: var(--green);
font-size: 12px;
flex-shrink: 0;
font-weight: 700;
}
.itemMeta {
display: flex;
align-items: center;
gap: 6px;
padding-left: 20px;
}
/* Per-scenario inline reset button */
.itemResetBtn {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
font-size: 12px;
color: var(--text-3);
padding: 1px 4px;
border-radius: 3px;
line-height: 1;
opacity: 0;
transition: opacity 0.12s, color 0.12s, background 0.12s;
margin-left: auto;
}
.item:hover .itemResetBtn { opacity: 1; }
.itemResetBtn:hover {
color: var(--red);
background: var(--red-dim);
opacity: 1;
}
.diff {
font-family: var(--mono);
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
border: 1px solid transparent;
}
.diff.green { background: var(--green-dim); color: var(--green); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.diff.amber { background: var(--amber-dim); color: var(--amber); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.diff.red { background: var(--red-dim); color: var(--red); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.type {
font-family: var(--mono);
font-size: 10px;
color: var(--text-3);
background: var(--surface3);
padding: 1px 5px;
border-radius: 3px;
font-weight: 600;
border: 1px solid transparent;
}
.type.task { background: var(--blue-dim); color: var(--blue); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.type.mcq { background: var(--purple-dim); color: var(--purple); border-color: color-mix(in srgb, currentColor 30%, transparent); }
.weight {
font-family: var(--mono);
font-size: 10px;
color: var(--text-3);
margin-left: auto;
}
@keyframes slideIn { from{opacity:0;transform:translateX(-4px)} to{opacity:1;transform:translateX(0)} }
+173
View File
@@ -0,0 +1,173 @@
import { useEffect, useRef, useCallback } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
import styles from './Terminal.module.css'
// ── xterm.js themes ──────────────────────────────────────────────────────────
const TERM_THEMES = {
dark: {
background: '#0d1117',
foreground: '#e6edf3',
cursor: '#58a6ff',
cursorAccent: '#0d1117',
selectionBackground:'#264f78',
black: '#0d1117', brightBlack: '#6e7681',
red: '#ff7b72', brightRed: '#ffa198',
green: '#3fb950', brightGreen: '#56d364',
yellow: '#d29922', brightYellow: '#e3b341',
blue: '#58a6ff', brightBlue: '#79c0ff',
magenta: '#bc8cff', brightMagenta: '#d2a8ff',
cyan: '#39c5cf', brightCyan: '#56d4dd',
white: '#e6edf3', brightWhite: '#ffffff',
},
light: {
background: '#f6f8fa',
foreground: '#1f2328',
cursor: '#0969da',
cursorAccent: '#f6f8fa',
selectionBackground:'rgba(84,174,255,0.35)',
black: '#24292f', brightBlack: '#57606a',
red: '#cf222e', brightRed: '#a40e26',
green: '#116329', brightGreen: '#1a7f37',
yellow: '#633c01', brightYellow: '#7d4e00',
blue: '#0969da', brightBlue: '#218bff',
magenta: '#8250df', brightMagenta: '#a475f9',
cyan: '#1b7c83', brightCyan: '#3192aa',
white: '#6e7781', brightWhite: '#8c959f',
},
}
function getCurrentTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'
}
// ── Component ─────────────────────────────────────────────────────────────────
export default function TerminalComponent({ collapsed, onToggleCollapse }) {
const containerRef = useRef(null)
const termRef = useRef(null)
const fitRef = useRef(null)
const wsRef = useRef(null)
const fit = useCallback(() => {
const fitAddon = fitRef.current
const term = termRef.current
if (!fitAddon || !term) return
if (!containerRef.current || containerRef.current.offsetHeight === 0) return
try {
fitAddon.fit()
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
} catch {}
}, [])
const connect = useCallback(() => {
const term = termRef.current
if (!term) return
if (wsRef.current) { wsRef.current.onclose = null; wsRef.current.close() }
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${location.host}/shell-ws`)
wsRef.current = ws
ws.onopen = () => { term.clear(); fit() }
ws.onmessage = (e) => { term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data)) }
ws.onclose = () => { term.write('\r\n\x1b[33m[Disconnected — click Reconnect]\x1b[0m\r\n') }
ws.onerror = () => { term.write('\r\n\x1b[31m[WebSocket error]\x1b[0m\r\n') }
}, [fit])
// Re-fit one frame after expand so the CSS height transition has settled
useEffect(() => {
if (!collapsed) {
const id = requestAnimationFrame(() => fit())
return () => cancelAnimationFrame(id)
}
}, [collapsed, fit])
// Mount terminal
useEffect(() => {
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: '"Cascadia Code", "Fira Code", Menlo, Monaco, "Courier New", monospace',
theme: TERM_THEMES[getCurrentTheme()],
scrollback: 5000,
allowTransparency: false,
})
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.loadAddon(new WebLinksAddon())
term.open(containerRef.current)
requestAnimationFrame(() => fitAddon.fit())
termRef.current = term
fitRef.current = fitAddon
term.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data)
})
connect()
window.addEventListener('resize', fit)
const ro = new ResizeObserver(() => fit())
if (containerRef.current) ro.observe(containerRef.current)
// Watch <html data-theme> and update xterm theme live
const mo = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName === 'data-theme') {
term.options.theme = TERM_THEMES[getCurrentTheme()]
}
}
})
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
return () => {
window.removeEventListener('resize', fit)
ro.disconnect()
mo.disconnect()
wsRef.current?.close()
term.dispose()
}
}, [connect, fit])
return (
<div className={styles.wrap}>
<div className={styles.bar}>
<div className={styles.barLeft}>
<div className={styles.dots}>
<span className={styles.dot} style={{background:'#ff5f56'}} />
<span className={styles.dot} style={{background:'#ffbd2e'}} />
<span className={styles.dot} style={{background:'#27c93f'}} />
</div>
<span className={styles.barTitle}>
<span className={styles.barIcon}>$_</span>
bash kubekosh
</span>
</div>
<div className={styles.barRight}>
<button className={styles.barBtn} onClick={connect} title="Reconnect terminal">
Reconnect
</button>
<button className={styles.barBtn} onClick={onToggleCollapse} title={collapsed ? 'Expand terminal' : 'Collapse terminal'}>
{collapsed ? '▲' : '▼'}
</button>
</div>
</div>
{/*
xtermOuter is position:relative so the absolutely-positioned xterm
mount point (containerRef) fills it exactly — canonical xterm.js pattern.
The div stays in the DOM even when collapsed so the PTY session lives.
*/}
<div className={styles.xtermOuter} style={collapsed ? { height: 0 } : undefined}>
<div ref={containerRef} className={styles.terminal} />
</div>
</div>
)
}
@@ -0,0 +1,91 @@
.wrap {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: var(--term-bg);
overflow: hidden;
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
height: 36px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.barLeft {
display: flex;
align-items: center;
gap: 12px;
}
.dots {
display: flex;
align-items: center;
gap: 6px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: block;
opacity: 0.9;
}
.barTitle {
font-family: var(--mono);
font-size: 12px;
color: var(--text-3);
display: flex;
align-items: center;
gap: 7px;
}
.barIcon {
color: var(--green);
font-weight: 600;
}
.barRight { display: flex; gap: 8px; }
.barBtn {
background: none;
border: 1px solid var(--border);
border-radius: 5px;
padding: 3px 10px;
font-size: 11px;
color: var(--text-3);
cursor: pointer;
font-family: var(--sans);
transition: all 0.12s;
}
.barBtn:hover { color: var(--text-2); border-color: var(--border2); }
/*
Canonical xterm.js container pattern:
- xtermOuter: flex:1, position:relative → gives FitAddon a reliable size box
- terminal: position:absolute, inset:0 → fills xtermOuter exactly
This eliminates the black gap caused by xterm canvas not matching its container.
*/
.xtermOuter {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
/* height:0 override applied inline when collapsed, keeping DOM alive for PTY */
}
.terminal {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
+143
View File
@@ -0,0 +1,143 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Dark theme (default) ────────────────────────────────────────────────── */
:root {
--bg: #0c0f14;
--surface: #131720;
--surface2: #1a2030;
--surface3: #212840;
--border: #252d42;
--border2: #2e3a55;
--green: #39d98a;
--green-dim: rgba(57,217,138,0.12);
--blue: #4dabf7;
--blue-dim: rgba(77,171,247,0.10);
--amber: #fcc419;
--amber-dim: rgba(252,196,25,0.10);
--red: #ff6b6b;
--red-dim: rgba(255,107,107,0.10);
--purple: #9775fa;
--purple-dim: rgba(151,117,250,0.10);
--text: #e2e8f4;
--text-2: #8899b8;
--text-3: #4d5f80;
--mono: 'IBM Plex Mono', monospace;
--sans: 'Epilogue', sans-serif;
--radius: 8px;
--radius-lg: 12px;
--term-bg: #0d1117;
}
/* ── Light theme ─────────────────────────────────────────────────────────── */
:root[data-theme="light"] {
--bg: #f0f4f8;
--surface: #ffffff;
--surface2: #f5f7fa;
--surface3: #eaecf1;
--border: #d6dce8;
--border2: #c0c9d8;
--green: #0d9955;
--green-dim: rgba(13,153,85,0.10);
--blue: #1976d2;
--blue-dim: rgba(25,118,210,0.10);
--amber: #b45309;
--amber-dim: rgba(180,83,9,0.10);
--red: #dc2626;
--red-dim: rgba(220,38,38,0.10);
--purple: #7c3aed;
--purple-dim: rgba(124,58,237,0.10);
--text: #111827;
--text-2: #374151;
--text-3: #6b7280;
--term-bg: #f6f8fa;
}
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: var(--sans);
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
transition: background 0.25s ease, color 0.25s ease;
}
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-3); }
/* Markdown styles */
.md h1,.md h2,.md h3 { font-family: var(--sans); font-weight: 700; margin-bottom: 12px; line-height: 1.3; }
.md h2 { font-size: 18px; color: var(--text); }
.md h3 { font-size: 15px; color: var(--text-2); }
.md p { margin-bottom: 12px; color: var(--text-2); line-height: 1.7; }
.md p:last-child { margin-bottom: 0; }
.md code {
font-family: var(--mono);
font-size: 12px;
background: var(--surface3);
color: var(--green);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border);
}
/* Inline code rendered by InlineMd (outside .md wrapper) */
.inline-code {
font-family: var(--mono);
font-size: 12px;
background: var(--surface3);
color: var(--green);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border);
}
.md pre {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
margin: 12px 0;
overflow-x: auto;
}
.md pre code {
background: none;
border: none;
padding: 0;
color: var(--green);
font-size: 12px;
line-height: 1.8;
}
.md ul, .md ol { padding-left: 20px; margin-bottom: 12px; color: var(--text-2); }
.md li { margin-bottom: 4px; line-height: 1.6; }
.md strong { color: var(--text); font-weight: 600; }
.md blockquote {
border-left: 3px solid var(--blue);
padding: 8px 14px;
background: var(--blue-dim);
border-radius: 0 var(--radius) var(--radius) 0;
margin: 12px 0;
color: var(--text-2);
font-style: italic;
}
/* Animations */
@keyframes fadeIn { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }
@keyframes spin { to{transform:rotate(360deg)} }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
@keyframes slideIn { from{transform:translateX(-8px);opacity:0} to{transform:translateX(0);opacity:1} }
+14
View File
@@ -0,0 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
// Apply saved theme before first paint to avoid flash
const savedTheme = localStorage.getItem('kubekosh-theme') || 'dark'
document.documentElement.setAttribute('data-theme', savedTheme)
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:4000'
}
},
build: {
outDir: 'dist'
}
})
+211
View File
@@ -0,0 +1,211 @@
# KubeKosh Configuration Schema Reference
KubeKosh uses two primary JSON files to define its curriculum, learning paths, and exam configurations:
1. **Bundles (`scenarios/bundles.json`)**: Defines the high-level study bundles (e.g., CKA, CKAD, CKS), active highlights, durations for exams, and lists of included scenarios.
2. **Scenarios (`scenarios/scenarios.json`)**: Defines individual exercises, hands-on tasks, multiple-choice questions (MCQs), environment preparations, and automated validation scripts.
---
## 1. Bundles Schema (`scenarios/bundles.json`)
Bundles are defined as a JSON array of objects. Each bundle organizes a learning track or mock exam.
### Schema Fields
* **`id`** *(string, required)*: A unique, kebab-case identifier for the bundle (e.g., `k8s-basics`).
* **`name`** *(string, required)*: The human-readable name of the bundle shown in navigation (e.g., `Kubernetes Basics`).
* **`icon`** *(string, required)*: An emoji or glyph representing the bundle (e.g., `🌱`).
* **`tagline`** *(string, required)*: A short summary of the bundle's objectives.
* **`color`** *(string, required)*: Hex color code representing the bundle's UI identity/accent color (e.g., `#3fb950`).
* **`colorDim`** *(string, required)*: Translucent RGBA color matching the accent color at low opacity, used for UI row highlighting (e.g., `rgba(63,185,80,0.12)`).
* **`exam_minutes`** *(number, required)*: The time limit allocated for the mock exam in minutes (e.g., `60`).
* **`scenario_ids`** *(array of strings, required)*: List of scenario IDs belonging to this bundle in the order they should appear.
### Example Bundle
```json
{
"id": "k8s-basics",
"name": "Kubernetes Basics",
"icon": "🌱",
"tagline": "Core concepts for beginners",
"color": "#3fb950",
"colorDim": "rgba(63,185,80,0.12)",
"exam_minutes": 60,
"scenario_ids": [
"pod-basics-mcq",
"kubectl-essentials-mcq",
"namespaces-basics",
"deploy-nginx"
]
}
```
---
## 2. Scenarios Schema (`scenarios/scenarios.json`)
Scenarios are defined as a JSON array of objects. A scenario can be either a hands-on console challenge (`"task"`) or a multiple-choice question (`"mcq"`).
### Common Fields (All Types)
```jsonc
{
"id": "unique-kebab-case-id", // string — unique scenario identifier
"title": "Human-readable Title", // string — shown in sidebar list
"category": "Workloads", // string — groups scenarios in sidebar accordion
"difficulty": "Easy", // "Easy" | "Medium" | "Hard"
"type": "task", // "task" | "mcq"
"weight": 7, // number — points value (used for final grade scoring)
"description": "## Markdown...", // string — problem statement supporting GitHub-flavored Markdown
"hints": [...], // array — see Hints schema below
"setup_commands": [...], // array<object> — commands run on environment preparation
"teardown_commands": [...], // array<object> — optional — cleanup commands run after scenario completes
"default_namespace": "default" // string — optional — default active namespace for the terminal
}
```
---
### Hints Schema
Each hint is rendered as a collapsible card inside the Hints tab of the UI:
```jsonc
{
"title": "Short title for the hint card",
"body": "Explanation text (plain text, no markdown format).",
"command": "kubectl run nginx --image=nginx" // optional — renders a copyable code block
}
```
---
### Setup & Teardown Commands
* **`setup_commands`**: Executed sequentially on the Kubernetes cluster when the user starts a scenario or clicks **"Prepare Environment"**. Useful for pre-deploying resources or injecting bugs.
* **`teardown_commands`**: Optional cleanup commands run when moving away from or resetting a scenario.
* Commands must be **objects** with a `command` key:
```jsonc
"setup_commands": [
{ "command": "kubectl create namespace debug" },
{ "command": "kubectl create deployment broken-app --image=nginx:1.25 -n debug" }
]
```
* *Note:* Non-zero exit codes are tolerated (e.g., "namespace already exists" errors won't halt the pipeline). All commands execute as `root`.
---
### Type: `"task"` — Hands-On Scenario
Requires the user to run shell commands in the interactive terminal. The system runs an automated validation sequence to check the cluster state.
```jsonc
{
"type": "task",
"validation": {
"description": "Check that deployment has been correctly configured",
"commands": [
{
"description": "Checks the running pods count",
"command": "kubectl get deploy nginx -o jsonpath='{.status.readyReplicas}'",
"expected_output": "3",
"match": "exact" // "exact" | "contains" | "regex"
}
]
}
}
```
#### Match Modes
| Mode | Behaviour |
| :--- | :--- |
| `exact` | Trimmed stdout must exactly equal `expected_output`. |
| `contains` | stdout must contain `expected_output` as a substring. |
| `not_contains` | stdout must **not** contain `expected_output` as a substring. |
| `regex` | stdout must match the regular expression in `expected_output`. |
---
### Type: `"mcq"` — Multiple Choice Question
Renders a questionnaire block. No terminal is shown. The user answers by selecting an option.
```jsonc
{
"type": "mcq",
"options": [
{ "id": "a", "text": "Option A explanation" },
{ "id": "b", "text": "Option B explanation" },
{ "id": "c", "text": "Option C explanation" },
{ "id": "d", "text": "Option D explanation" }
],
"correct_option": "c", // must match one of the option IDs
"explanation": "Detailed explanation of why C is the correct answer." // shown after submitting
}
```
---
## 3. Full Examples
### Full Example — Hands-On Task Scenario
```json
{
"id": "scale-deployment",
"title": "Scale a Deployment",
"category": "Workloads",
"difficulty": "Easy",
"type": "task",
"weight": 4,
"description": "## Scale the Deployment\n\nA deployment named `myapp` exists in the `default` namespace.\n\n**Scale it to 5 replicas.**",
"hints": [
{
"title": "Using kubectl scale",
"body": "The scale subcommand lets you change the replica count imperatively.",
"command": "kubectl scale deployment myapp --replicas=5"
}
],
"setup_commands": [
{ "command": "kubectl create deployment myapp --image=nginx:1.25 --replicas=1" }
],
"teardown_commands": [
{ "command": "kubectl delete deployment myapp --ignore-not-found" }
],
"default_namespace": "default",
"validation": {
"description": "Checks that myapp has 5 ready replicas.",
"commands": [
{
"description": "myapp has 5 ready replicas",
"command": "kubectl get deployment myapp -o jsonpath='{.status.readyReplicas}'",
"expected_output": "5",
"match": "exact"
}
]
}
}
```
### Full Example — MCQ Scenario
```json
{
"id": "service-types-mcq",
"title": "Kubernetes Service Types",
"category": "Networking",
"difficulty": "Easy",
"type": "mcq",
"weight": 3,
"description": "## Kubernetes Service Types\n\nWhich `kubectl` command creates a ClusterIP service named `my-svc` exposing port 80 for a deployment named `my-app`?",
"options": [
{ "id": "a", "text": "kubectl expose deployment my-app --name=my-svc --port=80 --type=ClusterIP" },
{ "id": "b", "text": "kubectl create service my-svc --port=80" },
{ "id": "c", "text": "kubectl apply service my-app --port=80" },
{ "id": "d", "text": "kubectl expose pod my-app --name=my-svc --port=80 --type=NodePort" }
],
"correct_option": "a",
"explanation": "`kubectl expose deployment` is the correct imperative command. It creates a Service targeting the deployment's pods. `--type=ClusterIP` is the default but explicit here for clarity.",
"hints": [
{
"title": "kubectl expose syntax",
"body": "Use kubectl expose to create a Service from an existing resource. Specify the resource type, name, port, and service type.",
"command": "kubectl expose deployment my-app --name=my-svc --port=80 --type=ClusterIP"
}
],
"setup_commands": [],
"teardown_commands": [],
"default_namespace": "default"
}
```
+146
View File
@@ -0,0 +1,146 @@
[
{
"id": "k8s-basics",
"name": "Kubernetes Basics",
"icon": "\ud83c\udf31",
"tagline": "Core concepts for beginners",
"color": "#3fb950",
"colorDim": "rgba(63,185,80,0.12)",
"exam_minutes": 60,
"scenario_ids": [
"pod-basics-mcq",
"kubectl-essentials-mcq",
"labels-selectors-mcq",
"init-containers-mcq",
"namespaces-basics",
"deploy-nginx",
"scale-deployment",
"rolling-update-task",
"liveness-probe-task",
"jobs-cronjobs-mcq",
"daemonset-mcq",
"configmap-volume",
"resource-limits-task",
"env-vars-configmap",
"secrets-basics",
"services-mcq",
"multi-container-pod-basics",
"expose-service-basics",
"pod-labeling-basics",
"extract-logs-basics",
"edit-deployment-basics",
"jsonpath-basics",
"dry-run-manifest-basics",
"delete-by-label-basics",
"kubectl-cp-basics",
"exec-command-basics"
]
},
{
"id": "k8s-admin",
"name": "Kubernetes Administrator",
"icon": "\ud83e\uddd1\u200d\u2708\ufe0f",
"tagline": "CKA exam \u2014 cluster administration",
"color": "#58a6ff",
"colorDim": "rgba(88,166,255,0.12)",
"exam_minutes": 120,
"scenario_ids": [
"rbac-role",
"rbac-clusterrole",
"serviceaccount-pod",
"node-label-selector",
"node-taint-toleration",
"resource-quota-ns",
"limitrange-task",
"etcd-backup-mcq",
"cluster-upgrade-mcq",
"deployment-rollback",
"cronjob-task",
"job-task",
"readiness-probe-task",
"pod-affinity-mcq",
"nodeport-task",
"ingress-task",
"dns-resolution-mcq",
"endpoint-fix-task",
"networkpolicy-egress",
"network-policy",
"storageclass-mcq",
"pvc-dynamic-task",
"emptydir-pod",
"pv-pvc-mount",
"pod-security-context",
"broken-deployment",
"crashloop-fix",
"container-logging-mcq"
]
},
{
"id": "k8s-appdev",
"name": "Kubernetes Developer",
"icon": "\ud83d\udee0\ufe0f",
"tagline": "CKAD exam \u2014 application development",
"color": "#bc8cff",
"colorDim": "rgba(188,140,255,0.12)",
"exam_minutes": 120,
"scenario_ids": [
"pod-basics-mcq",
"kubectl-essentials-mcq",
"namespaces-basics",
"labels-selectors-mcq",
"deploy-nginx",
"scale-deployment",
"rolling-update-task",
"deployment-rollback",
"liveness-probe-task",
"readiness-probe-task",
"configmap-volume",
"env-vars-configmap",
"secrets-basics",
"resource-limits-task",
"cronjob-task",
"job-task",
"emptydir-pod",
"pvc-dynamic-task",
"services-mcq",
"nodeport-task",
"ingress-task",
"rbac-role",
"pod-security-context",
"crashloop-fix",
"container-logging-mcq"
]
},
{
"id": "k8s-security",
"name": "Kubernetes Security",
"icon": "\ud83d\udee1",
"tagline": "CKS exam \u2014 hardening and threats",
"color": "#f97316",
"colorDim": "rgba(249,115,22,0.12)",
"exam_minutes": 120,
"scenario_ids": [
"cks-network-policy",
"cks-pod-security-context",
"cks-rbac-least-privilege",
"cks-seccomp-profile",
"cks-immutable-secret",
"cks-apparmor-profile",
"cks-automount-token",
"cks-network-policy-metadata",
"cks-rbac-clusterrole",
"cks-psa-namespace",
"cks-mcq-runtime-security",
"cks-mcq-api-server",
"cks-mcq-image-footprint",
"cks-mcq-psp-replacement",
"cks-mcq-kubelet-auth",
"cks-readonly-filesystem",
"cks-drop-capabilities",
"cks-tls-ingress",
"cks-image-pull-secret",
"cks-mcq-sandboxing",
"cks-mcq-audit-policy"
]
}
]
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
#!/bin/bash
set -e
LOG() { echo -e "\033[36m[k8s-lab]\033[0m $*"; }
OK() { echo -e "\033[32m[k8s-lab]\033[0m ✓ $*"; }
ERR() { echo -e "\033[31m[k8s-lab]\033[0m ✗ $*" >&2; }
LOG "Starting KubeKosh..."
# ── 0. Fix cgroupv2 hierarchy (Docker Desktop / Mac) ─────────────────────────
# cgroupv2 enforces the "no-internal-process constraint": a cgroup with domain
# controllers (cpu, memory, etc.) cannot have processes AND child cgroups at the
# same level. Docker Desktop places our container's processes in the root cgroup,
# making it impossible for containerd/runc to create pod sub-cgroups (k8s.io).
#
# Fix (same as k3d): move all current processes to a leaf cgroup first, then
# enable all available controllers in the root's subtree_control.
if [ -f /sys/fs/cgroup/cgroup.controllers ]; then
LOG "Configuring cgroupv2 delegation..."
mkdir -p /sys/fs/cgroup/init
# Move every process currently in the root cgroup into the leaf
xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs 2>/dev/null || true
# Enable all available controllers for child cgroups (e.g. k8s.io, kubepods)
sed -e 's/ / +/g' -e 's/^/+/' \
< /sys/fs/cgroup/cgroup.controllers \
> /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true
OK "cgroupv2 delegation configured"
fi
# ── 1. Start k3s server ──────────────────────────────────────────────────────
LOG "Starting k3s (Kubernetes)..."
# k3s needs cgroupv2 or cgroupv1 mounted; --disable flags slim it down for lab use
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=$!
# Wait for k3s API server to be ready
KUBECONFIG_PATH=/etc/rancher/k3s/k3s.yaml
for i in $(seq 1 60); do
if [ -f "$KUBECONFIG_PATH" ] && \
kubectl --kubeconfig="$KUBECONFIG_PATH" get nodes &>/dev/null 2>&1; then
break
fi
sleep 2
if [ $i -eq 60 ]; then
ERR "k3s failed to start. Last log lines:"
tail -20 /var/log/k3s.log >&2
exit 1
fi
done
OK "k3s API server is up"
# Symlink kubeconfig to the standard location for convenience
mkdir -p /root/.kube
cp "$KUBECONFIG_PATH" /root/.kube/config
export KUBECONFIG=/root/.kube/config
# ── 2. Wait for node to be Ready ────────────────────────────────────────────
LOG "Waiting for cluster node to become Ready..."
# Phase 1: wait until at least one node is registered
# (kubectl wait --all exits immediately with error if no resources exist yet)
# Stream k3s logs to stdout in background so failures are visible
tail -f /var/log/k3s.log &
TAIL_PID=$!
for i in $(seq 1 90); do
NODE_COUNT=$(kubectl get nodes --no-headers 2>/dev/null | wc -l)
if [ "$NODE_COUNT" -gt 0 ]; then
kill $TAIL_PID 2>/dev/null || true
break
fi
sleep 3
if [ $i -eq 90 ]; then
kill $TAIL_PID 2>/dev/null || true
ERR "Timed out waiting for a node to register (270s)"
ERR "k3s node status:"
kubectl get nodes 2>&1 >&2 || true
exit 1
fi
done
# Phase 2: wait for the node to reach Ready condition
kubectl wait --for=condition=Ready nodes --all --timeout=120s
OK "Cluster node is Ready"
# Phase 3: wait for flannel CNI to write its subnet config.
# Pods scheduled before flannel is ready get FailedCreatePodSandBox warnings
# (missing /run/flannel/subnet.env). Waiting here avoids that noise.
for i in $(seq 1 30); do
[ -f /run/flannel/subnet.env ] && break
sleep 1
done
# ── 3. Install metrics-server ────────────────────────────────────────────────
# LOG "Installing metrics-server..."
# kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml &>/dev/null || true
# kubectl patch deployment metrics-server -n kube-system \
# --type='json' \
# -p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]' \
# &>/dev/null 2>&1 || true
# OK "Metrics-server applied"
# ── 4. Shell environment ─────────────────────────────────────────────────────
LOG "Configuring shell environment..."
cat >> /root/.bashrc << 'BASHRC'
# KubeKosh aliases
export KUBECONFIG=/root/.kube/config
alias k='kubectl'
alias kgp='kubectl get pods'
alias kga='kubectl get pods --all-namespaces'
alias kgd='kubectl get deployments'
alias kgs='kubectl get services'
alias kgn='kubectl get nodes'
alias kgns='kubectl get namespaces'
alias kdp='kubectl describe pod'
alias kaf='kubectl apply -f'
alias kdf='kubectl delete -f'
alias kg='kubectl get'
alias kd='kubectl describe'
alias krm='kubectl delete'
alias kex='kubectl exec -it'
alias klogs='kubectl logs'
# Useful functions
kns() { kubectl config set-context --current --namespace="$1"; }
kctx() { kubectl config use-context "$1"; }
source <(kubectl completion bash) 2>/dev/null || true
complete -F __start_kubectl k 2>/dev/null || true
PS1='\[\033[01;32m\]\u@k8s-lab\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
echo ""
echo " ⎈ KubeKosh - Node: k8s-lab"
KUBECTL_VER=$(kubectl version --client 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+[^ ]*' | head -1)
echo " kubectl ${KUBECTL_VER}"
echo " Aliases: k=kubectl, kgp=get pods, kaf=apply -f, kns=set-namespace, kgns=get namespaces, kex=kubectl exec -it"
echo " kgd=get deployments, kgn=get nodes, kgs=get services, kdp=describe pod, krm=kubectl delete, klogs=kubectl logs"
echo ""
BASHRC
OK "Shell configured"
# ── 5. Start Node.js API server ──────────────────────────────────────────────
LOG "Starting API server..."
cd /app/backend && node server.js &>/var/log/api.log &
OK "API server started (port 4000)"
# ── 6. Browser terminal ──────────────────────────────────────────────────────
# Terminal is served via WebSocket at /shell-ws by the Node.js API server
# using node-pty — no external ttyd binary needed.
# ── 7. Start nginx reverse proxy ────────────────────────────────────────────
LOG "Starting nginx proxy..."
nginx -g 'daemon off;' &>/var/log/nginx.log &
OK "nginx started (port 80)"
# ── 8. Keep Alive & Graceful Shutdown ────────────────────────────────────────
cleanup() {
LOG "Caught signal, shutting down KubeKosh..."
kill -TERM "$K3S_PID" 2>/dev/null || true
kill $(jobs -p) 2>/dev/null || true
exit 0
}
trap cleanup SIGINT SIGTERM
LOG "══════════════════════════════════════════════════"
LOG " KubeKosh is ready! → http://localhost:7554 "
LOG "══════════════════════════════════════════════════"
# Wait for background jobs. When a signal is caught, wait returns instantly and triggers cleanup.
wait
+49
View File
@@ -0,0 +1,49 @@
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# Upstream: Node.js API + static frontend + WebSocket PTY
upstream api {
server 127.0.0.1:4000;
}
server {
listen 80;
server_name _;
# WebSocket terminal — /shell-ws is handled by the Node.js backend
location /shell-ws {
proxy_pass http://api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 7d;
}
# API routes
location /api/ {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Everything else → React SPA (served by same Node.js process)
location / {
proxy_pass http://api;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
}
}