|
| 1 | +/** |
| 2 | + * Crow's Nest Panel — Maker Lab Advanced: JupyterHub launcher + admin |
| 3 | + * bootstrap checklist + pair-programmer posture. |
| 4 | + * |
| 5 | + * Thin panel, same shape as kolibri/scratch-offline. The real UX lives |
| 6 | + * in JupyterHub's own web UI; this panel is setup hints + the deep link. |
| 7 | + */ |
| 8 | + |
| 9 | +export default { |
| 10 | + id: "maker-lab-advanced", |
| 11 | + name: "Maker Lab Advanced", |
| 12 | + icon: "graduation-cap", |
| 13 | + route: "/dashboard/maker-lab-advanced", |
| 14 | + navOrder: 57, |
| 15 | + category: "education", |
| 16 | + |
| 17 | + async handler(req, res, { layout, appRoot }) { |
| 18 | + const { pathToFileURL } = await import("node:url"); |
| 19 | + const { join } = await import("node:path"); |
| 20 | + const componentsPath = join(appRoot, "servers/gateway/dashboard/shared/components.js"); |
| 21 | + const { escapeHtml } = await import(pathToFileURL(componentsPath).href); |
| 22 | + |
| 23 | + const port = process.env.MLA_HTTP_PORT || "8088"; |
| 24 | + const hubUrl = `http://${req.hostname || "localhost"}:${port}`; |
| 25 | + const adminUser = process.env.MLA_ADMIN_USER || "(MLA_ADMIN_USER unset — set in .env before first launch)"; |
| 26 | + |
| 27 | + // Short liveness probe so the operator sees status at a glance. |
| 28 | + async function probe(url, timeoutMs) { |
| 29 | + try { |
| 30 | + const ctrl = new AbortController(); |
| 31 | + const t = setTimeout(() => ctrl.abort(), timeoutMs); |
| 32 | + const resp = await fetch(url, { signal: ctrl.signal }); |
| 33 | + clearTimeout(t); |
| 34 | + return resp.ok; |
| 35 | + } catch { return false; } |
| 36 | + } |
| 37 | + const live = await probe(`${hubUrl}/hub/health`, 1500); |
| 38 | + const statusBadge = live |
| 39 | + ? `<span class="ma-badge ma-ok">● live</span>` |
| 40 | + : `<span class="ma-badge ma-off">○ offline</span>`; |
| 41 | + |
| 42 | + const content = ` |
| 43 | + <style>${styles()}</style> |
| 44 | + <div class="ma-panel"> |
| 45 | + <h1>Maker Lab Advanced</h1> |
| 46 | + <p class="ma-sub">JupyterHub for older learners · ages 9+ · Python notebooks with kid-safe kernel defaults</p> |
| 47 | +
|
| 48 | + <section class="ma-card"> |
| 49 | + <h2>Status</h2> |
| 50 | + <p>${statusBadge}</p> |
| 51 | + <dl class="ma-dl"> |
| 52 | + <dt>Hub URL</dt><dd><code>${escapeHtml(hubUrl)}</code></dd> |
| 53 | + <dt>Configured admin</dt><dd><code>${escapeHtml(adminUser)}</code></dd> |
| 54 | + </dl> |
| 55 | + <p><a class="ma-btn" href="${escapeHtml(hubUrl)}" target="_blank" rel="noopener">Open Hub ↗</a></p> |
| 56 | + </section> |
| 57 | +
|
| 58 | + <section class="ma-card"> |
| 59 | + <h2>First-boot checklist</h2> |
| 60 | + <ol> |
| 61 | + <li>Set <code>MLA_ADMIN_USER</code> and <code>MLA_ADMIN_PASSWORD</code> in <code>.env</code>. Restart the bundle if you changed them.</li> |
| 62 | + <li>Navigate to <code>${escapeHtml(hubUrl)}/hub/signup</code>, sign up with the admin username + password from step 1. Because that username is in the hub's <code>admin_users</code> list, the account auto-authorizes — no self-approval required.</li> |
| 63 | + <li>Admin view at <code>${escapeHtml(hubUrl)}/hub/admin</code> lists users + server status.</li> |
| 64 | + <li>Learner signups go to <code>${escapeHtml(hubUrl)}/hub/authorize</code> for admin approval before they can spawn a server.</li> |
| 65 | + </ol> |
| 66 | + </section> |
| 67 | +
|
| 68 | + <section class="ma-card"> |
| 69 | + <h2>Kid-safe kernel defaults</h2> |
| 70 | + <ul> |
| 71 | + <li>Shell-escape magics (<code>%%bash</code>, <code>!rm</code>, <code>%sx</code>) are disabled at kernel startup. A learner can't shell out of the notebook into the container.</li> |
| 72 | + <li>Per-user home dirs live inside the container at <code>/home/<user></code>; writes outside that path fail with permission errors from the kernel perspective.</li> |
| 73 | + <li><strong>These are defaults for learners, not a security sandbox.</strong> A determined attacker with Python access can still explore the container filesystem. For untrusted users, swap the spawner for DockerSpawner + per-user images.</li> |
| 74 | + </ul> |
| 75 | + </section> |
| 76 | +
|
| 77 | + <section class="ma-card"> |
| 78 | + <h2>Pair-programmer (v1)</h2> |
| 79 | + <p>The <code>maker-lab-advanced</code> skill teaches the AI to act as a pair-programmer at tween/teen reading level. It reuses Maker Lab's hint pipeline and hint-ladder prompts, but:</p> |
| 80 | + <ul> |
| 81 | + <li>Default persona: <code>tween-tutor</code> (80-word hints, middle-grade vocabulary).</li> |
| 82 | + <li>For older learners (14+), the caregiver can switch the session persona to <code>adult-tutor</code> (200-word explanations, plain-language technical terminology, direct Q&A).</li> |
| 83 | + <li>Hints reference <strong>Python + notebook idioms</strong>, not Blockly blocks — the skill explicitly flips off the "never say the answer" constraint at <code>adult-tutor</code> level.</li> |
| 84 | + </ul> |
| 85 | + </section> |
| 86 | +
|
| 87 | + <section class="ma-card"> |
| 88 | + <h2>Where this sits in the ladder</h2> |
| 89 | + <table class="ma-tbl"> |
| 90 | + <tr><th>Ages</th><th>Surface</th><th>Tutor persona</th></tr> |
| 91 | + <tr><td>5-9</td><td>Blockly (maker-lab)</td><td>kid-tutor</td></tr> |
| 92 | + <tr><td>8+</td><td>Scratch (scratch-offline)</td><td>kid-tutor → tween-tutor</td></tr> |
| 93 | + <tr><td>9+</td><td>JupyterLab (this bundle)</td><td>tween-tutor → adult-tutor</td></tr> |
| 94 | + </table> |
| 95 | + </section> |
| 96 | + </div> |
| 97 | + `; |
| 98 | + return layout({ title: "Maker Lab Advanced", content }); |
| 99 | + }, |
| 100 | +}; |
| 101 | + |
| 102 | +function styles() { |
| 103 | + return ` |
| 104 | + .ma-panel { max-width: 900px; margin: 0 auto; padding: 1.5rem; } |
| 105 | + .ma-sub { color: var(--fg-muted, #888); margin: 0 0 1.5rem; } |
| 106 | + .ma-card { background: var(--card-bg, rgba(255,255,255,0.04)); border: 1px solid var(--border, #333); border-radius: 10px; padding: 1.25rem 1.5rem; margin-bottom: 1rem; } |
| 107 | + .ma-card h2 { margin: 0 0 0.75rem; font-size: 1.05rem; color: #84cc16; } |
| 108 | + .ma-card ol, .ma-card ul { margin: 0; padding-left: 1.25rem; line-height: 1.6; } |
| 109 | + .ma-badge { display: inline-block; padding: 0.25rem 0.7rem; border-radius: 999px; font-size: 0.85rem; font-weight: 600; } |
| 110 | + .ma-ok { background: rgba(34,197,94,0.15); color: #22c55e; } |
| 111 | + .ma-off { background: rgba(239,68,68,0.15); color: #ef4444; } |
| 112 | + .ma-btn { display: inline-block; padding: 0.6rem 1.2rem; background: #84cc16; color: #111; text-decoration: none; border-radius: 6px; font-weight: 600; margin-top: 0.5rem; } |
| 113 | + .ma-btn:hover { background: #a3e635; } |
| 114 | + .ma-dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1rem; margin: 0.5rem 0 0; } |
| 115 | + .ma-dl dt { color: var(--fg-muted, #888); } |
| 116 | + .ma-dl dd { margin: 0; } |
| 117 | + .ma-tbl { width: 100%; border-collapse: collapse; font-size: 0.95rem; } |
| 118 | + .ma-tbl th, .ma-tbl td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--border, #333); } |
| 119 | + .ma-tbl th { color: var(--fg-muted, #888); font-weight: 600; } |
| 120 | + `; |
| 121 | +} |
0 commit comments