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 # OpenClaw interactive edit backups
*.interactive.backup.* *.interactive.backup.*
# Runtime logs
server.out.log
server.err.log

View File

@@ -5,9 +5,9 @@
"author": "Codex", "author": "Codex",
"updated": "2026-03-19", "updated": "2026-03-19",
"description": "A rebuilt Linux learning path that moves from command basics to real operations troubleshooting with sandbox exercises.", "description": "A rebuilt Linux learning path that moves from command basics to real operations troubleshooting with sandbox exercises.",
"module_count": 8, "module_count": 10,
"total_lessons": 24, "total_lessons": 30,
"total_exercises": 82, "total_exercises": 106,
"pedagogy": "learning-first", "pedagogy": "learning-first",
"orientation": "ops-workflow" "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> <!DOCTYPE html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Linux Learning Lab</title> <title>Linux Learning Lab</title>
<style> <style>
:root { :root{--bg:#f4f8fc;--panel:#fff;--line:#d8e2ee;--text:#102033;--muted:#5d7187;--brand:#0f6db6;--soft:#e9f3ff;--terminal:#0e1724}
--bg: #f3f7fb; [data-theme="dark"]{--bg:#08111d;--panel:#0c1826;--line:#1d3245;--text:#edf4fb;--muted:#9bb0c3;--brand:#67baff;--soft:rgba(103,186,255,.1);--terminal:#06101c}
--panel: rgba(255, 255, 255, 0.94); *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font-family:"Segoe UI","Microsoft YaHei",sans-serif}
--line: #d8e2ee; 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}
--text: #102033; .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}
--muted: #5d7187; .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}
--brand: #0f6db6; .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}
--accent: #1d9b6c; .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)}
--soft: #e9f3ff; .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: #0e1724; .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)}
--shadow: 0 18px 40px rgba(16, 32, 51, 0.12); 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}}
[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; }
}
</style> </style>
</head> </head>
<body> <body>
<div class="shell"> <div class="shell">
<header class="topbar"> <div class="card top">
<div class="title"> <div>
<div class="eyebrow">Linux Ops Learning Lab</div> <div class="eyebrow" id="topEyebrow"></div>
<h1 id="courseTitle">Linux Learning Lab</h1> <h1 id="courseTitle"></h1>
<p id="courseDescription">Search Linux lessons, practice commands in a sandbox, and build better troubleshooting habits.</p> <p class="muted" id="courseDesc"></p>
</div>
<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> </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> </div>
</header>
<div class="layout"> <div class="layout">
<aside class="sidebar card"> <aside class="sidebar">
<section class="section"> <section class="card">
<div class="eyebrow">Overview</div> <div class="eyebrow" id="overviewEyebrow"></div>
<h2>Course overview</h2> <h2 id="overviewTitle"></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" id="stats"></div>
<div class="stats"> <div class="chips" id="commandTags" style="margin-top:12px;"></div>
<div class="stat"><span>Modules</span><strong id="moduleCount">0</strong></div> <p class="muted" id="metaLine" style="margin-top:12px;"></p>
<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>
</section> </section>
<section class="card">
<section class="section"> <div class="eyebrow" id="searchEyebrow"></div>
<div class="eyebrow">Search</div> <h2 id="searchTitle"></h2>
<h2>Quick lookup</h2> <div class="search" style="margin-top:12px;">
<div class="search"> <input id="searchInput" type="text" />
<input id="searchInput" type="text" placeholder="Try pwd, grep, module_1, or m1_l1_pwd" /> <button class="btn" id="searchBtn"></button>
<button class="btn" type="button" onclick="runSearch()">Search</button>
</div> </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>
<section class="card">
<section class="section"> <div class="eyebrow" id="modulesEyebrow"></div>
<div class="eyebrow">Modules</div> <h2 id="modulesTitle"></h2>
<h2>Course map</h2> <div class="modules" id="moduleList" style="margin-top:12px;"></div>
<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> </section>
</aside> </aside>
<main class="main"> <main class="main">
<section class="hero card"> <section class="card">
<div class="hero-head"> <div class="eyebrow" id="navEyebrow"></div>
<div> <h2 id="navTitle"></h2>
<div class="eyebrow" id="moduleLabel">Pick a lesson to begin</div> <p class="muted" id="navText" style="margin-top:8px;"></p>
<h2 id="lessonTitle">Turn Linux practice into workflow thinking</h2> <div class="filters" id="stageFilters" style="margin-top:12px;"></div>
<p class="muted" id="lessonGoal">Choose a lesson from the left to see goals, examples, common pitfalls, and runnable exercises.</p> <p class="muted" id="stageHint" style="margin-top:12px;"></p>
</div> <div class="filters" id="moduleJumps" style="margin-top:12px;"></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>
</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>
<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>
</section> </section>
<section class="card">
<section class="detail card"> <div class="eyebrow" id="lessonLabel"></div>
<div class="eyebrow">Learning stages</div> <h2 id="lessonTitle" style="margin-top:8px;"></h2>
<h2 style="margin: 8px 0 0;">System route from Linux basics to operations habits</h2> <p class="muted" id="lessonGoal" style="margin-top:8px;"></p>
<p class="muted">This view helps you study in phases instead of bouncing between unrelated commands.</p> <div class="toolbar" style="margin-top:12px;">
<div class="stage-grid" id="stageGrid"></div> <button class="ghost" id="masteryBtn"></button>
</section> <button class="ghost" id="prevBtn"></button>
<button class="btn" id="nextBtn"></button>
<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> </div>
</section> <div class="chips" id="lessonCommands" style="margin-top:12px;"></div>
<div class="grid" style="margin-top:16px;">
<section class="detail card"> <article class="card"><h3 id="summaryTitle"></h3><p class="muted" id="moduleSummary" style="margin-top:10px;"></p></article>
<div class="detail-grid"> <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>
<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> </div>
<div class="note" id="masteryNote" style="margin-top:12px;"></div>
</section> </section>
<section class="card grid">
<section class="practice card"> <article class="card"><h3 id="whyTitle"></h3><p class="muted" id="lessonWhy" style="margin-top:10px;"></p></article>
<div class="eyebrow">Practice</div> <article class="card"><h3 id="relatedTitle"></h3><div class="chips" id="relatedList" style="margin-top:10px;"></div></article>
<h2 style="margin: 8px 0 0;">Exercises and sandbox terminal</h2> <article class="card"><h3 id="conceptsTitle"></h3><ul id="conceptList" class="muted"></ul></article>
<p class="muted">Operation tasks run in the simulated terminal. Reflection tasks keep the learning flow readable even when source data is noisy.</p> <article class="card"><h3 id="flowTitle"></h3><ul id="flowList" class="muted"></ul></article>
<div class="exercise-list" id="exerciseList"></div> </section>
<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> </section>
</main> </main>
</div> </div>
</div> </div>
<script> <script>
const state = { 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;');
overview: null, 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]}]}};
lesson: null, 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]||'');
theme: localStorage.getItem('linux_lab_theme') || 'light', 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';
progress: JSON.parse(localStorage.getItem('linux_lab_progress') || '{}') 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()}
document.addEventListener('DOMContentLoaded', async () => { 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')}
setTheme(state.theme); 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>`}
document.getElementById('themeBtn').addEventListener('click', () => setTheme(state.theme === 'light' ? 'dark' : 'light')); 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>`}
document.getElementById('searchInput').addEventListener('keydown', (event) => { 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()}
if (event.key === 'Enter') runSearch(); 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()}
document.getElementById('prevBtn').addEventListener('click', () => { function openModule(id){const m=(state.overview?.modules||[]).find(x=>x.id===id);const l=m?.lessons?.[0];if(l)openLesson(l.id)}
if (state.lesson?.previous_lesson) openLesson(state.lesson.previous_lesson.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()}
document.getElementById('nextBtn').addEventListener('click', () => { 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>`}
if (state.lesson?.next_lesson) openLesson(state.lesson.next_lesson.id); 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'}
document.getElementById('masteryBtn').addEventListener('click', toggleMastery); 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)}
await loadOverview(); 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;
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;
</script> </script>
</body> </body>
</html> </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", "env", "export", "fdisk", "find", "free", "grep", "head", "history", "id",
"ifconfig", "ip", "journalctl", "kill", "last", "less", "ls", "lsof", "mkdir", "ifconfig", "ip", "journalctl", "kill", "last", "less", "ls", "lsof", "mkdir",
"more", "mount", "mv", "netstat", "nohup", "passwd", "ping", "pkill", "ps", "more", "mount", "mv", "netstat", "nohup", "passwd", "ping", "pkill", "ps",
"pwd", "rm", "sed", "service", "sort", "ss", "stat", "su", "systemctl", "tail", "pwd", "rm", "rpm", "sed", "service", "sort", "ss", "stat", "su", "systemctl",
"tar", "top", "touch", "traceroute", "uniq", "uptime", "vi", "vim", "w", "tail", "tar", "top", "touch", "traceroute", "uniq", "uptime", "vi", "vim", "w",
"wc", "wget", "whereis", "which", "yum" "wc", "wget", "whereis", "which", "whoami", "yum", "dpkg", "cal"
} }

600
server.py
View File

@@ -8,35 +8,40 @@ import http.server
import json import json
import os import os
import re import re
import secrets
import time
import urllib.parse import urllib.parse
from typing import Any from typing import Any
from sandbox import LinuxSandbox 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 = { 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") TASKS_FILE = os.path.join(os.path.dirname(__file__), "COURSE_TASKS.json")
HTML_FILE = os.path.join(os.path.dirname(__file__), "index.html") 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") PRIVACY_FILE = os.path.join(os.path.dirname(__file__), "privacy.html")
SANDBOX = LinuxSandbox() SANDBOX = LinuxSandbox()
SESSION_COOKIE = "linux_lab_session"
SESSION_TTL_SECONDS = 8 * 60 * 60
SESSIONS: dict[str, dict[str, Any]] = {}
PUBLIC_GET_PATHS = { PUBLIC_GET_PATHS = {
"/", "/",
"/login.html",
"/privacy", "/privacy",
"/privacy.html", "/privacy.html",
"/api/course",
"/api/course/search",
"/api/diagnostics",
"/api/health", "/api/health",
"/api/lesson", "/api/session",
"/api/overview",
} }
PUBLIC_POST_PATHS = { PUBLIC_POST_PATHS = {
"/api/login", "/api/login",
"/api/logout",
} }
SAFE_REMOTE_HOST = "xiaoxiaoluohao.indevs.in"
def load_course() -> dict[str, Any]: def load_course() -> dict[str, Any]:
@@ -51,6 +56,37 @@ def load_course() -> dict[str, Any]:
COURSE = load_course() 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: def looks_garbled(value: Any) -> bool:
if not isinstance(value, str) or not value.strip(): if not isinstance(value, str) or not value.strip():
return False return False
@@ -106,6 +142,19 @@ def command_label(commands: list[str]) -> str:
return " / ".join(commands[:3]) 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: def module_number(module_id: str | None) -> str | None:
if not module_id: if not module_id:
return None 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)}." 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]]: def flatten_lessons() -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
for module in COURSE.get("modules", []): for module in COURSE.get("modules", []):
@@ -295,7 +777,7 @@ def build_diagnostics() -> dict[str, Any]:
solution_commands = sorted(set(extract_solution_commands())) solution_commands = sorted(set(extract_solution_commands()))
required_commands = sorted(set(course_commands) | set(solution_commands)) required_commands = sorted(set(course_commands) | set(solution_commands))
supported_commands = sorted(SANDBOX.supported_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) total_exercises = sum(len(lesson.get("exercises", [])) for lesson in lessons)
operation_exercises = sum( operation_exercises = sum(
1 1
@@ -309,7 +791,7 @@ def build_diagnostics() -> dict[str, Any]:
"supported_command_count": len(supported_commands), "supported_command_count": len(supported_commands),
"required_command_count": len(required_commands), "required_command_count": len(required_commands),
"covered_command_count": len(required_commands) - len(unsupported_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, "unsupported_commands": unsupported_commands,
"course_commands": required_commands, "course_commands": required_commands,
"operation_exercises": operation_exercises, "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] lesson_ids = [item.get("id") for item in all_lessons]
current_id = lesson.get("id") current_id = lesson.get("id")
index = lesson_ids.index(current_id) if current_id in lesson_ids else -1 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 previous_lesson = None
next_lesson = None next_lesson = None
@@ -369,7 +852,7 @@ def build_lesson_payload(module: dict[str, Any], lesson: dict[str, Any]) -> dict
return { return {
"id": lesson.get("id"), "id": lesson.get("id"),
"display_title": lesson_title(lesson), "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_why": safe_text(lesson.get("why_it_matters"), default_why(commands)),
"display_module_title": module_title(module), "display_module_title": module_title(module),
"display_module_summary": module_summary(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)), "after_class": safe_text(lesson.get("after_class"), default_after_class(commands)),
"related_commands": safe_list(lesson.get("related_commands"), commands or ["pwd"]), "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", [])], "exercises": [build_exercise(exercise, lesson) for exercise in lesson.get("exercises", [])],
"exercise_count": len(lesson.get("exercises", [])), "exercise_count": len(lesson.get("exercises", [])),
"module_id": module.get("id"), "module_id": module.get("id"),
@@ -534,11 +1020,13 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args): def log_message(self, format, *args):
pass 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") raw = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(raw))) 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.end_headers()
self.wfile.write(raw) self.wfile.write(raw)
@@ -551,6 +1039,36 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) 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: def is_public_path(self, path: str, method: str) -> bool:
if method == "GET": if method == "GET":
return path in PUBLIC_GET_PATHS return path in PUBLIC_GET_PATHS
@@ -558,22 +1076,10 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
return path in PUBLIC_POST_PATHS return path in PUBLIC_POST_PATHS
return False 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: def require_auth_if_needed(self, path: str, method: str) -> bool:
if self.is_public_path(path, method): if self.is_public_path(path, method):
return True return True
host = self.headers.get("Host", "") if not self.current_session():
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):
self.send_json({"error": "Authentication required"}, 401) self.send_json({"error": "Authentication required"}, 401)
return False return False
return True return True
@@ -582,10 +1088,21 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
path = parsed.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 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") self.send_file(HTML_FILE, "text/html; charset=utf-8")
return return
@@ -593,6 +1110,9 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
self.send_file(PRIVACY_FILE, "text/html; charset=utf-8") self.send_file(PRIVACY_FILE, "text/html; charset=utf-8")
return return
if not self.require_auth_if_needed(path, "GET"):
return
if path == "/api/health": if path == "/api/health":
overview = build_overview() overview = build_overview()
self.send_json( self.send_json(
@@ -608,6 +1128,11 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
) )
return return
if path == "/api/session":
user = self.current_user()
self.send_json({"authenticated": bool(user), "user": user or ""})
return
if path == "/api/course": if path == "/api/course":
self.send_json(COURSE) self.send_json(COURSE)
return return
@@ -687,9 +1212,6 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
path = parsed.path path = parsed.path
if not self.require_auth_if_needed(path, "POST"):
return
content_length = int(self.headers.get("Content-Length", 0)) content_length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(content_length).decode("utf-8") if content_length else "{}" 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", "") username = data.get("username", "")
password = data.get("password", "") password = data.get("password", "")
if username in USERS and hashlib.sha256(password.encode("utf-8")).hexdigest() == USERS[username]: 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 return
self.send_json({"success": False, "error": "Invalid username or password"}, 401) self.send_json({"success": False, "error": "Invalid username or password"}, 401)
return return
if not self.require_auth_if_needed(path, "POST"):
return
if path == "/api/logout": 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 return
if path == "/api/reset": if path == "/api/reset":