Initial commit
This commit is contained in:
+83
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`));
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
@@ -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>© {new Date().getFullYear()} The KubeKosh Project • 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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} }
|
||||
@@ -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)} }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)} }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} }
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user