diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..950eaae --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_LAB3_TERMINAL_PATH=/wetty diff --git a/README.md b/README.md index 13a5ce0..2f0a74f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,23 @@ npm run dev 3. Open `http://localhost:3000`. +## Lab 3 Web Terminal + +Set `NEXT_PUBLIC_LAB3_TERMINAL_PATH` to the WeTTY endpoint used by your deployment. The default expected path is `/wetty`, and `.env.example` includes that value. Local environments can also provide a full URL such as `http://127.0.0.1:7681/wetty`. + +The Lab 3 widget assumes: + +- WeTTY runs on the lab host and is bound to `127.0.0.1` +- the public proxy forwards `/wetty` to the local WeTTY port +- WebSocket upgrade happens at the reverse proxy +- WeTTY is launched as root so it can present `/bin/login` locally instead of SSH + +Example service command: + +```bash +wetty --host 127.0.0.1 --port 3001 --base /wetty --allow-iframe +``` + ## Project Structure ```text diff --git a/content/labs/lab-3-llama-cpp-and-ollama.md b/content/labs/lab-3-llama-cpp-and-ollama.md index a521d83..e59cfde 100644 --- a/content/labs/lab-3-llama-cpp-and-ollama.md +++ b/content/labs/lab-3-llama-cpp-and-ollama.md @@ -24,7 +24,11 @@ In this lab, we will: Execute sections require running commands and producing output. -To start this lab, you'll need CLI access: +To start this lab, use the embedded terminal below. It connects to the same lab machine in your browser and should prompt you to log in with the default `student` account. + +
+ +If the embedded terminal is unavailable, you can still fall back to: - SSH - :22 - All necessary artifacts are in the `lab3` folder diff --git a/docs/lab3-terminal-deployment.md b/docs/lab3-terminal-deployment.md new file mode 100644 index 0000000..fe30e67 --- /dev/null +++ b/docs/lab3-terminal-deployment.md @@ -0,0 +1,32 @@ +# Lab 3 Embedded Terminal Deployment + +Lab 3 now expects a same-origin WeTTY endpoint mounted at `/wetty` by default. + +## Runtime Contract + +- Run WeTTY on the lab host, bound to `127.0.0.1` +- Mount it behind the same public origin as the Next.js app +- Let the reverse proxy handle WebSocket upgrade for `/wetty` +- Launch WeTTY as root so it uses `/bin/login` locally +- Keep SSH available as a fallback path, not the primary student UX + +Example launch command: + +```bash +wetty --host 127.0.0.1 --port 3001 --base /wetty --allow-iframe +``` + +## Proxmox VM Shape + +- Install WeTTY as a system service +- Keep it bound to localhost only +- Reverse proxy `/wetty` to the local WeTTY port over HTTPS + +## Docker Shape + +- Include WeTTY in the lab image +- Include the `login` and PAM pieces needed for `/bin/login` +- Run WeTTY under an init or supervisor process +- Reverse proxy `/wetty` to the local WeTTY port over HTTPS + +If a specific container image cannot support `/bin/login`, the operational fallback is WeTTY SSH mode to localhost. diff --git a/src/components/labs/Lab3TerminalFrame.test.tsx b/src/components/labs/Lab3TerminalFrame.test.tsx new file mode 100644 index 0000000..cca0abb --- /dev/null +++ b/src/components/labs/Lab3TerminalFrame.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { + LAB3_DEFAULT_TERMINAL_PATH, + Lab3TerminalFrame, + getLab3TerminalPath, +} from "~/components/labs/Lab3TerminalFrame"; + +describe("getLab3TerminalPath", () => { + it("falls back to the default same-origin terminal path", () => { + expect(getLab3TerminalPath()).toBe(LAB3_DEFAULT_TERMINAL_PATH); + expect(getLab3TerminalPath("")).toBe(LAB3_DEFAULT_TERMINAL_PATH); + }); + + it("normalizes relative and absolute terminal values to a same-origin path", () => { + expect(getLab3TerminalPath("wetty")).toBe("/wetty"); + expect(getLab3TerminalPath("https://labs.example.com/wetty?view=lab3")).toBe( + "https://labs.example.com/wetty?view=lab3", + ); + }); +}); + +describe("Lab3TerminalFrame", () => { + const originalEnvValue = process.env.NEXT_PUBLIC_LAB3_TERMINAL_PATH; + + afterEach(() => { + process.env.NEXT_PUBLIC_LAB3_TERMINAL_PATH = originalEnvValue; + }); + + it("renders the embedded terminal with the configured path", () => { + process.env.NEXT_PUBLIC_LAB3_TERMINAL_PATH = "lab-terminal"; + + render(); + + const frame = screen.getByTitle("Lab 3 terminal session"); + expect(frame).toHaveAttribute("src", "/lab-terminal"); + expect( + screen.getAllByRole("link", { name: "Open in New Tab" }).every((link) => { + return link.getAttribute("href") === "/lab-terminal"; + }), + ).toBe(true); + expect(screen.getAllByText("/home/student/lab3")).toHaveLength(2); + }); + + it("toggles the dock open and closed", () => { + render(); + + const toggle = screen.getAllByRole("button", { name: /open terminal|hide terminal|lab 3 terminal/i })[0]; + expect(toggle).toHaveAttribute("aria-expanded", "false"); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + }); + + it("renders fallback guidance alongside the embedded frame", () => { + render(); + + expect(screen.getByText(/If the terminal page does not appear/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/labs/Lab3TerminalFrame.tsx b/src/components/labs/Lab3TerminalFrame.tsx new file mode 100644 index 0000000..3de3a53 --- /dev/null +++ b/src/components/labs/Lab3TerminalFrame.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; + +export const LAB3_DEFAULT_TERMINAL_PATH = "/wetty"; + +export function getLab3TerminalPath(envValue?: string) { + const trimmedValue = envValue?.trim(); + + if (!trimmedValue) { + return LAB3_DEFAULT_TERMINAL_PATH; + } + + if (/^https?:\/\//i.test(trimmedValue)) { + return trimmedValue; + } + + return trimmedValue.startsWith("/") ? trimmedValue : `/${trimmedValue}`; +} + +type Lab3TerminalFrameProps = { + srcPath?: string; +}; + +export function Lab3TerminalFrame({ + srcPath = getLab3TerminalPath(process.env.NEXT_PUBLIC_LAB3_TERMINAL_PATH), +}: Lab3TerminalFrameProps) { + const frameRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); + const terminalPath = useMemo(() => getLab3TerminalPath(srcPath), [srcPath]); + + function handleFrameLoad() { + const frame = frameRef.current; + + try { + const frameDocument = frame?.contentDocument; + const title = frameDocument?.title ?? ""; + const bodyText = frameDocument?.body?.textContent?.replace(/\s+/g, " ").trim() ?? ""; + + if ( + /\b404\b/i.test(title) || + /\b404\b/i.test(bodyText) || + /This page could not be found/i.test(bodyText) + ) { + setStatus("error"); + return; + } + } catch { + // Ignore cross-document reads and treat the frame as successfully loaded. + } + + setStatus("ready"); + } + + return ( + <> +
+
+

Lab 3 Terminal

+

Use the lab shell directly in your browser

+

+ The terminal is docked to the bottom of the page. Expand it with the + arrow when you're ready to work from /home/student/lab3. +

+
+ +
+ + + + Open in New Tab + +
+
+ +
+ + +
+
+

+ Work from /home/student/lab3, or pop the terminal out + into a full tab for more room. +

+ + + Open in New Tab + +
+ +
+