Compare commits

6 Commits

Author SHA1 Message Date
Codex
3db89417d7 fix: make linux stage navigation dynamic 2026-04-01 10:33:02 +08:00
Codex
efaa715e93 feat: tighten linux login flow and refresh lab entry 2026-03-25 16:42:15 +08:00
Codex
2f7bd50a36 chore: refine linux learning lab overview and auth 2026-03-24 17:31:18 +08:00
Codex
d61730bf17 feat: enforce auth gate across linux lab 2026-03-24 17:07:40 +08:00
Codex
d2b667f569 feat: polish linux lab cockpit and auth flow 2026-03-24 12:36:29 +08:00
Codex
f5bcfa2259 feat: finish bilingual linux learning lab 2026-03-23 16:03:32 +08:00
6 changed files with 866 additions and 890 deletions

4
.gitignore vendored
View File

@@ -5,3 +5,7 @@ __pycache__/
# OpenClaw interactive edit backups
*.interactive.backup.*
# Runtime logs
server.out.log
server.err.log

View File

@@ -5,9 +5,9 @@
"author": "Codex",
"updated": "2026-03-19",
"description": "A rebuilt Linux learning path that moves from command basics to real operations troubleshooting with sandbox exercises.",
"module_count": 8,
"total_lessons": 24,
"total_exercises": 82,
"module_count": 10,
"total_lessons": 30,
"total_exercises": 106,
"pedagogy": "learning-first",
"orientation": "ops-workflow"
},
@@ -581,6 +581,148 @@
]
}
]
},
{
"id": "module_9_web_delivery",
"title": "Module 9: Web delivery and runtime verification",
"summary": "Connect service state, sockets, configs, and HTTP requests into a simple web delivery workflow.",
"lessons": [
{
"id": "m9_l1_service_to_http",
"title": "From service state to HTTP verification",
"goal": "Learn how to verify a web service from the process layer up to the HTTP layer.",
"why_it_matters": "A web service is not truly healthy until the application responds correctly, not just when the process exists.",
"concepts": ["Service state", "Socket readiness", "HTTP verification"],
"command": "systemctl / ss / curl",
"examples": ["systemctl status nginx", "ss -ltnp", "curl http://127.0.0.1"],
"pitfalls": ["Treating a running service as proof of healthy delivery", "Stopping at a listening socket without making a request"],
"scenarios": ["A service was restarted after deployment but the site still looks broken", "You need to prove whether the issue is process, port, or response body"],
"troubleshooting_flow": ["Check the service state", "Check listening sockets", "Send a real HTTP request", "Use the result to decide whether you need logs next"],
"related_commands": ["systemctl", "ss", "curl", "journalctl"],
"classic_view": "Healthy delivery means the request path works end to end, not just that one process is running.",
"takeaways": ["Check multiple layers", "Do not confuse a listening port with a correct response", "Always include a real request in a delivery check"],
"after_class": "Practice saying which layer each command verifies: service, port, or HTTP response.",
"exercises": [
{ "id": "m9_l1_e1", "type": "operation", "title": "Check nginx service state", "hint": "Run systemctl status nginx.", "success_test": "cmd == 'systemctl status nginx' and 'Active:' in output", "solution": ["systemctl status nginx"], "success_msg": "You verified the service layer." },
{ "id": "m9_l1_e2", "type": "operation", "title": "Check listening sockets", "hint": "Run ss -ltnp.", "success_test": "cmd == 'ss -ltnp' and 'LISTEN' in output", "solution": ["ss -ltnp"], "success_msg": "You verified the socket layer." },
{ "id": "m9_l1_e3", "type": "operation", "title": "Check the HTTP response", "hint": "Run curl http://127.0.0.1.", "success_test": "cmd == 'curl http://127.0.0.1' and 'hello localhost' in output", "solution": ["curl http://127.0.0.1"], "success_msg": "You verified the HTTP layer." },
{ "id": "m9_l1_e4", "type": "scenario", "question": "If systemctl and ss look fine but curl still fails, which layer deserves your attention next?", "answer": "The application response path and logs, because process and port checks alone do not prove healthy delivery." }
]
},
{
"id": "m9_l2_fetch_and_compare",
"title": "Fetch and compare remote content with curl and wget",
"goal": "Understand the difference between checking a response and saving an artifact.",
"why_it_matters": "Operations work often mixes quick request verification with actual file retrieval during delivery or recovery.",
"concepts": ["Response inspection", "Artifact download", "Saved output validation"],
"command": "curl / wget",
"examples": ["curl http://127.0.0.1", "wget https://example.com/file.tar.gz"],
"pitfalls": ["Using wget when you only need a quick response check", "Downloading a file without checking where it was saved"],
"scenarios": ["Confirm a page is reachable", "Fetch a package or static asset into a working directory"],
"troubleshooting_flow": ["Use curl for quick HTTP inspection", "Use wget when you need a saved file", "Verify the saved path or body immediately"],
"related_commands": ["curl", "wget", "ls", "cat"],
"classic_view": "Choose between inspect and download deliberately instead of treating all HTTP tools as the same.",
"takeaways": ["Use curl for response checks", "Use wget for saved artifacts", "Verify destination and content after retrieval"],
"after_class": "Explain why curl and wget answer different operational questions even when they both touch HTTP.",
"exercises": [
{ "id": "m9_l2_e1", "type": "operation", "title": "Inspect the local web response", "hint": "Run curl http://127.0.0.1.", "success_test": "cmd == 'curl http://127.0.0.1' and 'hello localhost' in output", "solution": ["curl http://127.0.0.1"], "success_msg": "You used curl as a response-checking tool." },
{ "id": "m9_l2_e2", "type": "operation", "title": "Simulate downloading an artifact", "hint": "Run wget https://example.com/file.tar.gz.", "success_test": "cmd == 'wget https://example.com/file.tar.gz' and 'saved' in output.lower()", "solution": ["wget https://example.com/file.tar.gz"], "success_msg": "You used wget as an artifact download tool." },
{ "id": "m9_l2_e3", "type": "understanding", "question": "Why is curl often the better first tool during a health check?", "answer": "Because it quickly shows the response without introducing file-placement concerns." }
]
},
{
"id": "m9_l3_config_runtime",
"title": "Inspect runtime config clues with cat, grep, and journalctl",
"goal": "Correlate config files, log output, and service behavior during a web issue.",
"why_it_matters": "Web incidents often require you to connect what the config says with what the logs report.",
"concepts": ["Config inspection", "Log correlation", "Runtime mismatch"],
"command": "cat / grep / journalctl",
"examples": ["cat /etc/nginx.conf", "grep error /var/log/syslog", "journalctl -n 20"],
"pitfalls": ["Changing config before reading the current file", "Reading logs without checking the active config context"],
"scenarios": ["An app starts but still shows misbehavior", "You suspect a config mismatch or startup error"],
"troubleshooting_flow": ["Read the config file", "Search logs for the matching signal", "Compare the config expectation with the runtime evidence"],
"related_commands": ["cat", "grep", "journalctl", "systemctl"],
"classic_view": "Config and logs make more sense when you read them as one story instead of separate artifacts.",
"takeaways": ["Read before changing", "Search logs for matching symptoms", "Use config and logs together"],
"after_class": "Practice explaining how one config clue should influence what log pattern you search for.",
"exercises": [
{ "id": "m9_l3_e1", "type": "operation", "title": "Read nginx.conf", "hint": "Run cat /etc/nginx.conf.", "success_test": "cmd == 'cat /etc/nginx.conf' and 'worker_processes' in output", "solution": ["cat /etc/nginx.conf"], "success_msg": "You inspected the active config file." },
{ "id": "m9_l3_e2", "type": "operation", "title": "Search syslog for errors", "hint": "Run grep error /var/log/syslog.", "success_test": "cmd == 'grep error /var/log/syslog' and 'error' in output.lower()", "solution": ["grep error /var/log/syslog"], "success_msg": "You filtered log output down to the error signal." },
{ "id": "m9_l3_e3", "type": "operation", "title": "Read recent journal entries", "hint": "Run journalctl -n 20.", "success_test": "cmd == 'journalctl -n 20' and 'nginx' in output.lower()", "solution": ["journalctl -n 20"], "success_msg": "You correlated service behavior with recent log evidence." }
]
}
]
},
{
"id": "module_10_backup_and_recovery",
"title": "Module 10: Backup, recovery, and safe change patterns",
"summary": "Practice the habits that make change safer: copy, archive, inspect, restore, and verify.",
"lessons": [
{
"id": "m10_l1_backup_patterns",
"title": "Create rollback options with cp and tar",
"goal": "Understand simple rollback preparation before changing files or directories.",
"why_it_matters": "A safe change is easier when you can return to a known-good file or archive quickly.",
"concepts": ["Point-in-time copy", "Archive backup", "Rollback preparation"],
"command": "cp / tar",
"examples": ["cp /etc/hosts /tmp/hosts.rollback", "tar -czf /tmp/etc-snapshot.tar.gz /etc"],
"pitfalls": ["Changing a file before creating any rollback artifact", "Treating a backup as valid without verifying it exists"],
"scenarios": ["Prepare for a risky config edit", "Bundle a directory tree for later recovery"],
"troubleshooting_flow": ["Create a copy or archive first", "Verify the backup exists", "Only then proceed with the risky change"],
"related_commands": ["cp", "tar", "ls", "stat"],
"classic_view": "Backups are what gives you permission to change safely.",
"takeaways": ["Make rollback options first", "Verify that the backup artifact exists", "Choose copy vs archive based on scope"],
"after_class": "Before any file edit, say what your rollback object is and where it lives.",
"exercises": [
{ "id": "m10_l1_e1", "type": "operation", "title": "Create a rollback copy", "hint": "Run cp /etc/hosts /tmp/hosts.rollback.", "success_test": "cmd == 'cp /etc/hosts /tmp/hosts.rollback' and exists('/tmp/hosts.rollback')", "solution": ["cp /etc/hosts /tmp/hosts.rollback"], "success_msg": "You created a rollback copy before change." },
{ "id": "m10_l1_e2", "type": "operation", "title": "Create an archive snapshot", "hint": "Run tar -czf /tmp/etc-snapshot.tar.gz /etc.", "success_test": "cmd == 'tar -czf /tmp/etc-snapshot.tar.gz /etc' and exists('/tmp/etc-snapshot.tar.gz')", "solution": ["tar -czf /tmp/etc-snapshot.tar.gz /etc"], "success_msg": "You created an archive-based snapshot." },
{ "id": "m10_l1_e3", "type": "scenario", "question": "Why is a verified backup more valuable than just planning to remember the old state?", "answer": "Because rollback depends on an actual artifact, not memory or assumptions." }
]
},
{
"id": "m10_l2_restore_path",
"title": "Restore into a clean path and inspect the result",
"goal": "Practice extracting or copying into a clean recovery target instead of overwriting blindly.",
"why_it_matters": "Safe recovery often means restoring to a staging path first so you can inspect what you got back.",
"concepts": ["Clean restore target", "Artifact extraction", "Verification after recovery"],
"command": "mkdir / tar / ls",
"examples": ["mkdir -p /tmp/restore/etc", "tar -xzf /tmp/etc-snapshot.tar.gz -C /tmp/restore/etc", "ls /tmp/restore/etc"],
"pitfalls": ["Extracting directly into a live path without inspection", "Skipping path validation after a restore"],
"scenarios": ["Recover a config tree into a staging directory", "Inspect a restored artifact before using it"],
"troubleshooting_flow": ["Create a clean target path", "Extract into the clean path", "List the restored result before using it"],
"related_commands": ["mkdir", "tar", "ls", "find"],
"classic_view": "Recovery is safer when you restore into a clean inspection space first.",
"takeaways": ["Restore into a staging path when possible", "Inspect restored content before reuse", "Use ls or find to verify the recovery target"],
"after_class": "Practice saying where you would restore first and why that path is safer than the live target.",
"exercises": [
{ "id": "m10_l2_e1", "type": "operation", "title": "Create a clean restore path", "hint": "Run mkdir -p /tmp/restore/etc.", "success_test": "cmd == 'mkdir -p /tmp/restore/etc' and exists('/tmp/restore/etc')", "solution": ["mkdir -p /tmp/restore/etc"], "success_msg": "You created a clean restore target." },
{ "id": "m10_l2_e2", "type": "operation", "title": "Extract the snapshot into the restore path", "hint": "Run tar -xzf /tmp/etc-snapshot.tar.gz -C /tmp/restore/etc.", "success_test": "cmd == 'tar -xzf /tmp/etc-snapshot.tar.gz -C /tmp/restore/etc' and exists('/tmp/restore/etc')", "solution": ["tar -xzf /tmp/etc-snapshot.tar.gz -C /tmp/restore/etc"], "success_msg": "You restored the archive into a clean path." },
{ "id": "m10_l2_e3", "type": "operation", "title": "List the restore target", "hint": "Run ls /tmp/restore/etc.", "success_test": "cmd == 'ls /tmp/restore/etc' and len(output) >= 0", "solution": ["ls /tmp/restore/etc"], "success_msg": "You inspected the restore target after extraction." }
]
},
{
"id": "m10_l3_change_window",
"title": "Plan a safe change window with date, cal, history, and journalctl",
"goal": "Think about change timing, traceability, and rollback evidence as part of routine operations work.",
"why_it_matters": "A safe operator tracks when a change happened and what happened immediately before or after it.",
"concepts": ["Time anchoring", "Change timeline", "Quick rollback evidence"],
"command": "date / cal / history / journalctl",
"examples": ["date", "cal", "history", "journalctl -n 20"],
"pitfalls": ["Making a change without checking maintenance timing", "Losing the command narrative that explains what happened"],
"scenarios": ["Prepare for a maintenance window", "Reconstruct the sequence around a recent restart or config change"],
"troubleshooting_flow": ["Anchor the time", "Check the calendar or window context", "Review your command narrative", "Read recent logs around the change"],
"related_commands": ["date", "cal", "history", "journalctl"],
"classic_view": "Operational safety improves when every change has time context, command context, and log context.",
"takeaways": ["Time-stamp your changes", "Keep a readable command narrative", "Pair history with logs for recovery thinking"],
"after_class": "Practice explaining a change using time, commands, and logs as one connected story.",
"exercises": [
{ "id": "m10_l3_e1", "type": "operation", "title": "Anchor the current time", "hint": "Run date.", "success_test": "cmd == 'date' and 'CST' in output", "solution": ["date"], "success_msg": "You anchored the current time." },
{ "id": "m10_l3_e2", "type": "operation", "title": "Review the calendar context", "hint": "Run cal.", "success_test": "cmd == 'cal' and 'March' in output", "solution": ["cal"], "success_msg": "You reviewed the calendar context." },
{ "id": "m10_l3_e3", "type": "operation", "title": "Review your command narrative", "hint": "Run history.", "success_test": "cmd == 'history' and len(output) >= 0", "solution": ["history"], "success_msg": "You reviewed the shell command timeline." },
{ "id": "m10_l3_e4", "type": "operation", "title": "Review recent journal lines", "hint": "Run journalctl -n 20.", "success_test": "cmd == 'journalctl -n 20' and 'nginx' in output.lower()", "solution": ["journalctl -n 20"], "success_msg": "You linked the change window to recent service logs." }
]
}
]
}
]
}

View File

@@ -1,883 +1,127 @@
<!DOCTYPE html>
<html lang="en">
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Linux Learning Lab</title>
<style>
:root {
--bg: #f3f7fb;
--panel: rgba(255, 255, 255, 0.94);
--line: #d8e2ee;
--text: #102033;
--muted: #5d7187;
--brand: #0f6db6;
--accent: #1d9b6c;
--soft: #e9f3ff;
--terminal: #0e1724;
--shadow: 0 18px 40px rgba(16, 32, 51, 0.12);
}
[data-theme="dark"] {
--bg: #08111d;
--panel: rgba(12, 24, 38, 0.94);
--line: #1d3245;
--text: #edf4fb;
--muted: #9bb0c3;
--brand: #67baff;
--accent: #62d6ab;
--soft: rgba(103, 186, 255, 0.1);
--terminal: #06101c;
--shadow: 0 20px 44px rgba(0, 0, 0, 0.34);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--text);
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top right, rgba(15, 109, 182, 0.15), transparent 25%),
radial-gradient(circle at bottom left, rgba(29, 155, 108, 0.12), transparent 20%),
var(--bg);
}
button, input { font: inherit; }
.shell { max-width: 1480px; margin: 0 auto; padding: 20px; }
.topbar, .card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.topbar {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
margin-bottom: 16px;
align-items: center;
}
.title h1 { margin: 0 0 8px; font-size: 30px; }
.title p, .muted { color: var(--muted); line-height: 1.8; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
.btn, .btn-soft, .btn-ghost {
border: 0;
border-radius: 999px;
padding: 11px 16px;
cursor: pointer;
font-weight: 700;
}
.btn { color: #fff; background: linear-gradient(135deg, var(--brand), #37a1ff); }
.btn-soft { color: var(--brand); background: var(--soft); }
.btn-ghost { color: var(--text); background: transparent; border: 1px solid var(--line); }
.layout {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.sidebar { padding: 20px; position: sticky; top: 20px; }
.section { margin-bottom: 18px; }
.eyebrow { font-size: 12px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--brand); }
h2 { margin: 8px 0 10px; font-size: 18px; }
.stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 12px;
}
.stat {
padding: 14px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255,255,255,0.3);
}
.stat span { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.stat strong { font-size: 22px; }
.mini-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.mini-stat {
padding: 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255,255,255,0.2);
}
.mini-stat span {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.mini-stat strong { font-size: 20px; }
.search {
display: flex;
gap: 8px;
margin-top: 12px;
}
.search input {
flex: 1;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: transparent;
color: var(--text);
outline: none;
}
.chip-list, .cmd-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.chip {
display: inline-flex;
align-items: center;
padding: 7px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: var(--soft);
color: var(--brand);
}
.results, .modules { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; max-height: 34vh; overflow: auto; }
.result, .lesson-btn, .module-card {
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255,255,255,0.18);
}
.result, .lesson-btn {
width: 100%;
text-align: left;
padding: 12px 14px;
color: var(--text);
cursor: pointer;
}
.result strong, .lesson-btn strong { display: block; margin-bottom: 4px; font-size: 14px; }
.module-card { padding: 14px; }
.module-card h3 { margin: 0 0 8px; font-size: 16px; }
.module-card p { margin: 0 0 10px; color: var(--muted); line-height: 1.7; }
.lesson-list { display: flex; flex-direction: column; gap: 8px; }
.lesson-btn.active { background: rgba(15, 109, 182, 0.12); border-color: rgba(15, 109, 182, 0.28); }
.main { display: flex; flex-direction: column; gap: 16px; }
.hero, .detail, .practice { padding: 22px; }
.hero-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 14px;
}
.hero-head h2 { margin: 8px 0 10px; font-size: 34px; }
.hero-grid, .detail-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 14px;
}
.panel {
border: 1px solid var(--line);
border-radius: 18px;
padding: 16px;
background: rgba(255,255,255,0.12);
}
.panel h3 { margin: 0 0 10px; font-size: 17px; }
.panel p, .panel li { color: var(--muted); line-height: 1.9; }
.panel ul { margin: 0; padding-left: 18px; }
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.code {
margin: 0;
padding: 13px 14px;
border-radius: 14px;
background: var(--terminal);
color: #d8e8ff;
font-family: Consolas, "Courier New", monospace;
font-size: 14px;
overflow: auto;
}
.examples { display: flex; flex-direction: column; gap: 10px; }
.exercise-list { display: flex; flex-direction: column; gap: 12px; margin-top: 14px; }
.exercise {
border: 1px solid var(--line);
border-radius: 18px;
padding: 16px;
background: rgba(255,255,255,0.12);
}
.badge {
display: inline-flex;
margin-bottom: 10px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(29, 155, 108, 0.12);
color: var(--accent);
font-size: 12px;
font-weight: 800;
}
.exercise h4 { margin: 0 0 10px; font-size: 20px; }
.terminal {
margin-top: 12px;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(151,177,201,0.18);
background: linear-gradient(180deg, var(--terminal), #172435);
}
.terminal-head { padding: 11px 14px; color: #b8d4ef; border-bottom: 1px solid rgba(151,177,201,0.16); }
.terminal-input { display: flex; align-items: center; gap: 8px; padding: 12px 14px; }
.prompt { color: #62d6ab; font-family: Consolas, "Courier New", monospace; font-weight: 800; }
.terminal-input input {
flex: 1;
border: 0;
outline: none;
background: transparent;
color: #f4fbff;
font-family: Consolas, "Courier New", monospace;
}
.terminal-input button {
border: 0;
border-radius: 12px;
padding: 9px 13px;
background: linear-gradient(135deg, var(--brand), #37a1ff);
color: #fff;
cursor: pointer;
font-weight: 800;
}
.output {
margin: 0;
min-height: 100px;
padding: 14px;
white-space: pre-wrap;
color: #d8e8ff;
font-family: Consolas, "Courier New", monospace;
}
.feedback { display: none; margin-top: 10px; padding: 11px 13px; border-radius: 14px; line-height: 1.7; font-weight: 700; }
.feedback.show { display: block; }
.feedback.success { background: rgba(29,155,108,0.1); border: 1px solid rgba(29,155,108,0.22); color: #187653; }
.feedback.warn { background: rgba(240,180,41,0.1); border: 1px solid rgba(240,180,41,0.24); color: #a26b04; }
.mastery-note {
margin-top: 12px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.18);
color: var(--muted);
line-height: 1.8;
}
.diagnostic {
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255,255,255,0.18);
color: var(--muted);
line-height: 1.8;
}
.module-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.module-summary-card {
padding: 14px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.18);
}
.module-summary-card h4 {
margin: 0 0 8px;
font-size: 16px;
}
.module-summary-card p {
margin: 0 0 8px;
color: var(--muted);
line-height: 1.7;
}
.stage-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.stage-card {
padding: 14px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.18);
}
.stage-card h4 {
margin: 0 0 8px;
font-size: 16px;
}
.stage-card p {
margin: 0 0 8px;
color: var(--muted);
line-height: 1.7;
}
.lesson-btn.done {
border-color: rgba(29, 155, 108, 0.4);
background: rgba(29, 155, 108, 0.1);
}
.ordered {
margin: 0;
padding-left: 18px;
color: var(--muted);
line-height: 1.9;
}
.empty { padding: 12px; border: 1px dashed var(--line); border-radius: 14px; color: var(--muted); text-align: center; }
@media (max-width: 1180px) {
.layout { grid-template-columns: 1fr; }
.sidebar { position: static; }
.hero-grid, .detail-grid { grid-template-columns: 1fr; }
.mini-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.module-summary-grid { grid-template-columns: 1fr; }
.stage-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.shell { padding: 14px; }
.topbar, .hero-head, .search, .terminal-input { flex-direction: column; align-items: stretch; }
.stats { grid-template-columns: 1fr; }
.mini-stats { grid-template-columns: 1fr; }
.stage-grid { grid-template-columns: 1fr; }
}
:root{--bg:#f4f8fc;--panel:#fff;--line:#d8e2ee;--text:#102033;--muted:#5d7187;--brand:#0f6db6;--soft:#e9f3ff;--terminal:#0e1724}
[data-theme="dark"]{--bg:#08111d;--panel:#0c1826;--line:#1d3245;--text:#edf4fb;--muted:#9bb0c3;--brand:#67baff;--soft:rgba(103,186,255,.1);--terminal:#06101c}
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font-family:"Segoe UI","Microsoft YaHei",sans-serif}
button,input{font:inherit} .shell{max-width:1400px;margin:0 auto;padding:16px} .card{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:16px}
.top,.toolbar,.chips,.filters{display:flex;flex-wrap:wrap;gap:10px}.top{justify-content:space-between;align-items:center;margin-bottom:16px}.layout{display:grid;grid-template-columns:320px 1fr;gap:16px}
.sidebar,.main{display:flex;flex-direction:column;gap:16px}.stats{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.stat{padding:12px;border:1px solid var(--line);border-radius:14px;background:var(--soft)} .stat span{display:block;font-size:12px;color:var(--muted)} .stat strong{font-size:22px}
.results,.modules{display:flex;flex-direction:column;gap:10px;max-height:34vh;overflow:auto}.btn,.ghost,.chip,.filter{border-radius:999px;padding:8px 12px;font-weight:700}.btn{border:0;background:linear-gradient(135deg,var(--brand),#37a1ff);color:#fff;cursor:pointer}.ghost,.filter{border:1px solid var(--line);background:transparent;color:var(--text);cursor:pointer}.chip{background:var(--soft);color:var(--brand);display:inline-flex}
.filter.active,.lesson.active,.moduleJump.active{background:var(--soft);border-color:rgba(15,109,182,.4);color:var(--brand)} .lesson,.searchBtn{width:100%;text-align:left;border:1px solid var(--line);background:transparent;border-radius:14px;padding:12px;cursor:pointer;color:var(--text)}
.search{display:flex;gap:8px}.search input,.terminal input{flex:1;padding:12px;border:1px solid var(--line);border-radius:12px;background:transparent;color:var(--text)} .grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
.terminal{margin-top:12px;border:1px solid var(--line);border-radius:14px;background:var(--terminal);overflow:hidden}.terminalHead,.output{color:#d8e8ff;font-family:Consolas,"Courier New",monospace}.terminalHead{padding:10px 12px;border-bottom:1px solid rgba(151,177,201,.16)}.terminalRow{display:flex;gap:8px;padding:12px}.terminalRow button{border:0;border-radius:10px;padding:8px 12px;background:linear-gradient(135deg,var(--brand),#37a1ff);color:#fff;cursor:pointer}.output{margin:0;min-height:96px;padding:12px;white-space:pre-wrap}.note{padding:10px 12px;border:1px solid var(--line);border-radius:12px;color:var(--muted)}
h1,h2,h3,p{margin:0}.eyebrow{font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--brand)} .muted{color:var(--muted);line-height:1.8}
@media (max-width:1100px){.layout{grid-template-columns:1fr}.grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="shell">
<header class="topbar">
<div class="title">
<div class="eyebrow">Linux Ops Learning Lab</div>
<h1 id="courseTitle">Linux Learning Lab</h1>
<p id="courseDescription">Search Linux lessons, practice commands in a sandbox, and build better troubleshooting habits.</p>
<div class="card top">
<div>
<div class="eyebrow" id="topEyebrow"></div>
<h1 id="courseTitle"></h1>
<p class="muted" id="courseDesc"></p>
</div>
<div class="actions">
<button class="btn-ghost" type="button" id="themeBtn">Toggle theme</button>
<button class="btn-soft" type="button" onclick="resetSandbox()">Reset sandbox</button>
<a class="btn-ghost" href="/privacy" target="_blank" rel="noreferrer">Privacy</a>
<div class="toolbar">
<span class="chip" id="sessionUser"></span>
<button class="ghost" id="themeBtn"></button>
<button class="ghost" id="langBtn"></button>
<button class="ghost" id="logoutBtn"></button>
</div>
</header>
</div>
<div class="layout">
<aside class="sidebar card">
<section class="section">
<div class="eyebrow">Overview</div>
<h2>Course overview</h2>
<p class="muted">Lessons are grouped by module and command family. The page focuses on readable summaries and guided sandbox practice.</p>
<div class="stats">
<div class="stat"><span>Modules</span><strong id="moduleCount">0</strong></div>
<div class="stat"><span>Lessons</span><strong id="lessonCount">0</strong></div>
<div class="stat"><span>Exercises</span><strong id="exerciseCount">0</strong></div>
<div class="stat"><span>Commands</span><strong id="commandCount">0</strong></div>
<div class="stat"><span>Mastered</span><strong id="masteredCount">0</strong></div>
<div class="stat"><span>Completion</span><strong id="completionRate">0%</strong></div>
</div>
<div class="chip-list" id="commandTags"></div>
<p class="muted" id="metaLine" style="margin-top: 12px;">Version --</p>
<aside class="sidebar">
<section class="card">
<div class="eyebrow" id="overviewEyebrow"></div>
<h2 id="overviewTitle"></h2>
<div class="stats" id="stats"></div>
<div class="chips" id="commandTags" style="margin-top:12px;"></div>
<p class="muted" id="metaLine" style="margin-top:12px;"></p>
</section>
<section class="section">
<div class="eyebrow">Search</div>
<h2>Quick lookup</h2>
<div class="search">
<input id="searchInput" type="text" placeholder="Try pwd, grep, module_1, or m1_l1_pwd" />
<button class="btn" type="button" onclick="runSearch()">Search</button>
<section class="card">
<div class="eyebrow" id="searchEyebrow"></div>
<h2 id="searchTitle"></h2>
<div class="search" style="margin-top:12px;">
<input id="searchInput" type="text" />
<button class="btn" id="searchBtn"></button>
</div>
<div class="results" id="searchResults"><div class="empty">Search by command, lesson id, or module id.</div></div>
<div class="results" id="searchResults" style="margin-top:12px;"></div>
</section>
<section class="section">
<div class="eyebrow">Modules</div>
<h2>Course map</h2>
<div class="modules" id="moduleList"><div class="empty">Loading modules...</div></div>
</section>
<section class="section">
<div class="eyebrow">Diagnostics</div>
<h2>Course coverage check</h2>
<div class="mini-stats">
<div class="mini-stat"><span>Coverage</span><strong id="coverageRate">0%</strong></div>
<div class="mini-stat"><span>Operations</span><strong id="operationCount">0</strong></div>
<div class="mini-stat"><span>Reflection</span><strong id="reflectionCount">0</strong></div>
<div class="mini-stat"><span>Unsupported</span><strong id="unsupportedCount">0</strong></div>
</div>
<div class="diagnostic" id="diagnosticNote" style="margin-top: 12px;">Checking course and sandbox alignment...</div>
<section class="card">
<div class="eyebrow" id="modulesEyebrow"></div>
<h2 id="modulesTitle"></h2>
<div class="modules" id="moduleList" style="margin-top:12px;"></div>
</section>
</aside>
<main class="main">
<section class="hero card">
<div class="hero-head">
<div>
<div class="eyebrow" id="moduleLabel">Pick a lesson to begin</div>
<h2 id="lessonTitle">Turn Linux practice into workflow thinking</h2>
<p class="muted" id="lessonGoal">Choose a lesson from the left to see goals, examples, common pitfalls, and runnable exercises.</p>
</div>
<div class="actions">
<button class="btn-soft" type="button" id="masteryBtn">Mark mastered</button>
<button class="btn-ghost" type="button" id="prevBtn">Previous</button>
<button class="btn" type="button" id="nextBtn">Next</button>
</div>
<section class="card">
<div class="eyebrow" id="navEyebrow"></div>
<h2 id="navTitle"></h2>
<p class="muted" id="navText" style="margin-top:8px;"></p>
<div class="filters" id="stageFilters" style="margin-top:12px;"></div>
<p class="muted" id="stageHint" style="margin-top:12px;"></p>
<div class="filters" id="moduleJumps" style="margin-top:12px;"></div>
</section>
<section class="card">
<div class="eyebrow" id="lessonLabel"></div>
<h2 id="lessonTitle" style="margin-top:8px;"></h2>
<p class="muted" id="lessonGoal" style="margin-top:8px;"></p>
<div class="toolbar" style="margin-top:12px;">
<button class="ghost" id="masteryBtn"></button>
<button class="ghost" id="prevBtn"></button>
<button class="btn" id="nextBtn"></button>
</div>
<div class="cmd-list" id="lessonCommands"></div>
<div class="hero-grid">
<div class="panel">
<h3>Module summary</h3>
<p id="moduleSummary">This card shows the learning direction of the current module.</p>
</div>
<div class="panel">
<h3>Sandbox runtime</h3>
<p>You can execute commands directly below. The platform returns simulated output and exercise feedback.</p>
<ul>
<li>Current directory: <span id="runtimeCwd">/</span></li>
<li>Current user: <span id="runtimeUser">sandbox_user</span></li>
</ul>
</div>
<div class="chips" id="lessonCommands" style="margin-top:12px;"></div>
<div class="grid" style="margin-top:16px;">
<article class="card"><h3 id="summaryTitle"></h3><p class="muted" id="moduleSummary" style="margin-top:10px;"></p></article>
<article class="card"><h3 id="runtimeTitle"></h3><p class="muted" id="runtimeText" style="margin-top:10px;"></p><p class="muted" id="runtimeMeta" style="margin-top:10px;"></p></article>
</div>
<div class="mastery-note" id="masteryNote">Master a lesson after you can explain the command, predict the output, and connect it to a real operations step.</div>
<div class="note" id="masteryNote" style="margin-top:12px;"></div>
</section>
<section class="detail card">
<div class="eyebrow">Learning stages</div>
<h2 style="margin: 8px 0 0;">System route from Linux basics to operations habits</h2>
<p class="muted">This view helps you study in phases instead of bouncing between unrelated commands.</p>
<div class="stage-grid" id="stageGrid"></div>
<section class="card grid">
<article class="card"><h3 id="whyTitle"></h3><p class="muted" id="lessonWhy" style="margin-top:10px;"></p></article>
<article class="card"><h3 id="relatedTitle"></h3><div class="chips" id="relatedList" style="margin-top:10px;"></div></article>
<article class="card"><h3 id="conceptsTitle"></h3><ul id="conceptList" class="muted"></ul></article>
<article class="card"><h3 id="flowTitle"></h3><ul id="flowList" class="muted"></ul></article>
</section>
<section class="detail card">
<div class="eyebrow">Module heatmap</div>
<h2 style="margin: 8px 0 0;">See how the learning path is distributed</h2>
<p class="muted">This panel helps you check whether the project really teaches both command basics and operations workflows.</p>
<div class="module-summary-grid" id="moduleSummaryGrid"></div>
</section>
<section class="detail card">
<div class="eyebrow">Learning cockpit</div>
<h2 style="margin: 8px 0 0;">Visual study map for the current lesson</h2>
<p class="muted">These cards help you move from memorizing a command to understanding the workflow around it.</p>
<div class="detail-grid" style="margin-top: 16px;">
<article class="panel">
<h3>Lesson radar</h3>
<div class="mini-stats" id="lessonRadar"></div>
</article>
<article class="panel">
<h3>Experiment ladder</h3>
<ol class="ordered" id="experimentList"></ol>
</article>
<article class="panel">
<h3>Observation checklist</h3>
<ul id="observationList"></ul>
</article>
<article class="panel">
<h3>Related commands</h3>
<div class="chip-list" id="relatedCommandList"></div>
</article>
</div>
</section>
<section class="detail card">
<div class="detail-grid">
<article class="panel">
<h3>Why this matters</h3>
<p id="lessonWhy">Understanding when to use a command matters more than memorizing flags in isolation.</p>
</article>
<article class="panel">
<h3>Teaching angle</h3>
<p id="classicView">Each lesson connects the command to a broader troubleshooting flow.</p>
</article>
<article class="panel">
<h3>Core ideas</h3>
<ul id="conceptList"></ul>
</article>
<article class="panel">
<h3>Common scenarios</h3>
<ul id="scenarioList"></ul>
</article>
<article class="panel">
<h3>Common pitfalls</h3>
<ul id="pitfallList"></ul>
</article>
<article class="panel">
<h3>Troubleshooting flow</h3>
<ul id="flowList"></ul>
</article>
<article class="panel">
<h3>Takeaways</h3>
<ul id="takeawayList"></ul>
<p id="afterClass" style="margin-top: 10px;"></p>
</article>
<article class="panel">
<h3>Example commands</h3>
<div class="examples" id="exampleList"></div>
</article>
</div>
</section>
<section class="practice card">
<div class="eyebrow">Practice</div>
<h2 style="margin: 8px 0 0;">Exercises and sandbox terminal</h2>
<p class="muted">Operation tasks run in the simulated terminal. Reflection tasks keep the learning flow readable even when source data is noisy.</p>
<div class="exercise-list" id="exerciseList"></div>
<section class="card">
<div class="eyebrow" id="practiceEyebrow"></div>
<h2 id="practiceTitle"></h2>
<p class="muted" id="practiceText" style="margin-top:8px;"></p>
<div id="exerciseList" style="display:grid;gap:12px;margin-top:12px;"></div>
</section>
</main>
</div>
</div>
<script>
const state = {
overview: null,
lesson: null,
theme: localStorage.getItem('linux_lab_theme') || 'light',
progress: JSON.parse(localStorage.getItem('linux_lab_progress') || '{}')
};
document.addEventListener('DOMContentLoaded', async () => {
setTheme(state.theme);
document.getElementById('themeBtn').addEventListener('click', () => setTheme(state.theme === 'light' ? 'dark' : 'light'));
document.getElementById('searchInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') runSearch();
});
document.getElementById('prevBtn').addEventListener('click', () => {
if (state.lesson?.previous_lesson) openLesson(state.lesson.previous_lesson.id);
});
document.getElementById('nextBtn').addEventListener('click', () => {
if (state.lesson?.next_lesson) openLesson(state.lesson.next_lesson.id);
});
document.getElementById('masteryBtn').addEventListener('click', toggleMastery);
await loadOverview();
});
function saveProgress() {
localStorage.setItem('linux_lab_progress', JSON.stringify(state.progress));
}
function masteredCount() {
return Object.values(state.progress).filter(Boolean).length;
}
function isMastered(lessonId) {
return Boolean(state.progress[lessonId]);
}
function completionRate() {
const total = state.overview?.meta?.lesson_count || 0;
if (!total) return 0;
return Math.round((masteredCount() / total) * 100);
}
function toggleMastery() {
if (!state.lesson?.id) return;
state.progress[state.lesson.id] = !state.progress[state.lesson.id];
saveProgress();
renderOverview();
renderModules();
renderLesson();
}
async function loadOverview() {
const response = await fetch('/api/overview');
state.overview = await response.json();
renderOverview();
const firstLesson = state.overview.modules?.[0]?.lessons?.[0];
if (firstLesson) await openLesson(firstLesson.id);
}
function renderOverview() {
const meta = state.overview.meta || {};
document.getElementById('courseTitle').textContent = meta.title || 'Linux Learning Lab';
document.getElementById('courseDescription').textContent = meta.description || '';
document.getElementById('moduleCount').textContent = meta.module_count || 0;
document.getElementById('lessonCount').textContent = meta.lesson_count || 0;
document.getElementById('exerciseCount').textContent = meta.exercise_count || 0;
document.getElementById('commandCount').textContent = meta.command_count || 0;
document.getElementById('masteredCount').textContent = masteredCount();
document.getElementById('completionRate').textContent = completionRate() + '%';
document.getElementById('coverageRate').textContent = (state.overview.diagnostics?.coverage_rate ?? 0) + '%';
document.getElementById('operationCount').textContent = state.overview.diagnostics?.operation_exercises ?? 0;
document.getElementById('reflectionCount').textContent = state.overview.diagnostics?.reflection_exercises ?? 0;
document.getElementById('unsupportedCount').textContent = state.overview.diagnostics?.unsupported_count ?? 0;
document.getElementById('diagnosticNote').textContent = (state.overview.diagnostics?.unsupported_count ?? 0) === 0
? 'All commands referenced by the course are covered by the sandbox command set.'
: 'Some course commands are not fully supported by the sandbox yet.';
document.getElementById('metaLine').textContent = `Version ${meta.version || '4.0'} | Updated ${meta.updated || '--'}`;
renderRuntime(state.overview.runtime || {});
renderCommandTags(state.overview.commands || []);
renderModules();
renderStageGrid();
renderModuleSummaryGrid();
}
function renderRuntime(runtime) {
document.getElementById('runtimeCwd').textContent = runtime.cwd || '/';
document.getElementById('runtimeUser').textContent = runtime.user || 'sandbox_user';
}
function renderCommandTags(commands) {
const container = document.getElementById('commandTags');
if (!commands.length) {
container.innerHTML = '<span class="chip">Waiting for command index</span>';
return;
}
container.innerHTML = commands.slice(0, 10).map((item) => `<span class="chip">${escapeHtml(item)}</span>`).join('');
}
function renderModules() {
const modules = state.overview.modules || [];
const container = document.getElementById('moduleList');
if (!modules.length) {
container.innerHTML = '<div class="empty">No modules found.</div>';
return;
}
container.innerHTML = modules.map((module) => `
<article class="module-card">
<h3>${escapeHtml(module.display_title)}</h3>
<p>${escapeHtml(module.display_summary)}</p>
<div class="lesson-list">
${(module.lessons || []).map((lesson) => `
<button class="lesson-btn ${state.lesson?.id === lesson.id ? 'active' : ''} ${isMastered(lesson.id) ? 'done' : ''}" type="button" onclick="openLesson('${lesson.id}')">
<strong>${isMastered(lesson.id) ? 'Completed - ' : ''}${escapeHtml(lesson.display_title)}</strong>
<span class="muted">${escapeHtml((lesson.command_tokens || []).join(' | ') || lesson.command || 'mixed commands')} | ${lesson.exercise_count || 0} exercises</span>
</button>
`).join('')}
</div>
</article>
`).join('');
}
function renderModuleSummaryGrid() {
const modules = state.overview.modules || [];
const target = document.getElementById('moduleSummaryGrid');
if (!modules.length) {
target.innerHTML = '<div class="empty">No module diagnostics found.</div>';
return;
}
target.innerHTML = modules.map((module) => `
<article class="module-summary-card">
<h4>${escapeHtml(module.display_title)}</h4>
<p>${escapeHtml(module.display_summary)}</p>
<div class="mini-stats">
<div class="mini-stat"><span>Lessons</span><strong>${module.lesson_count || 0}</strong></div>
<div class="mini-stat"><span>Exercises</span><strong>${module.exercise_count || 0}</strong></div>
<div class="mini-stat"><span>Ops</span><strong>${module.operation_count || 0}</strong></div>
<div class="mini-stat"><span>Commands</span><strong>${module.command_count || 0}</strong></div>
</div>
</article>
`).join('');
}
function renderStageGrid() {
const groups = [
{ title: 'Stage 1: Foundations', summary: 'Orientation, listing, file operations, and permissions.', range: [0, 1] },
{ title: 'Stage 2: Search & Observation', summary: 'Text search, log preview, process, service, and network inspection.', range: [2, 3] },
{ title: 'Stage 3: Incidents', summary: 'Disk, auth, and service-path drills that connect commands into playbooks.', range: [4, 4] },
{ title: 'Stage 4: Automation & Platform', summary: 'Shell state, package tools, archives, monitoring, and scheduling.', range: [5, 7] }
];
const modules = state.overview.modules || [];
const target = document.getElementById('stageGrid');
target.innerHTML = groups.map((group) => {
const selectedModules = modules.slice(group.range[0], group.range[1] + 1);
const lessonTotal = selectedModules.reduce((sum, item) => sum + (item.lesson_count || 0), 0);
const exerciseTotal = selectedModules.reduce((sum, item) => sum + (item.exercise_count || 0), 0);
return `
<article class="stage-card">
<h4>${escapeHtml(group.title)}</h4>
<p>${escapeHtml(group.summary)}</p>
<div class="mini-stats">
<div class="mini-stat"><span>Modules</span><strong>${selectedModules.length}</strong></div>
<div class="mini-stat"><span>Lessons</span><strong>${lessonTotal}</strong></div>
<div class="mini-stat"><span>Exercises</span><strong>${exerciseTotal}</strong></div>
<div class="mini-stat"><span>Mastered</span><strong>${selectedModules.reduce((sum, item) => sum + (item.lessons || []).filter((lesson) => isMastered(lesson.id)).length, 0)}</strong></div>
</div>
</article>
`;
}).join('');
}
async function openLesson(lessonId, focusExerciseId = '') {
const response = await fetch('/api/lesson?id=' + encodeURIComponent(lessonId));
const payload = await response.json();
state.lesson = payload.lesson;
renderModules();
renderLesson();
if (focusExerciseId) {
setTimeout(() => {
document.getElementById('exercise-' + focusExerciseId)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 120);
}
}
function renderLesson() {
const lesson = state.lesson;
if (!lesson) return;
document.getElementById('moduleLabel').textContent = lesson.display_module_title || 'Current lesson';
document.getElementById('lessonTitle').textContent = lesson.display_title || 'Linux lesson';
document.getElementById('lessonGoal').textContent = lesson.display_goal || '';
document.getElementById('moduleSummary').textContent = lesson.display_module_summary || '';
document.getElementById('lessonWhy').textContent = lesson.display_why || '';
document.getElementById('classicView').textContent = lesson.classic_view || '';
document.getElementById('afterClass').textContent = lesson.after_class || '';
document.getElementById('masteryBtn').textContent = isMastered(lesson.id) ? 'Mark for review' : 'Mark mastered';
document.getElementById('masteryNote').textContent = isMastered(lesson.id)
? 'This lesson is marked as mastered. Revisit it if you cannot explain the command chain from memory.'
: 'Mark a lesson as mastered after you can explain the command, predict the output, and choose the next troubleshooting step.';
document.getElementById('prevBtn').disabled = !lesson.previous_lesson;
document.getElementById('nextBtn').disabled = !lesson.next_lesson;
document.getElementById('lessonCommands').innerHTML = (lesson.command_tokens || [lesson.command || 'mixed commands']).map((item) => `<span class="chip">${escapeHtml(item)}</span>`).join('');
renderLessonCockpit(lesson);
renderList('conceptList', lesson.concepts, 'No concept notes yet.');
renderList('scenarioList', lesson.scenarios, 'No scenario notes yet.');
renderList('pitfallList', lesson.pitfalls, 'No pitfall notes yet.');
renderList('flowList', lesson.troubleshooting_flow, 'No flow notes yet.');
renderList('takeawayList', lesson.takeaways, 'No takeaways yet.');
const examples = lesson.examples || [];
document.getElementById('exampleList').innerHTML = examples.length ? examples.map((item) => `<pre class="code">${escapeHtml(item)}</pre>`).join('') : '<div class="empty">No example commands yet.</div>';
renderExercises(lesson.exercises || []);
}
function renderLessonCockpit(lesson) {
document.getElementById('lessonRadar').innerHTML = [
{ label: 'Commands', value: lesson.command_tokens?.length || 1 },
{ label: 'Exercises', value: lesson.exercise_count || 0 },
{ label: 'Pitfalls', value: lesson.pitfalls?.length || 0 },
{ label: 'Related', value: lesson.related_commands?.length || 0 }
].map((item) => `
<div class="mini-stat">
<span>${escapeHtml(item.label)}</span>
<strong>${item.value}</strong>
</div>
`).join('');
const experimentSteps = [
`Confirm the goal: ${lesson.display_goal || 'Understand the command objective.'}`,
`Run ${lesson.command || 'the main command'} and inspect the immediate output.`,
`Compare the output with one example or scenario from this lesson.`,
`Choose the next troubleshooting step based on what the command revealed.`
];
document.getElementById('experimentList').innerHTML = experimentSteps.map((item) => `<li>${escapeHtml(item)}</li>`).join('');
const observationItems = [
`Before running: confirm the current context and target path.`,
`During output review: look for the field or line that answers the lesson goal.`,
`After running: explain what should happen next in a real operations flow.`
];
document.getElementById('observationList').innerHTML = observationItems.map((item) => `<li>${escapeHtml(item)}</li>`).join('');
const related = lesson.related_commands || [];
document.getElementById('relatedCommandList').innerHTML = related.length
? related.map((item) => `<span class="chip">${escapeHtml(item)}</span>`).join('')
: '<span class="chip">No related commands yet</span>';
}
function renderList(id, items, emptyText) {
const target = document.getElementById(id);
target.innerHTML = (items && items.length) ? items.map((item) => `<li>${escapeHtml(item)}</li>`).join('') : `<li>${escapeHtml(emptyText)}</li>`;
}
function renderExercises(exercises) {
const container = document.getElementById('exerciseList');
if (!exercises.length) {
container.innerHTML = '<div class="empty">This lesson does not have exercises yet.</div>';
return;
}
container.innerHTML = exercises.map((exercise) => {
const label = exercise.type === 'operation' ? 'Operation task' : exercise.type === 'scenario' ? 'Scenario reflection' : 'Understanding task';
if (exercise.type === 'operation') {
const placeholder = exercise.solution?.[0] || state.lesson.command || 'pwd';
return `
<article class="exercise" id="exercise-${exercise.id}">
<span class="badge">${label}</span>
<h4>${escapeHtml(exercise.title || 'Operation task')}</h4>
<p class="muted">${escapeHtml(exercise.hint || 'Enter a command and inspect the output.')}</p>
<div class="terminal">
<div class="terminal-head">Linux sandbox terminal</div>
<div class="terminal-input">
<span class="prompt">$</span>
<input id="input-${exercise.id}" type="text" placeholder="${escapeHtml(placeholder)}" onkeydown="if(event.key==='Enter'){runExercise('${exercise.id}')}" />
<button type="button" onclick="runExercise('${exercise.id}')">Run</button>
</div>
<pre class="output" id="output-${exercise.id}">Waiting for a command...</pre>
</div>
<div class="feedback" id="feedback-${exercise.id}"></div>
</article>
`;
}
return `
<article class="exercise" id="exercise-${exercise.id}">
<span class="badge">${label}</span>
<h4>${escapeHtml(exercise.question || 'Reflection task')}</h4>
<p>${escapeHtml(exercise.answer || 'Summarize the purpose, output, and next step in your own words.')}</p>
</article>
`;
}).join('');
}
async function runExercise(exerciseId) {
const input = document.getElementById('input-' + exerciseId);
const output = document.getElementById('output-' + exerciseId);
const feedback = document.getElementById('feedback-' + exerciseId);
const cmd = input.value.trim();
if (!cmd) return;
output.textContent = `$ ${cmd}\n\nRunning...`;
feedback.className = 'feedback';
feedback.textContent = '';
try {
const runResponse = await fetch('/api/run?cmd=' + encodeURIComponent(cmd));
const runData = await runResponse.json();
output.textContent = `$ ${cmd}\n\n${runData.output || runData.message || '(no output)'}`;
renderRuntime({ cwd: runData.cwd || '/', user: state.overview?.runtime?.user || 'sandbox_user' });
const checkResponse = await fetch('/api/check?exercise_id=' + encodeURIComponent(exerciseId) + '&last_cmd=' + encodeURIComponent(cmd) + '&output=' + encodeURIComponent(runData.output || ''));
const checkData = await checkResponse.json();
feedback.className = 'feedback show ' + (checkData.success ? 'success' : 'warn');
feedback.textContent = checkData.message + (checkData.next_suggestion ? '\n' + checkData.next_suggestion : '');
} catch (error) {
output.textContent = `$ ${cmd}\n\nRequest failed: ${error.message}`;
feedback.className = 'feedback show warn';
feedback.textContent = 'The request failed. Please try again.';
}
}
async function resetSandbox() {
await fetch('/api/reset', { method: 'POST' });
renderRuntime({ cwd: '/', user: 'sandbox_user' });
alert('Sandbox reset complete.');
}
async function runSearch() {
const query = document.getElementById('searchInput').value.trim();
const container = document.getElementById('searchResults');
if (!query) {
container.innerHTML = '<div class="empty">Search by command, lesson id, or module id.</div>';
return;
}
container.innerHTML = '<div class="empty">Searching...</div>';
const response = await fetch('/api/course/search?q=' + encodeURIComponent(query));
const payload = await response.json();
const results = payload.results || [];
if (!results.length) {
container.innerHTML = '<div class="empty">No results found. Try a command name or lesson id.</div>';
return;
}
container.innerHTML = results.map((item) => `
<button class="result" type="button" onclick="openLesson('${item.lesson_id}', '${item.exercise_id || ''}')">
<strong>${escapeHtml(item.title)}</strong>
<span class="muted">${escapeHtml(item.subtitle || item.type)}</span>
</button>
`).join('');
}
function setTheme(theme) {
state.theme = theme;
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('linux_lab_theme', theme);
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
window.openLesson = openLesson;
window.runExercise = runExercise;
window.runSearch = runSearch;
window.resetSandbox = resetSandbox;
const D=v=>{const e=document.createElement('textarea');e.innerHTML=v;return e.value};const $=id=>document.getElementById(id);const E=v=>String(v).replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('\"','&quot;').replaceAll("'",'&#39;');
const LANG={zh:{top:'Linux &#x8FD0;&#x7EF4;&#x5B66;&#x4E60;&#x5B9E;&#x9A8C;&#x5BA4;',title:'Linux &#x5B66;&#x4E60;&#x5B9E;&#x9A8C;&#x5BA4;',desc:'&#x767B;&#x5F55;&#x540E;&#x5728;&#x6C99;&#x7BB1;&#x91CC;&#x5B66;&#x547D;&#x4EE4;&#x3001;&#x505A;&#x7EC3;&#x4E60;&#x3001;&#x770B;&#x603B;&#x89C8;&#x3002;',session:'&#x5F53;&#x524D;&#x7528;&#x6237;',themeDark:'&#x6DF1;&#x8272;&#x6A21;&#x5F0F;',themeLight:'&#x6D45;&#x8272;&#x6A21;&#x5F0F;',logout:'&#x9000;&#x51FA;&#x767B;&#x5F55;',overview:'&#x603B;&#x89C8;',nav:'&#x8BFE;&#x7A0B;&#x5FEB;&#x901F;&#x5165;&#x53E3;',navText:'&#x6309;&#x9636;&#x6BB5;&#x6216;&#x6A21;&#x5757;&#x5FEB;&#x901F;&#x8DF3;&#x8F6C;&#x3002;',search:'&#x641C;&#x7D22;',searchBtn:'&#x641C;&#x7D22;',searchPlaceholder:'&#x8BD5;&#x8BD5; pwd&#x3001;grep &#x6216; module_1',modules:'&#x6A21;&#x5757;',all:'&#x5168;&#x90E8;&#x9636;&#x6BB5;',startLabel:'&#x5148;&#x9009;&#x4E00;&#x8282;&#x8BFE;&#x5F00;&#x59CB;',startTitle:'Linux &#x5B66;&#x4E60;&#x9A7E;&#x9A76;&#x8231;',startGoal:'&#x4ECE;&#x5DE6;&#x4FA7;&#x9009;&#x62E9;&#x8BFE;&#x7A0B;&#xFF0C;&#x67E5;&#x770B;&#x76EE;&#x6807;&#x4E0E;&#x7EC3;&#x4E60;&#x3002;',summary:'&#x6A21;&#x5757;&#x6458;&#x8981;',runtime:'&#x6C99;&#x7BB1;&#x8FD0;&#x884C;&#x6001;',runtimeText:'&#x4E0B;&#x65B9;&#x53EF;&#x76F4;&#x63A5;&#x6267;&#x884C;&#x547D;&#x4EE4;&#x3002;',mark:'&#x6807;&#x8BB0;&#x5DF2;&#x638C;&#x63E1;',review:'&#x6807;&#x8BB0;&#x5F85;&#x590D;&#x4E60;',prev:'&#x4E0A;&#x4E00;&#x8BFE;',next:'&#x4E0B;&#x4E00;&#x8BFE;',why:'&#x4E3A;&#x4EC0;&#x4E48;&#x91CD;&#x8981;',related:'&#x76F8;&#x5173;&#x547D;&#x4EE4;',concepts:'&#x6838;&#x5FC3;&#x6982;&#x5FF5;',flow:'&#x6392;&#x969C;&#x6D41;&#x7A0B;',practice:'&#x7EC3;&#x4E60;',practiceText:'&#x64CD;&#x4F5C;&#x9898;&#x76F4;&#x63A5;&#x5728;&#x6A21;&#x62DF;&#x7EC8;&#x7AEF;&#x4E2D;&#x8FD0;&#x884C;&#x3002;',run:'&#x8FD0;&#x884C;',terminal:'Linux &#x6C99;&#x7BB1;&#x7EC8;&#x7AEF;',waiting:'&#x7B49;&#x5F85;&#x8F93;&#x5165;&#x547D;&#x4EE4;...',empty:'&#x6682;&#x65E0;&#x5185;&#x5BB9;&#x3002;',searchEmpty:'&#x53EF;&#x4EE5;&#x6309;&#x547D;&#x4EE4;&#x540D;&#x6216;&#x8BFE;&#x7A0B; ID &#x641C;&#x7D22;&#x3002;',searchLoading:'&#x6B63;&#x5728;&#x641C;&#x7D22;...',searchNo:'&#x6CA1;&#x6709;&#x627E;&#x5230;&#x7ED3;&#x679C;&#x3002;',authExpired:'&#x767B;&#x5F55;&#x72B6;&#x6001;&#x5DF2;&#x5931;&#x6548;&#xFF0C;&#x8BF7;&#x91CD;&#x65B0;&#x767B;&#x5F55;&#x3002;',logoutDone:'&#x5DF2;&#x9000;&#x51FA;&#x767B;&#x5F55;&#xFF0C;&#x8BF7;&#x91CD;&#x65B0;&#x767B;&#x5F55;&#x3002;',stages:[{key:'all',title:'&#x5168;&#x90E8;&#x9636;&#x6BB5;'},{key:'stage-1',title:'&#x9636;&#x6BB5; 1',range:[0,1]},{key:'stage-2',title:'&#x9636;&#x6BB5; 2',range:[2,3]},{key:'stage-3',title:'&#x9636;&#x6BB5; 3',range:[4,4]},{key:'stage-4',title:'&#x9636;&#x6BB5; 4',range:[5,7]},{key:'stage-5',title:'&#x9636;&#x6BB5; 5',range:[8,9]}]},en:{top:'Linux Ops Learning Lab',title:'Linux Learning Lab',desc:'Sign in to learn commands, practice in the sandbox, and navigate the course overview.',session:'Current user',themeDark:'Dark mode',themeLight:'Light mode',logout:'Log out',overview:'Overview',nav:'Course quick access',navText:'Jump quickly by stage or module.',search:'Search',searchBtn:'Search',searchPlaceholder:'Try pwd, grep, or module_1',modules:'Modules',all:'All stages',startLabel:'Pick a lesson to begin',startTitle:'Linux learning cockpit',startGoal:'Choose a lesson from the left to inspect goals and exercises.',summary:'Module summary',runtime:'Sandbox runtime',runtimeText:'Run commands directly below.',mark:'Mark mastered',review:'Mark for review',prev:'Previous',next:'Next',why:'Why this matters',related:'Related commands',concepts:'Core ideas',flow:'Troubleshooting flow',practice:'Practice',practiceText:'Operation tasks run inside the simulated terminal.',run:'Run',terminal:'Linux sandbox terminal',waiting:'Waiting for a command...',empty:'No content yet.',searchEmpty:'Search by command or lesson id.',searchLoading:'Searching...',searchNo:'No results found.',authExpired:'Session expired. Please sign in again.',logoutDone:'Logged out. Please sign in again.',stages:[{key:'all',title:'All stages'},{key:'stage-1',title:'Stage 1',range:[0,1]},{key:'stage-2',title:'Stage 2',range:[2,3]},{key:'stage-3',title:'Stage 3',range:[4,4]},{key:'stage-4',title:'Stage 4',range:[5,7]},{key:'stage-5',title:'Stage 5',range:[8,9]}]}};
const state={overview:null,lesson:null,language:localStorage.getItem('linux_lab_language')||'zh',theme:localStorage.getItem('linux_lab_theme')||'light',progress:JSON.parse(localStorage.getItem('linux_lab_progress')||'{}'),user:'',stage:'all'}; const T=()=>LANG[state.language]; const tx=k=>D(T()[k]||'');
const api=async(u,o={})=>{const r=await fetch(u,{credentials:'same-origin',...o}); if(r.status===401){localStorage.setItem('linux_lab_login_notice',tx('authExpired')); location.href='/login.html'; throw new Error(tx('authExpired'));} return r}; const save=()=>localStorage.setItem('linux_lab_progress',JSON.stringify(state.progress)); const mastered=id=>Boolean(state.progress[id]); const tokens=t=>Array.isArray(t?.command_tokens)&&t.command_tokens.length?t.command_tokens.filter(Boolean):String(t?.command||'').split('/').map(s=>s.trim()).filter(Boolean); const label=t=>tokens(t).join(' / ')||'command';
const buildStageGroups=()=>{const modules=state.overview?.modules||[];const stageCount=Math.max(1,Math.min(5,Math.ceil(modules.length/2)));const chunkSize=Math.max(1,Math.ceil(modules.length/stageCount));const allLabel=state.language==='zh'?'全部阶段':'All stages';const stagePrefix=state.language==='zh'?'阶段':'Stage';const groups=[{key:'all',title:allLabel,modules}];for(let i=0;i<stageCount;i+=1){const start=i*chunkSize;const end=Math.min(start+chunkSize,modules.length);if(start>=modules.length)break;groups.push({key:`stage-${i+1}`,title:`${stagePrefix} ${i+1}`,modules:modules.slice(start,end)});}return groups}; const groups=()=>buildStageGroups(); const visibleModules=()=>state.stage==='all'?(state.overview?.modules||[]):(groups().find(g=>g.key===state.stage)?.modules||[]);
function setTheme(theme,persist=true){state.theme=theme==='dark'?'dark':'light';document.documentElement.setAttribute('data-theme',state.theme);if(persist)localStorage.setItem('linux_lab_theme',state.theme);$('themeBtn').textContent=state.theme==='light'?tx('themeDark'):tx('themeLight')}
function setLanguage(lang,persist=true){state.language=lang==='en'?'en':'zh';if(persist)localStorage.setItem('linux_lab_language',state.language);renderText();if(state.overview)renderOverview();if(state.lesson)renderLesson()}
function renderText(){document.title=tx('title');document.documentElement.lang=state.language==='zh'?'zh-CN':'en';$('topEyebrow').textContent=tx('top');$('courseTitle').textContent=tx('title');$('courseDesc').textContent=tx('desc');$('sessionUser').textContent=`${tx('session')}: ${state.user||'admin'}`;$('themeBtn').textContent=state.theme==='light'?tx('themeDark'):tx('themeLight');$('langBtn').textContent=state.language==='zh'?'EN':'\u4E2D\u6587';$('logoutBtn').textContent=tx('logout');$('overviewEyebrow').textContent=tx('overview');$('overviewTitle').textContent=tx('overview');$('searchEyebrow').textContent=tx('search');$('searchTitle').textContent=tx('search');$('searchBtn').textContent=tx('searchBtn');$('searchInput').placeholder=tx('searchPlaceholder');$('modulesEyebrow').textContent=tx('modules');$('modulesTitle').textContent=tx('modules');$('navEyebrow').textContent=tx('nav');$('navTitle').textContent=tx('nav');$('navText').textContent=tx('navText');$('lessonLabel').textContent=tx('startLabel');$('lessonTitle').textContent=tx('startTitle');$('lessonGoal').textContent=tx('startGoal');$('summaryTitle').textContent=tx('summary');$('runtimeTitle').textContent=tx('runtime');$('runtimeText').textContent=tx('runtimeText');$('masteryBtn').textContent=tx('mark');$('prevBtn').textContent=tx('prev');$('nextBtn').textContent=tx('next');$('whyTitle').textContent=tx('why');$('relatedTitle').textContent=tx('related');$('conceptsTitle').textContent=tx('concepts');$('flowTitle').textContent=tx('flow');$('practiceEyebrow').textContent=tx('practice');$('practiceTitle').textContent=tx('practice');$('practiceText').textContent=tx('practiceText')}
function renderEmpty(){$('searchResults').innerHTML=`<div class=\"note\">${E(tx('searchEmpty'))}</div>`;$('moduleList').innerHTML=`<div class=\"note\">${E(tx('empty'))}</div>`;$('lessonCommands').innerHTML='';$('moduleSummary').textContent=tx('startGoal');$('masteryNote').textContent=tx('empty');$('relatedList').innerHTML='';$('conceptList').innerHTML=`<li>${E(tx('empty'))}</li>`;$('flowList').innerHTML=`<li>${E(tx('empty'))}</li>`;$('exampleList')&&($('exampleList').innerHTML='');$('exerciseList').innerHTML=`<div class=\"note\">${E(tx('empty'))}</div>`}
function renderOverview(){const m=state.overview.meta||{};const labels=state.language==='zh'?['\u6A21\u5757','\u8BFE\u7A0B','\u7EC3\u4E60','\u547D\u4EE4','\u5DF2\u638C\u63E1','\u5B8C\u6210\u5EA6']:['Modules','Lessons','Exercises','Commands','Mastered','Completion'];const masteredCount=Object.values(state.progress).filter(Boolean).length;const stageGroups=groups();$('stats').innerHTML=[[labels[0],m.module_count||0],[labels[1],m.lesson_count||0],[labels[2],m.exercise_count||0],[labels[3],m.command_count||0],[labels[4],masteredCount],[labels[5],`${m.lesson_count?Math.round(masteredCount*100/m.lesson_count):0}%`]].map(([k,v])=>`<div class=\"stat\"><span>${E(k)}</span><strong>${v}</strong></div>`).join('');$('commandTags').innerHTML=(state.overview.commands||[]).map(c=>`<span class=\"chip\">${E(c)}</span>`).join('');$('metaLine').textContent=`v${m.version||'4.0'} | ${m.updated||'--'}`;$('runtimeMeta').textContent=`${state.language==='zh'?'\u6C99\u7BB1\u8DEF\u5F84':'Sandbox path'}: ${state.overview.runtime?.cwd||'/'} / ${state.overview.runtime?.user||'sandbox_user'}`;$('stageFilters').innerHTML=stageGroups.map(g=>`<button class=\"filter ${state.stage===g.key?'active':''}\" type=\"button\" onclick=\"setStage('${g.key}')\">${E(D(g.title||g.key))}</button>`).join('');$('stageHint').textContent=state.stage==='all'?tx('navText'):D(stageGroups.find(g=>g.key===state.stage)?.title||'');const modules=visibleModules();$('moduleJumps').innerHTML=modules.map(mod=>`<button class=\"ghost moduleJump ${state.lesson?.module_id===mod.id?'active':''}\" type=\"button\" onclick=\"openModule('${mod.id}')\">${E(mod.display_title||mod.title||mod.id)}</button>`).join('');$('moduleList').innerHTML=modules.map(mod=>`<button class=\"lesson ${state.lesson?.module_id===mod.id?'active':''}\" type=\"button\" onclick=\"openModule('${mod.id}')\"><strong>${E(mod.display_title||mod.title||mod.id)}</strong><span class=\"muted\">${E(mod.display_summary||mod.summary||'')}</span></button>`).join('')||`<div class=\"note\">${E(tx('empty'))}</div>`}
function renderLesson(){if(!state.lesson)return;const l=state.lesson;const commandLabel=label(l);$('lessonLabel').textContent=l.display_module_title||l.module_id;$('lessonTitle').textContent=l.display_title||l.id;$('lessonGoal').textContent=state.language==='zh'?`\u56F4\u7ED5 ${commandLabel} \u7EC3\u4E60\u201C\u5148\u786E\u8BA4\u4E0A\u4E0B\u6587\uFF0C\u518D\u89C2\u5BDF\u8F93\u51FA\uFF0C\u518D\u51B3\u5B9A\u4E0B\u4E00\u6B65\u201D\u7684 Linux \u64CD\u4F5C\u601D\u8DEF\u3002`:(l.display_goal||`Practice a Linux workflow around ${commandLabel}: confirm context, inspect output, then decide the next step.`);$('moduleSummary').textContent=l.display_module_summary||'';$('masteryBtn').textContent=mastered(l.id)?tx('review'):tx('mark');$('masteryNote').textContent=mastered(l.id)?tx('review'):tx('mark');$('prevBtn').disabled=!l.previous_lesson;$('nextBtn').disabled=!l.next_lesson;$('lessonCommands').innerHTML=tokens(l).map(c=>`<span class=\"chip\">${E(c)}</span>`).join('');$('lessonWhy').textContent=state.language==='zh'?`\u5728\u771F\u5B9E\u8FD0\u7EF4\u91CC\uFF0C${commandLabel} \u5F80\u5F80\u4E0D\u662F\u7EC8\u70B9\uFF0C\u800C\u662F\u4E0B\u4E00\u6B65\u68C0\u67E5\u3001\u4FEE\u590D\u6216\u9A8C\u8BC1\u52A8\u4F5C\u7684\u8D77\u70B9\u3002`:(l.display_why||`In real operations work, ${commandLabel} often becomes the starting point for the next inspection, repair, or verification step.`);$('relatedList').innerHTML=(l.related_commands||[]).map(c=>`<span class=\"chip\">${E(c)}</span>`).join('')||`<span class=\"chip\">${E(tx('empty'))}</span>`;$('conceptList').innerHTML=(l.concepts||[]).map(i=>`<li>${E(i)}</li>`).join('')||`<li>${E(tx('empty'))}</li>`;$('flowList').innerHTML=(l.troubleshooting_flow||[]).map(i=>`<li>${E(i)}</li>`).join('')||`<li>${E(tx('empty'))}</li>`;renderExercises(l.exercises||[]);renderOverview()}
function renderExercises(items){$('exerciseList').innerHTML=items.length?items.map(ex=>ex.type!=='operation'?`<article class=\"card\"><h3>${E(ex.question||ex.title||tx('practice'))}</h3><p class=\"muted\" style=\"margin-top:10px;white-space:pre-line;\">${E(ex.answer||tx('empty'))}</p></article>`:`<article class=\"card\"><div class=\"badge\">${E(tx('practice'))}</div><h3 style=\"margin-top:10px;\">${E(ex.title||tx('practice'))}</h3><p class=\"muted\" style=\"margin-top:10px;white-space:pre-line;\">${E(ex.hint||tx('empty'))}</p><div class=\"terminal\"><div class=\"terminalHead\">${E(tx('terminal'))}</div><div class=\"terminalRow\"><input id=\"input-${ex.id}\" type=\"text\" placeholder=\"${E((ex.solution||[])[0]||'pwd')}\" onkeydown=\"if(event.key==='Enter'){runExercise('${ex.id}')}\"><button type=\"button\" onclick=\"runExercise('${ex.id}')\">${E(tx('run'))}</button></div><pre class=\"output\" id=\"output-${ex.id}\">${E(tx('waiting'))}</pre></div><div class=\"note\" id=\"feedback-${ex.id}\" style=\"display:none;margin-top:10px;\"></div></article>`).join(''):`<div class=\"note\">${E(tx('empty'))}</div>`}
async function openLesson(id){const r=await api('/api/lesson?id='+encodeURIComponent(id));const p=await r.json();state.lesson=p.lesson;renderLesson()}
function openModule(id){const m=(state.overview?.modules||[]).find(x=>x.id===id);const l=m?.lessons?.[0];if(l)openLesson(l.id)}
function setStage(key){state.stage=key;renderOverview()}
function toggleMastery(){if(!state.lesson?.id)return;state.progress[state.lesson.id]=!state.progress[state.lesson.id];save();renderLesson()}
async function runSearch(){const q=$('searchInput').value.trim();if(!q){$('searchResults').innerHTML=`<div class=\"note\">${E(tx('searchEmpty'))}</div>`;return}$('searchResults').innerHTML=`<div class=\"note\">${E(tx('searchLoading'))}</div>`;const r=await api('/api/course/search?q='+encodeURIComponent(q));const p=await r.json();$('searchResults').innerHTML=(p.results||[]).length?(p.results||[]).map(it=>`<button class=\"searchBtn\" type=\"button\" onclick=\"openLesson('${it.lesson_id}')\"><strong>${E(it.title)}</strong><span class=\"muted\">${E(it.subtitle||'')}</span></button>`).join(''):`<div class=\"note\">${E(tx('searchNo'))}</div>`}
async function runExercise(id){const input=$('input-'+id),out=$('output-'+id),fb=$('feedback-'+id),cmd=input.value.trim();if(!cmd)return;out.textContent=`$ ${cmd}\\n\\n...`;const rr=await api('/api/run?cmd='+encodeURIComponent(cmd));const rd=await rr.json();out.textContent=`$ ${cmd}\\n\\n${rd.output||rd.message||''}`;const cr=await api('/api/check?exercise_id='+encodeURIComponent(id)+'&last_cmd='+encodeURIComponent(cmd)+'&output='+encodeURIComponent(rd.output||''));const cd=await cr.json();fb.style.display='block';fb.textContent=cd.message+(cd.next_suggestion?'\\n'+cd.next_suggestion:'')}
async function logoutLab(){try{await fetch('/api/logout',{method:'POST',credentials:'same-origin'})}catch(e){}localStorage.setItem('linux_lab_login_notice',tx('logoutDone'));location.href='/login.html'}
async function boot(){const s=await fetch('/api/session',{credentials:'same-origin'});const p=await s.json();if(!p.authenticated){localStorage.setItem('linux_lab_login_notice',tx('authExpired'));location.href='/login.html';return}state.user=p.user||'admin';renderText();renderEmpty();setTheme(state.theme,false);const r=await api('/api/overview');state.overview=await r.json();renderOverview();const first=state.overview?.modules?.[0]?.lessons?.[0];if(first)await openLesson(first.id)}
document.addEventListener('DOMContentLoaded',()=>{$('themeBtn').addEventListener('click',()=>setTheme(state.theme==='light'?'dark':'light'));$('langBtn').addEventListener('click',()=>setLanguage(state.language==='zh'?'en':'zh'));$('logoutBtn').addEventListener('click',logoutLab);$('searchBtn').addEventListener('click',runSearch);$('searchInput').addEventListener('keydown',e=>{if(e.key==='Enter')runSearch()});$('masteryBtn').addEventListener('click',toggleMastery);$('prevBtn').addEventListener('click',()=>{if(state.lesson?.previous_lesson)openLesson(state.lesson.previous_lesson.id)});$('nextBtn').addEventListener('click',()=>{if(state.lesson?.next_lesson)openLesson(state.lesson.next_lesson.id)});boot()});
window.openLesson=openLesson;window.openModule=openModule;window.setStage=setStage;window.runExercise=runExercise;
</script>
</body>
</html>

48
login.html Normal file
View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Linux Login</title>
<style>
:root{--bg:#f4f8fc;--panel:#fff;--line:#d8e2ee;--text:#102033;--muted:#5d7187;--brand:#0f6db6;--soft:#e9f3ff}
[data-theme="dark"]{--bg:#08111d;--panel:#0c1826;--line:#1d3245;--text:#edf4fb;--muted:#9bb0c3;--brand:#67baff;--soft:rgba(103,186,255,.1)}
*{box-sizing:border-box} body{margin:0;min-height:100vh;display:grid;place-items:center;padding:20px;background:var(--bg);color:var(--text);font-family:"Segoe UI","Microsoft YaHei",sans-serif}
.card{width:min(100%,460px);background:var(--panel);border:1px solid var(--line);border-radius:20px;padding:28px} .toolbar,.tips{display:flex;flex-wrap:wrap;gap:10px}.toolbar{justify-content:flex-end;margin-bottom:16px}
button,input{font:inherit}.btn,.ghost,.chip{border-radius:999px;padding:10px 14px;font-weight:700}.btn{border:0;background:linear-gradient(135deg,var(--brand),#37a1ff);color:#fff;cursor:pointer;width:100%}.ghost{border:1px solid var(--line);background:transparent;color:var(--text);cursor:pointer}.chip{display:inline-flex;background:var(--soft);color:var(--brand)}
form{display:grid;gap:12px;margin-top:16px} input{padding:12px;border:1px solid var(--line);border-radius:12px;background:transparent;color:var(--text)} .eyebrow{font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--brand)} h1,p{margin:0} p{color:var(--muted);line-height:1.8}.msg{margin-top:14px;padding:12px;border:1px solid var(--line);border-radius:12px;color:var(--muted);white-space:pre-wrap}
</style>
</head>
<body>
<section class="card">
<div class="toolbar">
<button class="ghost" id="themeBtn"></button>
<button class="ghost" id="langBtn"></button>
</div>
<div class="eyebrow" id="eyebrow"></div>
<h1 id="title" style="margin:10px 0 8px;"></h1>
<p id="desc"></p>
<form id="loginForm">
<input id="user" type="text" autocomplete="username" />
<input id="pass" type="password" autocomplete="current-password" />
<button class="btn" type="submit" id="submitBtn"></button>
</form>
<div class="tips" style="margin-top:14px;">
<span class="chip" id="accountTip"></span>
<span class="chip" id="passwordTip"></span>
</div>
<div class="msg" id="msg"></div>
</section>
<script>
const D=v=>{const e=document.createElement('textarea');e.innerHTML=v;return e.value};const $=id=>document.getElementById(id);
const LANG={zh:{eyebrow:'&#x5B89;&#x5168;&#x5165;&#x53E3;',title:'Linux &#x5B66;&#x4E60;&#x5B9E;&#x9A8C;&#x5BA4;&#x767B;&#x5F55;',desc:'&#x767B;&#x5F55;&#x540E;&#x624D;&#x80FD;&#x8BBF;&#x95EE;&#x8BFE;&#x7A0B;&#x603B;&#x89C8;&#x3001;&#x7EC3;&#x4E60;&#x7EC8;&#x7AEF;&#x548C;&#x5B66;&#x4E60;&#x9762;&#x677F;&#x3002;',user:'&#x7528;&#x6237;&#x540D;',pass:'&#x5BC6;&#x7801;',submit:'&#x767B;&#x5F55;&#x5E76;&#x8FDB;&#x5165;&#x5B66;&#x4E60;&#x53F0;',themeDark:'&#x6DF1;&#x8272;&#x6A21;&#x5F0F;',themeLight:'&#x6D45;&#x8272;&#x6A21;&#x5F0F;',lang:'EN',account:'&#x8D26;&#x53F7;&#xFF1A;&#x6309;&#x90E8;&#x7F72;&#x914D;&#x7F6E;',password:'&#x5BC6;&#x7801;&#xFF1A;&#x6309;&#x90E8;&#x7F72;&#x914D;&#x7F6E;',ready:'&#x8BF7;&#x8F93;&#x5165;&#x90E8;&#x7F72;&#x65F6;&#x914D;&#x7F6E;&#x7684;&#x8D26;&#x53F7;&#x5BC6;&#x7801;&#x8FDB;&#x5165; Linux &#x5B66;&#x4E60;&#x9879;&#x76EE;&#x3002;',loading:'&#x6B63;&#x5728;&#x767B;&#x5F55;...',failed:'&#x767B;&#x5F55;&#x5931;&#x8D25;&#xFF0C;&#x8BF7;&#x68C0;&#x67E5;&#x8D26;&#x53F7;&#x4E0E;&#x5BC6;&#x7801;&#x3002;'},en:{eyebrow:'Secure entry',title:'Linux Learning Lab Login',desc:'Sign in before opening the course overview, practice terminal, and learning dashboard.',user:'Username',pass:'Password',submit:'Sign in and enter lab',themeDark:'Dark mode',themeLight:'Light mode',lang:'涓枃',account:'Account: deployment configured',password:'Password: deployment configured',ready:'Enter the deployment-configured credentials to access the Linux learning project.',loading:'Signing in...',failed:'Login failed. Check the username and password.'}};
const state={language:localStorage.getItem('linux_lab_language')||'zh',theme:localStorage.getItem('linux_lab_theme')||'light'};const t=()=>LANG[state.language];const tx=k=>D(t()[k]||'');
function setTheme(theme,persist=true){state.theme=theme==='dark'?'dark':'light';document.documentElement.setAttribute('data-theme',state.theme);if(persist)localStorage.setItem('linux_lab_theme',state.theme);$('themeBtn').textContent=state.theme==='light'?tx('themeDark'):tx('themeLight')}
function render(){document.title=tx('title');document.documentElement.lang=state.language==='zh'?'zh-CN':'en';$('eyebrow').textContent=tx('eyebrow');$('title').textContent=tx('title');$('desc').textContent=tx('desc');$('user').placeholder=tx('user');$('pass').placeholder=tx('pass');$('submitBtn').textContent=tx('submit');$('langBtn').textContent=state.language==='zh'?'EN':'\u4E2D\u6587';$('accountTip').textContent=tx('account');$('passwordTip').textContent=tx('password');setTheme(state.theme,false);const n=localStorage.getItem('linux_lab_login_notice');$('msg').textContent=n||tx('ready');localStorage.removeItem('linux_lab_login_notice')}
async function checkSession(){const r=await fetch('/api/session',{credentials:'same-origin'});const p=await r.json();if(p.authenticated)location.href='/app'}
async function login(e){e.preventDefault();$('msg').textContent=tx('loading');const r=await fetch('/api/login',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:$('user').value.trim(),password:$('pass').value.trim()})});const p=await r.json();if(r.ok&&p.success){location.href='/app';return}$('msg').textContent=p.error||tx('failed')}
document.addEventListener('DOMContentLoaded',async()=>{render();await checkSession();$('themeBtn').addEventListener('click',()=>setTheme(state.theme==='light'?'dark':'light'));$('langBtn').addEventListener('click',()=>{state.language=state.language==='zh'?'en':'zh';localStorage.setItem('linux_lab_language',state.language);render()});$('loginForm').addEventListener('submit',login)})
</script>
</body>
</html>

View File

@@ -81,9 +81,9 @@ SUPPORTED_COMMANDS = {
"env", "export", "fdisk", "find", "free", "grep", "head", "history", "id",
"ifconfig", "ip", "journalctl", "kill", "last", "less", "ls", "lsof", "mkdir",
"more", "mount", "mv", "netstat", "nohup", "passwd", "ping", "pkill", "ps",
"pwd", "rm", "sed", "service", "sort", "ss", "stat", "su", "systemctl", "tail",
"tar", "top", "touch", "traceroute", "uniq", "uptime", "vi", "vim", "w",
"wc", "wget", "whereis", "which", "yum"
"pwd", "rm", "rpm", "sed", "service", "sort", "ss", "stat", "su", "systemctl",
"tail", "tar", "top", "touch", "traceroute", "uniq", "uptime", "vi", "vim", "w",
"wc", "wget", "whereis", "which", "whoami", "yum", "dpkg", "cal"
}

600
server.py
View File

@@ -8,35 +8,40 @@ import http.server
import json
import os
import re
import secrets
import time
import urllib.parse
from typing import Any
from sandbox import LinuxSandbox
DEFAULT_USERNAME = os.environ.get("LINUX_LAB_USERNAME", "admin")
DEFAULT_PASSWORD = os.environ.get("LINUX_LAB_PASSWORD", "safe_linux_2026")
USERS = {
"admin": hashlib.sha256(b"safe_linux_2026").hexdigest(),
DEFAULT_USERNAME: hashlib.sha256(DEFAULT_PASSWORD.encode("utf-8")).hexdigest(),
}
TASKS_FILE = os.path.join(os.path.dirname(__file__), "COURSE_TASKS.json")
HTML_FILE = os.path.join(os.path.dirname(__file__), "index.html")
LOGIN_FILE = os.path.join(os.path.dirname(__file__), "login.html")
PRIVACY_FILE = os.path.join(os.path.dirname(__file__), "privacy.html")
SANDBOX = LinuxSandbox()
SESSION_COOKIE = "linux_lab_session"
SESSION_TTL_SECONDS = 8 * 60 * 60
SESSIONS: dict[str, dict[str, Any]] = {}
PUBLIC_GET_PATHS = {
"/",
"/login.html",
"/privacy",
"/privacy.html",
"/api/course",
"/api/course/search",
"/api/diagnostics",
"/api/health",
"/api/lesson",
"/api/overview",
"/api/session",
}
PUBLIC_POST_PATHS = {
"/api/login",
"/api/logout",
}
SAFE_REMOTE_HOST = "xiaoxiaoluohao.indevs.in"
def load_course() -> dict[str, Any]:
@@ -51,6 +56,37 @@ def load_course() -> dict[str, Any]:
COURSE = load_course()
def secure_cookie_enabled() -> bool:
return os.environ.get("LINUX_LAB_SECURE_COOKIE", "").lower() in {"1", "true", "yes", "on"}
def create_session(username: str) -> str:
session_id = secrets.token_urlsafe(24)
SESSIONS[session_id] = {
"user": username,
"expires_at": time.time() + SESSION_TTL_SECONDS,
}
return session_id
def get_session(session_id: str) -> dict[str, Any] | None:
if not session_id:
return None
session = SESSIONS.get(session_id)
if not session:
return None
if session.get("expires_at", 0) <= time.time():
SESSIONS.pop(session_id, None)
return None
session["expires_at"] = time.time() + SESSION_TTL_SECONDS
return session
def delete_session(session_id: str):
if session_id:
SESSIONS.pop(session_id, None)
def looks_garbled(value: Any) -> bool:
if not isinstance(value, str) or not value.strip():
return False
@@ -106,6 +142,19 @@ def command_label(commands: list[str]) -> str:
return " / ".join(commands[:3])
def is_supported_command_token(command: str) -> bool:
normalized = command.strip().lower()
if not normalized:
return False
supported = SANDBOX.supported_commands()
if normalized in supported:
return True
if "|" in normalized:
segments = [segment.strip() for segment in normalized.split("|") if segment.strip()]
return bool(segments) and all(segment in supported for segment in segments)
return False
def module_number(module_id: str | None) -> str | None:
if not module_id:
return None
@@ -188,6 +237,439 @@ def default_after_class(commands: list[str]) -> str:
return f"Repeat this lesson in the sandbox two or three more times and explain every step out loud using {command_label(commands)}."
COMMAND_CATEGORIES = {
"navigation": {"pwd", "ls", "cd", "which", "whereis", "clear"},
"filesystem": {"cat", "echo", "mkdir", "touch", "cp", "mv", "rm", "chmod", "stat", "tar"},
"text": {"grep", "find", "head", "tail", "sort", "uniq", "cut", "awk", "sed"},
"observation": {"ps", "top", "uptime", "free", "df", "du", "lsof", "last", "w", "history", "date", "cal"},
"service": {"systemctl", "journalctl", "nohup", "crontab"},
"network": {"ip", "ping", "ss", "curl", "wget", "netstat", "traceroute", "dig"},
"identity": {"whoami", "id", "env", "export", "alias"},
"package": {"apt", "yum", "rpm", "dpkg"},
}
CATEGORY_ROLES = {
"navigation": "Use this command family to regain orientation before acting.",
"filesystem": "Use this command family to inspect, create, move, or protect files safely.",
"text": "Use this command family to filter noisy output into evidence you can act on.",
"observation": "Use this command family to understand what the host is doing right now.",
"service": "Use this command family to control long-running workloads and read their execution history.",
"network": "Use this command family to test reachability, sockets, HTTP responses, and DNS resolution.",
"identity": "Use this command family to understand who you are, what your shell knows, and what your environment passes downstream.",
"package": "Use this command family to inspect, install, or verify software that powers the host.",
"general": "Use the base form first, then add options only when you know what evidence you need.",
}
CATEGORY_EXTENSION_TIPS = {
"navigation": [
"Repeat the lesson from a second directory and explain why path context changes the next command.",
"Practice saying the current directory out loud before every file-changing action.",
],
"filesystem": [
"Run the read-only form first, then explain what would happen if you switched to a state-changing form.",
"Create a small backup-and-verify routine so file work becomes reversible instead of risky.",
],
"text": [
"Turn one noisy log or command output into a smaller evidence set with filters and field extraction.",
"Compare broad search output with a narrower variant and explain which one is safer under pressure.",
],
"observation": [
"Capture the one output field that matters most before jumping into repair actions.",
"Explain how this observation command changes your next diagnostic step instead of stopping at raw output.",
],
"service": [
"Link service state, listening port, and logs into a three-step incident playbook.",
"Practice one failed-start scenario and one healthy-state verification sequence.",
],
"network": [
"Distinguish host reachability, open port, HTTP response, and DNS lookup as separate layers.",
"Run one command per network layer and explain what each result rules in or rules out.",
],
"identity": [
"Check identity and environment before using sudo, editing config, or launching a process.",
"Practice spotting the difference between shell state and system-wide state.",
],
"package": [
"Trace one tool from package name to installed files to executable path.",
"Explain how package inspection helps before upgrades, rollbacks, or incident recovery.",
],
}
COMMAND_ORIGINS = {
"pwd": "pwd is short for 'print working directory'.",
"ls": "ls is the compact Unix shorthand for 'list'.",
"grep": "grep comes from the old ed command g/re/p, meaning globally search a regular expression and print matches.",
"awk": "awk is named after Aho, Weinberger, and Kernighan, the language's original authors.",
"sed": "sed stands for 'stream editor', highlighting that it edits text as a flowing stream.",
"ps": "ps expands to 'process status'.",
"df": "df expands to 'disk free'.",
"du": "du expands to 'disk usage'.",
"ss": "ss expands to 'socket statistics' and was designed as a faster successor to parts of netstat.",
"lsof": "lsof expands to 'list open files'.",
"nohup": "nohup literally means 'no hangup' because it ignores the hangup signal when a terminal closes.",
"crontab": "crontab means 'cron table', the per-user schedule file for cron jobs.",
"apt": "apt is short for 'Advanced Package Tool'.",
"yum": "yum originally expanded to 'Yellowdog Updater, Modified'.",
"dpkg": "dpkg is the low-level Debian package management tool.",
"rpm": "rpm refers to the RPM package manager format and toolchain.",
"dig": "dig is short for 'Domain Information Groper', a famously odd but memorable DNS tool name.",
"curl": "curl is a client-side URL transfer tool; the name is intentionally short and terminal-friendly.",
"wget": "wget historically comes from the idea of 'web get'.",
"netstat": "netstat expands to 'network statistics'.",
"systemctl": "systemctl is the systemd control command for units such as services, timers, and sockets.",
"journalctl": "journalctl is the control/query tool for the systemd journal.",
"whoami": "whoami reads like a sentence because it answers the most practical shell identity question directly.",
"whereis": "whereis focuses on where a binary, source tree, and manual page live.",
"which": "which answers which executable the shell would run first.",
"ping": "ping was named after sonar ping sounds; the common acronym explanation is popular but not official.",
}
COMMAND_FUN_FACTS = {
"pwd": "Strong operators use pwd almost like a seatbelt: quick, boring, and essential before riskier actions.",
"ls": "Many incidents start with a wrong assumption about what exists, which is why ls remains a first-response habit.",
"grep": "The command name looks cryptic until you learn its editor history, and then you start seeing Unix culture in miniature.",
"awk": "awk is tiny in name but surprisingly powerful; many shell one-liners quietly depend on it for structured text work.",
"sed": "sed shines when you need a quick surgical text transform without opening an editor.",
"ss": "On modern Linux hosts, ss is often the better first stop than netstat for live socket checks.",
"curl": "curl often becomes the bridge between 'the service is up' and 'the application is actually responding correctly'.",
"systemctl": "systemctl teaches a crucial operations lesson: a process existing is not the same as a service being healthy.",
"journalctl": "journalctl turns logs into a timeline, which is exactly what you need when a deployment fails under time pressure.",
"nohup": "nohup is a good reminder that shell sessions and process lifetime are related, but not the same thing.",
"dig": "dig is one of the most useful commands for proving that a problem is DNS, or proving that it is not DNS.",
"history": "history is not only memory; it is a personal operations notebook showing how you solved similar problems before.",
}
COMMAND_VARIANTS = {
"pwd": [
{"syntax": "pwd", "purpose": "Print the current logical working directory.", "example": "pwd"},
{"syntax": "pwd -P", "purpose": "Resolve symbolic links and show the physical path.", "example": "pwd -P"},
],
"ls": [
{"syntax": "ls -l", "purpose": "Show a long listing with permissions, owner, size, and time.", "example": "ls -l /var/log"},
{"syntax": "ls -la", "purpose": "Include hidden entries such as dotfiles.", "example": "ls -la ~"},
{"syntax": "ls -lh", "purpose": "Format file sizes in human-readable units.", "example": "ls -lh build"},
],
"cd": [
{"syntax": "cd /path", "purpose": "Jump to a specific directory.", "example": "cd /var/log"},
{"syntax": "cd -", "purpose": "Return to the previous directory quickly.", "example": "cd -"},
{"syntax": "cd ~", "purpose": "Go back to the current user's home directory.", "example": "cd ~"},
],
"cat": [
{"syntax": "cat file", "purpose": "Print the full contents of a file.", "example": "cat /etc/os-release"},
{"syntax": "cat -n file", "purpose": "Show line numbers while printing.", "example": "cat -n app.log"},
],
"echo": [
{"syntax": "echo text", "purpose": "Print a string or quick note.", "example": "echo hello"},
{"syntax": "echo $PATH", "purpose": "Inspect shell expansion and environment values.", "example": "echo $PATH"},
],
"mkdir": [
{"syntax": "mkdir dir", "purpose": "Create one directory.", "example": "mkdir logs"},
{"syntax": "mkdir -p a/b/c", "purpose": "Create parent directories in one run.", "example": "mkdir -p releases/2026/03"},
],
"touch": [
{"syntax": "touch file", "purpose": "Create an empty file or update its timestamp.", "example": "touch notes.txt"},
],
"cp": [
{"syntax": "cp src dest", "purpose": "Copy one file to another path.", "example": "cp app.conf app.conf.bak"},
{"syntax": "cp -r src dest", "purpose": "Copy a directory tree recursively.", "example": "cp -r site site.bak"},
{"syntax": "cp -a src dest", "purpose": "Preserve attributes while copying.", "example": "cp -a /etc/nginx nginx.backup"},
],
"mv": [
{"syntax": "mv old new", "purpose": "Rename or move a file or directory.", "example": "mv access.log archive.log"},
],
"rm": [
{"syntax": "rm file", "purpose": "Remove one file.", "example": "rm debug.log"},
{"syntax": "rm -r dir", "purpose": "Remove a directory tree recursively.", "example": "rm -r old_build"},
{"syntax": "rm -i file", "purpose": "Prompt before deleting.", "example": "rm -i important.txt"},
],
"chmod": [
{"syntax": "chmod 644 file", "purpose": "Set common read/write file permissions.", "example": "chmod 644 app.conf"},
{"syntax": "chmod +x script.sh", "purpose": "Add execute permission.", "example": "chmod +x deploy.sh"},
],
"stat": [
{"syntax": "stat file", "purpose": "Inspect inode, timestamps, and file metadata.", "example": "stat access.log"},
],
"grep": [
{"syntax": "grep pattern file", "purpose": "Search matching lines.", "example": "grep ERROR app.log"},
{"syntax": "grep -n pattern file", "purpose": "Show line numbers with matches.", "example": "grep -n timeout app.log"},
{"syntax": "grep -i pattern file", "purpose": "Ignore letter case.", "example": "grep -i failed auth.log"},
],
"find": [
{"syntax": "find /path -name pattern", "purpose": "Search by file or directory name.", "example": "find /var/log -name '*.log'"},
{"syntax": "find /path -type f", "purpose": "Restrict the search to files.", "example": "find . -type f"},
{"syntax": "find /path -mtime -1", "purpose": "Filter by recent modification time.", "example": "find /backup -mtime -1"},
],
"head": [
{"syntax": "head file", "purpose": "Show the first 10 lines.", "example": "head app.log"},
{"syntax": "head -n 20 file", "purpose": "Show a custom number of leading lines.", "example": "head -n 20 app.log"},
],
"tail": [
{"syntax": "tail file", "purpose": "Show the last 10 lines.", "example": "tail app.log"},
{"syntax": "tail -n 50 file", "purpose": "Show more trailing lines.", "example": "tail -n 50 app.log"},
{"syntax": "tail -f file", "purpose": "Follow a log while it grows.", "example": "tail -f /var/log/nginx/access.log"},
],
"sort": [
{"syntax": "sort file", "purpose": "Sort text lines.", "example": "sort hosts.txt"},
{"syntax": "sort -u file", "purpose": "Sort and deduplicate in one step.", "example": "sort -u users.txt"},
],
"uniq": [
{"syntax": "uniq file", "purpose": "Collapse adjacent duplicates.", "example": "uniq users.txt"},
{"syntax": "sort file | uniq -c", "purpose": "Count repeated values after sorting.", "example": "sort status.txt | uniq -c"},
],
"cut": [
{"syntax": "cut -d: -f1 file", "purpose": "Pick fields using a delimiter.", "example": "cut -d: -f1 /etc/passwd"},
],
"awk": [
{"syntax": "awk '{print $1}' file", "purpose": "Print one or more whitespace-separated fields.", "example": "awk '{print $1}' access.log"},
{"syntax": "awk -F: '{print $1}' file", "purpose": "Set a custom field separator.", "example": "awk -F: '{print $1}' /etc/passwd"},
],
"sed": [
{"syntax": "sed -n '1,10p' file", "purpose": "Print only selected line ranges.", "example": "sed -n '1,10p' app.log"},
{"syntax": "sed 's/old/new/' file", "purpose": "Replace text in the output stream.", "example": "sed 's/http/https/' app.conf"},
],
"ps": [
{"syntax": "ps -ef", "purpose": "Show a full process list.", "example": "ps -ef"},
{"syntax": "ps -ef | grep name", "purpose": "Filter for one process family.", "example": "ps -ef | grep nginx"},
],
"top": [
{"syntax": "top", "purpose": "Open an interactive live process view.", "example": "top"},
{"syntax": "top -b -n 1", "purpose": "Capture one batch snapshot for logs or pipes.", "example": "top -b -n 1 | head -20"},
],
"ip": [
{"syntax": "ip addr", "purpose": "Show interfaces and assigned IP addresses.", "example": "ip addr"},
{"syntax": "ip route", "purpose": "Inspect the routing table.", "example": "ip route"},
],
"ping": [
{"syntax": "ping host", "purpose": "Test reachability continuously.", "example": "ping 8.8.8.8"},
{"syntax": "ping -c 4 host", "purpose": "Send a fixed number of probes.", "example": "ping -c 4 example.com"},
],
"ss": [
{"syntax": "ss -lntp", "purpose": "Show listening TCP ports and owning processes.", "example": "ss -lntp"},
{"syntax": "ss -s", "purpose": "Print a compact socket summary.", "example": "ss -s"},
],
"curl": [
{"syntax": "curl URL", "purpose": "Fetch a URL quickly from the terminal.", "example": "curl http://localhost:8080/health"},
{"syntax": "curl -I URL", "purpose": "Request response headers only.", "example": "curl -I https://example.com"},
{"syntax": "curl -X POST URL", "purpose": "Send a request with an explicit HTTP method.", "example": "curl -X POST http://localhost:8080/api/login"},
],
"wget": [
{"syntax": "wget URL", "purpose": "Download a file to disk.", "example": "wget https://example.com/file.tar.gz"},
{"syntax": "wget -O name URL", "purpose": "Choose the output filename.", "example": "wget -O app.tar.gz https://example.com/latest.tar.gz"},
],
"systemctl": [
{"syntax": "systemctl status name", "purpose": "Inspect service state and recent status text.", "example": "systemctl status nginx"},
{"syntax": "systemctl restart name", "purpose": "Restart a service after config or code changes.", "example": "systemctl restart nginx"},
{"syntax": "systemctl enable name", "purpose": "Enable a unit at boot time.", "example": "systemctl enable nginx"},
],
"journalctl": [
{"syntax": "journalctl -u name", "purpose": "Read logs for one systemd unit.", "example": "journalctl -u nginx"},
{"syntax": "journalctl -xe", "purpose": "Show recent errors with context.", "example": "journalctl -xe"},
{"syntax": "journalctl -u name -n 50", "purpose": "Read the latest lines for one unit.", "example": "journalctl -u nginx -n 50"},
],
"df": [
{"syntax": "df -h", "purpose": "Show filesystem usage in human units.", "example": "df -h"},
{"syntax": "df -i", "purpose": "Check inode exhaustion, not just bytes.", "example": "df -i"},
],
"du": [
{"syntax": "du -sh path", "purpose": "Summarize directory size.", "example": "du -sh /var/log"},
{"syntax": "du -h path", "purpose": "Break size down recursively.", "example": "du -h /var/log | sort -h | tail"},
],
"whoami": [
{"syntax": "whoami", "purpose": "Print the current account name.", "example": "whoami"},
],
"id": [
{"syntax": "id", "purpose": "Show UID, GID, and groups.", "example": "id"},
{"syntax": "id user", "purpose": "Inspect another account if the environment allows it.", "example": "id nginx"},
],
"env": [
{"syntax": "env", "purpose": "Print environment variables.", "example": "env"},
{"syntax": "env | grep NAME", "purpose": "Filter the environment for one variable.", "example": "env | grep PATH"},
],
"export": [
{"syntax": "export VAR=value", "purpose": "Set an environment variable for child processes.", "example": "export APP_ENV=prod"},
{"syntax": "export PATH=$PATH:/opt/bin", "purpose": "Extend PATH temporarily in the current shell.", "example": "export PATH=$PATH:/opt/bin"},
],
"alias": [
{"syntax": "alias", "purpose": "List current shell aliases.", "example": "alias"},
{"syntax": "alias ll='ls -alF'", "purpose": "Create a shortcut for a repeated command.", "example": "alias ll='ls -alF'"},
],
"which": [
{"syntax": "which cmd", "purpose": "Show the executable the shell would run first.", "example": "which python"},
],
"whereis": [
{"syntax": "whereis cmd", "purpose": "Locate binary, source, and manual page paths.", "example": "whereis nginx"},
],
"apt": [
{"syntax": "apt search name", "purpose": "Search repositories for packages.", "example": "apt search nginx"},
{"syntax": "apt install name", "purpose": "Install a package.", "example": "apt install nginx"},
{"syntax": "apt remove name", "purpose": "Remove a package.", "example": "apt remove nginx"},
],
"yum": [
{"syntax": "yum search name", "purpose": "Search repositories.", "example": "yum search nginx"},
{"syntax": "yum install name", "purpose": "Install a package.", "example": "yum install nginx"},
],
"rpm": [
{"syntax": "rpm -qa", "purpose": "List installed RPM packages.", "example": "rpm -qa | grep nginx"},
{"syntax": "rpm -qi name", "purpose": "Inspect one package in detail.", "example": "rpm -qi nginx"},
],
"dpkg": [
{"syntax": "dpkg -l", "purpose": "List installed Debian packages.", "example": "dpkg -l | grep nginx"},
{"syntax": "dpkg -L name", "purpose": "List files installed by one package.", "example": "dpkg -L nginx"},
],
"tar": [
{"syntax": "tar -tf file.tar", "purpose": "List archive contents without extracting.", "example": "tar -tf backup.tar"},
{"syntax": "tar -czf out.tar.gz dir", "purpose": "Create a gzip-compressed archive.", "example": "tar -czf logs.tar.gz /var/log/myapp"},
{"syntax": "tar -xzf file.tar.gz", "purpose": "Extract a gzip-compressed archive.", "example": "tar -xzf logs.tar.gz"},
],
"nohup": [
{"syntax": "nohup cmd &", "purpose": "Keep a process alive after terminal disconnect.", "example": "nohup python3 server.py &"},
],
"uptime": [
{"syntax": "uptime", "purpose": "Show uptime and load averages.", "example": "uptime"},
],
"free": [
{"syntax": "free -h", "purpose": "Show memory usage in human units.", "example": "free -h"},
],
"lsof": [
{"syntax": "lsof -i :port", "purpose": "Find the process using a port.", "example": "lsof -i :8080"},
{"syntax": "lsof file", "purpose": "Find which process has a file open.", "example": "lsof /var/log/app.log"},
],
"netstat": [
{"syntax": "netstat -lntp", "purpose": "Legacy view of listening TCP ports.", "example": "netstat -lntp"},
],
"traceroute": [
{"syntax": "traceroute host", "purpose": "Trace network hops to a target host.", "example": "traceroute example.com"},
],
"dig": [
{"syntax": "dig name", "purpose": "Query DNS records with full output.", "example": "dig example.com"},
{"syntax": "dig name +short", "purpose": "Print compact answers only.", "example": "dig example.com +short"},
],
"date": [
{"syntax": "date", "purpose": "Print the current system time.", "example": "date"},
{"syntax": "date '+%F %T'", "purpose": "Format the output explicitly.", "example": "date '+%F %T'"},
],
"cal": [
{"syntax": "cal", "purpose": "Show the current month calendar.", "example": "cal"},
{"syntax": "cal 2026", "purpose": "Show a whole year calendar.", "example": "cal 2026"},
],
"crontab": [
{"syntax": "crontab -l", "purpose": "List current cron jobs.", "example": "crontab -l"},
{"syntax": "crontab -e", "purpose": "Edit the cron table.", "example": "crontab -e"},
],
"history": [
{"syntax": "history", "purpose": "Show previous shell commands.", "example": "history"},
{"syntax": "history | grep word", "purpose": "Search for a prior command pattern.", "example": "history | grep tar"},
],
"last": [
{"syntax": "last", "purpose": "Show recent login records.", "example": "last"},
],
"w": [
{"syntax": "w", "purpose": "Show who is logged in and what they are doing.", "example": "w"},
],
"clear": [
{"syntax": "clear", "purpose": "Clear terminal output without changing shell state.", "example": "clear"},
],
}
def command_category(command: str) -> str:
normalized = command.strip().lower()
for category, commands in COMMAND_CATEGORIES.items():
if normalized in commands:
return category
return "general"
def command_origin(command: str) -> str:
normalized = command.strip().lower()
return COMMAND_ORIGINS.get(
normalized,
f"{normalized} follows the classic Unix habit of using short names so repeated terminal work stays fast.",
)
def command_fun_fact(command: str) -> str:
normalized = command.strip().lower()
return COMMAND_FUN_FACTS.get(
normalized,
f"Treat {normalized} as part of a command chain: its real value appears when it informs the next observation or repair step.",
)
def generic_variant(command: str) -> list[dict[str, str]]:
normalized = command.strip().lower()
if normalized in {"cd", "export", "alias"}:
return [
{
"syntax": f"help {normalized}",
"purpose": "Inspect builtin shell help before using unfamiliar forms.",
"example": f"help {normalized}",
}
]
return [
{
"syntax": f"{normalized} --help",
"purpose": "Review the supported options before using the command in a new situation.",
"example": f"{normalized} --help",
}
]
def command_variants(command: str) -> list[dict[str, str]]:
normalized = command.strip().lower()
return COMMAND_VARIANTS.get(normalized, generic_variant(normalized))
def build_command_guides(commands: list[str]) -> list[dict[str, Any]]:
guides: list[dict[str, Any]] = []
seen: set[str] = set()
for command in commands:
normalized = command.strip().lower()
if not normalized or normalized in seen:
continue
seen.add(normalized)
guides.append(
{
"command": normalized,
"role": CATEGORY_ROLES.get(command_category(normalized), CATEGORY_ROLES["general"]),
"origin": command_origin(normalized),
"fun_fact": command_fun_fact(normalized),
"variants": command_variants(normalized),
}
)
return guides
def build_ops_extensions(commands: list[str]) -> list[str]:
items: list[str] = []
seen: set[str] = set()
for command in commands:
category = command_category(command)
for tip in CATEGORY_EXTENSION_TIPS.get(category, []):
if tip not in seen:
items.append(tip)
seen.add(tip)
closing_notes = [
"Run the simplest read-only form first, then explain what stronger or riskier form you would use only if the evidence demands it.",
"Write a tiny playbook note for this lesson: goal -> command -> key output -> next step.",
]
for note in closing_notes:
if note not in seen:
items.append(note)
seen.add(note)
return items[:6]
def build_review_prompts(commands: list[str], lesson_goal: str) -> list[str]:
label = command_label(commands)
return [
f"What system question are you trying to answer with {label} before you touch the system state?",
f"Which output line, field, or signal matters most for this goal: {lesson_goal}",
f"What narrower or safer syntax variant would you try first if the default output feels too broad or too risky?",
f"After {label}, what should the next verification, comparison, or repair step be in a real operations flow?",
]
def flatten_lessons() -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for module in COURSE.get("modules", []):
@@ -295,7 +777,7 @@ def build_diagnostics() -> dict[str, Any]:
solution_commands = sorted(set(extract_solution_commands()))
required_commands = sorted(set(course_commands) | set(solution_commands))
supported_commands = sorted(SANDBOX.supported_commands())
unsupported_commands = [command for command in required_commands if command not in SANDBOX.supported_commands()]
unsupported_commands = [command for command in required_commands if not is_supported_command_token(command)]
total_exercises = sum(len(lesson.get("exercises", [])) for lesson in lessons)
operation_exercises = sum(
1
@@ -309,7 +791,7 @@ def build_diagnostics() -> dict[str, Any]:
"supported_command_count": len(supported_commands),
"required_command_count": len(required_commands),
"covered_command_count": len(required_commands) - len(unsupported_commands),
"coverage_rate": 100 if not required_commands else round(((len(required_commands) - len(unsupported_commands)) / len(required_commands)) * 100)),
"coverage_rate": 100 if not required_commands else round(((len(required_commands) - len(unsupported_commands)) / len(required_commands)) * 100),
"unsupported_commands": unsupported_commands,
"course_commands": required_commands,
"operation_exercises": operation_exercises,
@@ -354,6 +836,7 @@ def build_lesson_payload(module: dict[str, Any], lesson: dict[str, Any]) -> dict
lesson_ids = [item.get("id") for item in all_lessons]
current_id = lesson.get("id")
index = lesson_ids.index(current_id) if current_id in lesson_ids else -1
goal = safe_text(lesson.get("goal"), default_goal(commands))
previous_lesson = None
next_lesson = None
@@ -369,7 +852,7 @@ def build_lesson_payload(module: dict[str, Any], lesson: dict[str, Any]) -> dict
return {
"id": lesson.get("id"),
"display_title": lesson_title(lesson),
"display_goal": safe_text(lesson.get("goal"), default_goal(commands)),
"display_goal": goal,
"display_why": safe_text(lesson.get("why_it_matters"), default_why(commands)),
"display_module_title": module_title(module),
"display_module_summary": module_summary(module),
@@ -387,6 +870,9 @@ def build_lesson_payload(module: dict[str, Any], lesson: dict[str, Any]) -> dict
),
"after_class": safe_text(lesson.get("after_class"), default_after_class(commands)),
"related_commands": safe_list(lesson.get("related_commands"), commands or ["pwd"]),
"command_guides": build_command_guides(commands),
"ops_extensions": build_ops_extensions(commands),
"review_prompts": build_review_prompts(commands, goal),
"exercises": [build_exercise(exercise, lesson) for exercise in lesson.get("exercises", [])],
"exercise_count": len(lesson.get("exercises", [])),
"module_id": module.get("id"),
@@ -534,11 +1020,13 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass
def send_json(self, data: Any, status: int = 200):
def send_json(self, data: Any, status: int = 200, extra_headers: dict[str, str] | None = None):
raw = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
for header, value in (extra_headers or {}).items():
self.send_header(header, value)
self.end_headers()
self.wfile.write(raw)
@@ -551,6 +1039,36 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
def send_redirect(self, location: str):
self.send_response(302)
self.send_header("Location", location)
self.end_headers()
def cookie_header(self, session_id: str = "", clear: bool = False) -> str:
base = f"{SESSION_COOKIE}={session_id if not clear else ''}; Path=/; HttpOnly; SameSite=Lax"
if clear:
return base + "; Max-Age=0"
if secure_cookie_enabled():
base += "; Secure"
return base + f"; Max-Age={SESSION_TTL_SECONDS}"
def current_session_id(self) -> str:
cookie = self.headers.get("Cookie", "")
for part in cookie.split(";"):
name, _, value = part.strip().partition("=")
if name == SESSION_COOKIE:
return value.strip()
return ""
def current_session(self) -> dict[str, Any] | None:
return get_session(self.current_session_id())
def current_user(self) -> str | None:
session = self.current_session()
if not session:
return None
return str(session.get("user") or "")
def is_public_path(self, path: str, method: str) -> bool:
if method == "GET":
return path in PUBLIC_GET_PATHS
@@ -558,22 +1076,10 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
return path in PUBLIC_POST_PATHS
return False
def check_auth(self, auth_header: str, token: str) -> bool:
if self.client_address[0] == "127.0.0.1":
return True
if token == "safe_linux_2026":
return True
if auth_header.startswith("Bearer ") and auth_header[7:] == "safe_linux_2026":
return True
return False
def require_auth_if_needed(self, path: str, method: str) -> bool:
if self.is_public_path(path, method):
return True
host = self.headers.get("Host", "")
auth_header = self.headers.get("Authorization", "")
token = self.headers.get("X-Token", "")
if SAFE_REMOTE_HOST in host and not self.check_auth(auth_header, token):
if not self.current_session():
self.send_json({"error": "Authentication required"}, 401)
return False
return True
@@ -582,10 +1088,21 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if not self.require_auth_if_needed(path, "GET"):
if path == "/":
self.send_redirect("/app" if self.current_session() else "/login.html")
return
if path == "/":
if path == "/login.html":
if self.current_session():
self.send_redirect("/app")
return
self.send_file(LOGIN_FILE, "text/html; charset=utf-8")
return
if path in {"/app", "/index.html"}:
if not self.current_session():
self.send_redirect("/login.html")
return
self.send_file(HTML_FILE, "text/html; charset=utf-8")
return
@@ -593,6 +1110,9 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
self.send_file(PRIVACY_FILE, "text/html; charset=utf-8")
return
if not self.require_auth_if_needed(path, "GET"):
return
if path == "/api/health":
overview = build_overview()
self.send_json(
@@ -608,6 +1128,11 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
)
return
if path == "/api/session":
user = self.current_user()
self.send_json({"authenticated": bool(user), "user": user or ""})
return
if path == "/api/course":
self.send_json(COURSE)
return
@@ -687,9 +1212,6 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if not self.require_auth_if_needed(path, "POST"):
return
content_length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(content_length).decode("utf-8") if content_length else "{}"
@@ -703,14 +1225,30 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
username = data.get("username", "")
password = data.get("password", "")
if username in USERS and hashlib.sha256(password.encode("utf-8")).hexdigest() == USERS[username]:
self.send_json({"success": True, "token": "safe_linux_2026", "message": "Login succeeded"})
session_id = create_session(username)
self.send_json(
{
"success": True,
"user": username,
"message": "Login succeeded",
"expires_in": SESSION_TTL_SECONDS,
},
extra_headers={"Set-Cookie": self.cookie_header(session_id)},
)
return
self.send_json({"success": False, "error": "Invalid username or password"}, 401)
return
if not self.require_auth_if_needed(path, "POST"):
return
if path == "/api/logout":
self.send_json({"success": True, "message": "Logged out"})
delete_session(self.current_session_id())
self.send_json(
{"success": True, "message": "Logged out"},
extra_headers={"Set-Cookie": self.cookie_header(clear=True)},
)
return
if path == "/api/reset":