forked from admin/linux-practice
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3db89417d7 | ||
|
|
efaa715e93 | ||
|
|
2f7bd50a36 | ||
|
|
d61730bf17 | ||
|
|
d2b667f569 | ||
|
|
f5bcfa2259 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
950
index.html
950
index.html
@@ -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>
|
||||||
<div class="actions">
|
<div class="toolbar">
|
||||||
<button class="btn-ghost" type="button" id="themeBtn">Toggle theme</button>
|
<span class="chip" id="sessionUser"></span>
|
||||||
<button class="btn-soft" type="button" onclick="resetSandbox()">Reset sandbox</button>
|
<button class="ghost" id="themeBtn"></button>
|
||||||
<a class="btn-ghost" href="/privacy" target="_blank" rel="noreferrer">Privacy</a>
|
<button class="ghost" id="langBtn"></button>
|
||||||
|
<button class="ghost" id="logoutBtn"></button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
<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">
|
</section>
|
||||||
<button class="btn-soft" type="button" id="masteryBtn">Mark mastered</button>
|
<section class="card">
|
||||||
<button class="btn-ghost" type="button" id="prevBtn">Previous</button>
|
<div class="eyebrow" id="lessonLabel"></div>
|
||||||
<button class="btn" type="button" id="nextBtn">Next</button>
|
<h2 id="lessonTitle" style="margin-top:8px;"></h2>
|
||||||
</div>
|
<p class="muted" id="lessonGoal" style="margin-top:8px;"></p>
|
||||||
|
<div class="toolbar" style="margin-top:12px;">
|
||||||
|
<button class="ghost" id="masteryBtn"></button>
|
||||||
|
<button class="ghost" id="prevBtn"></button>
|
||||||
|
<button class="btn" id="nextBtn"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cmd-list" id="lessonCommands"></div>
|
<div class="chips" id="lessonCommands" style="margin-top:12px;"></div>
|
||||||
<div class="hero-grid">
|
<div class="grid" style="margin-top:16px;">
|
||||||
<div class="panel">
|
<article class="card"><h3 id="summaryTitle"></h3><p class="muted" id="moduleSummary" style="margin-top:10px;"></p></article>
|
||||||
<h3>Module summary</h3>
|
<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>
|
||||||
<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>
|
||||||
<div class="mastery-note" id="masteryNote">Master a lesson after you can explain the command, predict the output, and connect it to a real operations step.</div>
|
<div class="note" id="masteryNote" style="margin-top:12px;"></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="card grid">
|
||||||
<section class="detail card">
|
<article class="card"><h3 id="whyTitle"></h3><p class="muted" id="lessonWhy" style="margin-top:10px;"></p></article>
|
||||||
<div class="eyebrow">Learning stages</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;">System route from Linux basics to operations habits</h2>
|
<article class="card"><h3 id="conceptsTitle"></h3><ul id="conceptList" class="muted"></ul></article>
|
||||||
<p class="muted">This view helps you study in phases instead of bouncing between unrelated commands.</p>
|
<article class="card"><h3 id="flowTitle"></h3><ul id="flowList" class="muted"></ul></article>
|
||||||
<div class="stage-grid" id="stageGrid"></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
<section class="card">
|
||||||
<section class="detail card">
|
<div class="eyebrow" id="practiceEyebrow"></div>
|
||||||
<div class="eyebrow">Module heatmap</div>
|
<h2 id="practiceTitle"></h2>
|
||||||
<h2 style="margin: 8px 0 0;">See how the learning path is distributed</h2>
|
<p class="muted" id="practiceText" style="margin-top:8px;"></p>
|
||||||
<p class="muted">This panel helps you check whether the project really teaches both command basics and operations workflows.</p>
|
<div id="exerciseList" style="display:grid;gap:12px;margin-top:12px;"></div>
|
||||||
<div class="module-summary-grid" id="moduleSummaryGrid"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="detail card">
|
|
||||||
<div class="eyebrow">Learning cockpit</div>
|
|
||||||
<h2 style="margin: 8px 0 0;">Visual study map for the current lesson</h2>
|
|
||||||
<p class="muted">These cards help you move from memorizing a command to understanding the workflow around it.</p>
|
|
||||||
<div class="detail-grid" style="margin-top: 16px;">
|
|
||||||
<article class="panel">
|
|
||||||
<h3>Lesson radar</h3>
|
|
||||||
<div class="mini-stats" id="lessonRadar"></div>
|
|
||||||
</article>
|
|
||||||
<article class="panel">
|
|
||||||
<h3>Experiment ladder</h3>
|
|
||||||
<ol class="ordered" id="experimentList"></ol>
|
|
||||||
</article>
|
|
||||||
<article class="panel">
|
|
||||||
<h3>Observation checklist</h3>
|
|
||||||
<ul id="observationList"></ul>
|
|
||||||
</article>
|
|
||||||
<article class="panel">
|
|
||||||
<h3>Related commands</h3>
|
|
||||||
<div class="chip-list" id="relatedCommandList"></div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="detail card">
|
|
||||||
<div class="detail-grid">
|
|
||||||
<article class="panel">
|
|
||||||
<h3>Why this matters</h3>
|
|
||||||
<p id="lessonWhy">Understanding when to use a command matters more than memorizing flags in isolation.</p>
|
|
||||||
</article>
|
|
||||||
<article class="panel">
|
|
||||||
<h3>Teaching angle</h3>
|
|
||||||
<p id="classicView">Each lesson connects the command to a broader troubleshooting flow.</p>
|
|
||||||
</article>
|
|
||||||
<article class="panel">
|
|
||||||
<h3>Core ideas</h3>
|
|
||||||
<ul id="conceptList"></ul>
|
|
||||||
</article>
|
|
||||||
<article class="panel">
|
|
||||||
<h3>Common scenarios</h3>
|
|
||||||
<ul id="scenarioList"></ul>
|
|
||||||
</article>
|
|
||||||
<article class="panel">
|
|
||||||
<h3>Common pitfalls</h3>
|
|
||||||
<ul id="pitfallList"></ul>
|
|
||||||
</article>
|
|
||||||
<article class="panel">
|
|
||||||
<h3>Troubleshooting flow</h3>
|
|
||||||
<ul id="flowList"></ul>
|
|
||||||
</article>
|
|
||||||
<article class="panel">
|
|
||||||
<h3>Takeaways</h3>
|
|
||||||
<ul id="takeawayList"></ul>
|
|
||||||
<p id="afterClass" style="margin-top: 10px;"></p>
|
|
||||||
</article>
|
|
||||||
<article class="panel">
|
|
||||||
<h3>Example commands</h3>
|
|
||||||
<div class="examples" id="exampleList"></div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="practice card">
|
|
||||||
<div class="eyebrow">Practice</div>
|
|
||||||
<h2 style="margin: 8px 0 0;">Exercises and sandbox terminal</h2>
|
|
||||||
<p class="muted">Operation tasks run in the simulated terminal. Reflection tasks keep the learning flow readable even when source data is noisy.</p>
|
|
||||||
<div class="exercise-list" id="exerciseList"></div>
|
|
||||||
</section>
|
</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('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('\"','"').replaceAll("'",''');
|
||||||
overview: null,
|
const LANG={zh:{top:'Linux 运维学习实验室',title:'Linux 学习实验室',desc:'登录后在沙箱里学命令、做练习、看总览。',session:'当前用户',themeDark:'深色模式',themeLight:'浅色模式',logout:'退出登录',overview:'总览',nav:'课程快速入口',navText:'按阶段或模块快速跳转。',search:'搜索',searchBtn:'搜索',searchPlaceholder:'试试 pwd、grep 或 module_1',modules:'模块',all:'全部阶段',startLabel:'先选一节课开始',startTitle:'Linux 学习驾驶舱',startGoal:'从左侧选择课程,查看目标与练习。',summary:'模块摘要',runtime:'沙箱运行态',runtimeText:'下方可直接执行命令。',mark:'标记已掌握',review:'标记待复习',prev:'上一课',next:'下一课',why:'为什么重要',related:'相关命令',concepts:'核心概念',flow:'排障流程',practice:'练习',practiceText:'操作题直接在模拟终端中运行。',run:'运行',terminal:'Linux 沙箱终端',waiting:'等待输入命令...',empty:'暂无内容。',searchEmpty:'可以按命令名或课程 ID 搜索。',searchLoading:'正在搜索...',searchNo:'没有找到结果。',authExpired:'登录状态已失效,请重新登录。',logoutDone:'已退出登录,请重新登录。',stages:[{key:'all',title:'全部阶段'},{key:'stage-1',title:'阶段 1',range:[0,1]},{key:'stage-2',title:'阶段 2',range:[2,3]},{key:'stage-3',title:'阶段 3',range:[4,4]},{key:'stage-4',title:'阶段 4',range:[5,7]},{key:'stage-5',title:'阶段 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('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
.replaceAll('"', '"')
|
|
||||||
.replaceAll("'", ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.openLesson = openLesson;
|
|
||||||
window.runExercise = runExercise;
|
|
||||||
window.runSearch = runSearch;
|
|
||||||
window.resetSandbox = resetSandbox;
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
48
login.html
Normal file
48
login.html
Normal 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:'安全入口',title:'Linux 学习实验室登录',desc:'登录后才能访问课程总览、练习终端和学习面板。',user:'用户名',pass:'密码',submit:'登录并进入学习台',themeDark:'深色模式',themeLight:'浅色模式',lang:'EN',account:'账号:按部署配置',password:'密码:按部署配置',ready:'请输入部署时配置的账号密码进入 Linux 学习项目。',loading:'正在登录...',failed:'登录失败,请检查账号与密码。'},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>
|
||||||
|
|
||||||
@@ -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
600
server.py
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user