Add runtime service links for lab endpoints
This commit is contained in:
@@ -30,7 +30,7 @@ To start this lab, use the embedded terminal below. It connects to the same lab
|
||||
|
||||
If the embedded terminal is unavailable, you can still fall back to:
|
||||
|
||||
- SSH - <IP>:22
|
||||
- SSH - {{service-address:ssh}}
|
||||
- A regular terminal session on the lab host
|
||||
|
||||
## Objective 1: HuggingFace & LLaMa.cpp
|
||||
|
||||
@@ -25,11 +25,11 @@ In this lab, we will:
|
||||
|
||||
To start this lab, one web service has been preconfigured:
|
||||
|
||||
- Open WebUI - http://<IP>:8080
|
||||
- Open WebUI - {{service-url:open-webui}}
|
||||
|
||||
## Objective 1 Execute: Accessing Open WebUI
|
||||
|
||||
Your lab machine has been pre-installed with Open Webui. It is accessible on your provided system IP at port 8080 (http://<IP>:8080). You can log in or register with the following default credentials:
|
||||
Your lab machine has been pre-installed with Open Webui. It is accessible on your provided system URL at {{service-url:open-webui}}. You can log in or register with the following default credentials:
|
||||
|
||||
Username: student@openwebui.com
|
||||
Password: student
|
||||
|
||||
@@ -25,7 +25,7 @@ In this lab, we will:
|
||||
|
||||
To start this lab, one web service has been preconfigured:
|
||||
|
||||
- Open WebUI - http://<IP>:8080
|
||||
- Open WebUI - {{service-url:open-webui}}
|
||||
|
||||
## Objective 1 Execute: Generate an Open WebUI API Key
|
||||
|
||||
@@ -33,7 +33,7 @@ Before we install any harness, we need a key that lets the harness call the same
|
||||
|
||||
### Execute: Sign in to Open WebUI
|
||||
|
||||
1. Navigate to `http://<YOUR STUDENT IP>:8080`.
|
||||
1. Navigate to `{{service-url:open-webui}}`.
|
||||
2. Sign in with the same account you used in Lab 4, or the credentials supplied by your instructor.
|
||||
3. Confirm that you can reach the normal chat screen before continuing.
|
||||
|
||||
@@ -82,7 +82,7 @@ According to the Open WebUI reference docs, API keys are created from **Settings
|
||||
Run a quick authenticated request against the Open WebUI model list endpoint. You should receive JSON back instead of an authentication error.
|
||||
|
||||
```bash
|
||||
curl http://<YOUR STUDENT IP>:8080/api/models \
|
||||
curl {{service-url:open-webui:/api}}/models \
|
||||
-H "Authorization: Bearer YOUR_OPENWEBUI_API_KEY"
|
||||
```
|
||||
|
||||
@@ -160,7 +160,7 @@ droid --version</code></pre>
|
||||
|
||||
For all three harnesses, the common backend values are:
|
||||
|
||||
- `Base URL` - `http://<YOUR STUDENT IP>:8080/api`
|
||||
- `Base URL` - `{{service-url:open-webui:/api}}`
|
||||
- `API Key` - `YOUR_OPENWEBUI_API_KEY`
|
||||
- `Model ID` - Any model ID returned by Open WebUI, such as `qwen3.5:4b`
|
||||
|
||||
@@ -180,7 +180,7 @@ The shared idea is simple: your harness sends requests to Open WebUI's authentic
|
||||
"openwebui": {
|
||||
"name": "Open WebUI",
|
||||
"options": {
|
||||
"baseURL": "http://<YOUR STUDENT IP>:8080/api",
|
||||
"baseURL": "{{service-url:open-webui:/api}}",
|
||||
},
|
||||
"models": {
|
||||
"qwen3.5:4b": {
|
||||
@@ -213,7 +213,7 @@ opencode</code></pre>
|
||||
<p>Kilo Code's documented workflow is provider-driven through the extension settings UI. Use the following values when creating or editing your provider profile.</p>
|
||||
<ul>
|
||||
<li><code>API Provider</code> - <code>OpenAI Compatible</code></li>
|
||||
<li><code>OpenAI Base URL</code> - <code>http://<YOUR STUDENT IP>:8080/api</code></li>
|
||||
<li><code>OpenAI Base URL</code> - <code>{{service-url:open-webui:/api}}</code></li>
|
||||
<li><code>API Key</code> - <code>YOUR_OPENWEBUI_API_KEY</code></li>
|
||||
<li><code>Model ID</code> - <code>qwen3.5:4b</code> or another model exposed by Open WebUI</li>
|
||||
<li><code>Approval Mode</code> - Leave the safer default enabled for your first run</li>
|
||||
@@ -266,7 +266,7 @@ opencode</code></pre>
|
||||
{
|
||||
"model_display_name": "Open WebUI - Qwen 3.5 4B",
|
||||
"model": "qwen3.5:4b",
|
||||
"base_url": "http://<YOUR STUDENT IP>:8080/api",
|
||||
"base_url": "{{service-url:open-webui:/api}}",
|
||||
"api_key": "YOUR_OPENWEBUI_API_KEY",
|
||||
"provider": "generic-chat-completion-api",
|
||||
"max_tokens": 4096
|
||||
|
||||
@@ -24,8 +24,8 @@ In this lab, we will:
|
||||
|
||||
To start this lab, two web services have been preconfigured:
|
||||
|
||||
- ChunkViz - http://<IP>:3000
|
||||
- Embedding Atlas - http://<IP>:5055
|
||||
- ChunkViz - {{service-url:chunkviz}}
|
||||
- Embedding Atlas - {{service-url:embedding-atlas}}
|
||||
|
||||
## Objective 1 Explore: Chunking Strategy
|
||||
|
||||
@@ -33,7 +33,7 @@ Chunking is the first step in any RAG pipeline. It is the process of dividing a
|
||||
|
||||
Successful chunking is highly dependent on the type of document being processed. In production-grade RAG systems, teams often evaluate multiple strategies across different document types, then route content through the processing path that produces the strongest retrieval results. For this lab, we will use a visualization tool to build intuition for those trade-offs.
|
||||
|
||||
In a web browser, navigate to http://<STUDENT ASSIGNED SYSTEM IP>:3000. Once loaded, you should see the ChunkViz homepage.
|
||||
In a web browser, navigate to {{service-url:chunkviz}}. Once loaded, you should see the ChunkViz homepage.
|
||||
|
||||
<figure style="text-align: center;">
|
||||
<a href="https://i.imgur.com/PG6fp1V.png" target="_blank">
|
||||
@@ -115,7 +115,7 @@ Now that we have seen some of the trade-offs involved in chunking, we can move t
|
||||
|
||||
This allows a system to perform similarity search efficiently. When a user submits a query, the query is embedded into the same vector space, and the system retrieves the chunks whose embeddings are closest to it. This differs from how embeddings are used internally by an LLM for attention and transformation, but it is the key step that allows a RAG system to retrieve information based on meaning rather than simple keyword matching.
|
||||
|
||||
Navigate to http://<STUDENT ASSIGNED SYSTEM IP>:5055. Here, we have started a project called Embedding Atlas. Embedding Atlas is a tool that provides interactive visualizations for datasets stored in parquet format. Each chunk in this case is one row in the dataset, allowing us to visualize, cross-filter, and search embeddings and metadata interactively.
|
||||
Navigate to {{service-url:embedding-atlas}}. Here, we have started a project called Embedding Atlas. Embedding Atlas is a tool that provides interactive visualizations for datasets stored in parquet format. Each chunk in this case is one row in the dataset, allowing us to visualize, cross-filter, and search embeddings and metadata interactively.
|
||||
|
||||
<figure style="text-align: center;">
|
||||
<a href="https://i.imgur.com/8PvcZBP.png" target="_blank">
|
||||
|
||||
@@ -24,7 +24,7 @@ In this lab, we will:
|
||||
|
||||
To start this lab, one web service has been preconfigured:
|
||||
|
||||
- Unsloth - http://<IP>:8888
|
||||
- Unsloth - {{service-url:unsloth}}
|
||||
|
||||
You'll need to install Kiln from the following URL - https://github.com/Kiln-AI/Kiln/releases/tag/v0.18.1
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ In this lab, we will:
|
||||
|
||||
To start this lab, one web service has been preconfigured:
|
||||
|
||||
- Promptfoo - http://<IP>:15500
|
||||
- Promptfoo - {{service-url:promptfoo}}
|
||||
|
||||
## Objective 1 Explore: Direct Prompt Injection
|
||||
|
||||
@@ -53,7 +53,7 @@ While manual interaction with a model is often required for a successful jailbre
|
||||
|
||||
### Explore: Promptfoo red-team workflow
|
||||
|
||||
Promptfoo is available on our lab machine at http://<YOUR STUDENT IP>:15500. We can start by creating a new red-team configuration.
|
||||
Promptfoo is available on our lab machine at {{service-url:promptfoo}}. We can start by creating a new red-team configuration.
|
||||
|
||||
<figure style="text-align: center;">
|
||||
<a href="https://i.imgur.com/YyP8mwB.png" target="_blank">
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { micromark } from "micromark";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LabContent } from "~/components/labs/LabContent";
|
||||
import { getLabDocument } from "~/lib/labs";
|
||||
|
||||
describe("LabContent", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the Lab 1 widget tokens into interactive components", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
function mockRuntimeConfig(config?: Record<string, unknown>) {
|
||||
return vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
config ?? {
|
||||
lab1NetronUrl: "http://127.0.0.1:8338",
|
||||
lab2OllamaModels: [
|
||||
{
|
||||
label: "Gemma 4 E2B Q2",
|
||||
value: "cajina/gemma4_e2b-q2_k_xl:v01",
|
||||
label: "Gemma 4 E2B Q4",
|
||||
value: "batiai/gemma4-e2b:q4",
|
||||
},
|
||||
],
|
||||
lab2OllamaUrl: "http://127.0.0.1:11434",
|
||||
lab3TerminalUrl: "http://127.0.0.1:7681/wetty",
|
||||
}),
|
||||
{ status: 200 },
|
||||
},
|
||||
),
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("renders the Lab 1 widget tokens into interactive components", async () => {
|
||||
mockRuntimeConfig();
|
||||
|
||||
render(
|
||||
<LabContent
|
||||
@@ -44,7 +52,9 @@ describe("LabContent", () => {
|
||||
expect(screen.getByText("Visualize token confidence locally")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters harness branches from a single Objective 2 selector", () => {
|
||||
it("filters harness branches from a single Objective 2 selector", async () => {
|
||||
mockRuntimeConfig();
|
||||
|
||||
render(
|
||||
<LabContent
|
||||
className="lab-content"
|
||||
@@ -68,10 +78,12 @@ describe("LabContent", () => {
|
||||
const droidInstall = screen.getByText("Factory Droid Install").closest("section");
|
||||
const kiloCodeConfig = screen.getByText("Kilo Code Config").closest("section");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openCodeInstall?.hidden).toBe(true);
|
||||
expect(kiloCodeInstall?.hidden).toBe(true);
|
||||
expect(droidInstall?.hidden).toBe(true);
|
||||
expect(kiloCodeConfig?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Kilo Code" }));
|
||||
|
||||
@@ -94,4 +106,99 @@ describe("LabContent", () => {
|
||||
expect(kiloCodeInstall?.hidden).toBe(true);
|
||||
expect(droidInstall?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("renders service URL tokens in prose as clickable links", async () => {
|
||||
mockRuntimeConfig();
|
||||
|
||||
render(
|
||||
<LabContent
|
||||
className="lab-content"
|
||||
html="<p>Open WebUI lives at {{service-url:open-webui}}.</p>"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = await screen.findByRole("link", {
|
||||
name: "http://localhost:8080/",
|
||||
});
|
||||
|
||||
expect(link).toHaveAttribute("href", "http://localhost:8080/");
|
||||
});
|
||||
|
||||
it("renders inline service URL tokens inside code as plain text", async () => {
|
||||
mockRuntimeConfig();
|
||||
|
||||
render(
|
||||
<LabContent
|
||||
className="lab-content"
|
||||
html="<p>Use <code>{{service-url:open-webui:/api}}</code> as the base URL.</p>"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("http://localhost:8080/api")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "http://localhost:8080/api" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders fenced service URL tokens as plain text that stays copyable", async () => {
|
||||
mockRuntimeConfig();
|
||||
|
||||
render(
|
||||
<LabContent
|
||||
className="lab-content"
|
||||
html="<pre><code>{{service-url:open-webui:/api}}</code></pre>"
|
||||
/>,
|
||||
);
|
||||
|
||||
const code = await screen.findByText("http://localhost:8080/api");
|
||||
expect(code.closest("pre")).not.toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "http://localhost:8080/api" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders SSH address tokens as plain host and port text", async () => {
|
||||
mockRuntimeConfig();
|
||||
|
||||
render(
|
||||
<LabContent
|
||||
className="lab-content"
|
||||
html="<p>Fallback SSH access: {{service-address:ssh}}</p>"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Fallback SSH access: localhost:22", {
|
||||
selector: "p",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "localhost:22" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the real Lab 5 service references with runtime URLs", async () => {
|
||||
mockRuntimeConfig({
|
||||
services: {
|
||||
"open-webui": "https://lab.example/openwebui",
|
||||
},
|
||||
});
|
||||
|
||||
const lab = getLabDocument("lab-5-api-and-harnesses");
|
||||
expect(lab).not.toBeNull();
|
||||
|
||||
render(
|
||||
<LabContent
|
||||
className="lab-content"
|
||||
html={micromark(lab?.content ?? "", { allowDangerousHtml: true })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("link", { name: "https://lab.example/openwebui" }),
|
||||
).toHaveAttribute("href", "https://lab.example/openwebui");
|
||||
const apiMatches = await screen.findAllByText("https://lab.example/openwebui/api");
|
||||
expect(apiMatches.some((node) => node.tagName === "CODE")).toBe(true);
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "https://lab.example/openwebui/api" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,14 @@ import { Objective5Chat } from "~/components/labs/Objective5Chat";
|
||||
import { QuantizationGridExplorer } from "~/components/labs/QuantizationGridExplorer";
|
||||
import { QuantizationExplorer } from "~/components/labs/QuantizationExplorer";
|
||||
import { TokenizerPlaygroundEmbed } from "~/components/labs/TokenizerPlaygroundEmbed";
|
||||
import {
|
||||
fetchCoursewareRuntimeConfig,
|
||||
isCoursewareServiceId,
|
||||
normalizeCoursewareRuntimeConfig,
|
||||
resolveCoursewareServiceAddress,
|
||||
resolveCoursewareServiceUrl,
|
||||
type ResolvedCoursewareRuntimeConfig,
|
||||
} from "~/lib/courseware-runtime";
|
||||
|
||||
type LabContentProps = {
|
||||
className: string;
|
||||
@@ -43,6 +51,8 @@ const lab3TerminalToken = "<div data-lab3-terminal></div>";
|
||||
const lab1ConfidenceToken = "<div data-lab1-confidence></div>";
|
||||
const lab1NetronToken = "<div data-lab1-netron-panel></div>";
|
||||
const tokenizerPlaygroundToken = "<div data-tokenizer-playground></div>";
|
||||
const serviceTokenPattern =
|
||||
/\{\{service-(url|address):([a-z0-9-]+)(?::([^}]+))?\}\}/g;
|
||||
|
||||
function looksLikeCliCommand(commandText: string, className: string) {
|
||||
if (cliLanguagePattern.test(className)) return true;
|
||||
@@ -258,9 +268,150 @@ function enhanceHarnessSelectors(root: HTMLElement) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveServiceTokenValue(
|
||||
runtimeConfig: ResolvedCoursewareRuntimeConfig,
|
||||
tokenType: string,
|
||||
serviceId: string,
|
||||
pathSuffix?: string,
|
||||
) {
|
||||
if (!isCoursewareServiceId(serviceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tokenType === "url") {
|
||||
return resolveCoursewareServiceUrl(runtimeConfig, serviceId, pathSuffix);
|
||||
}
|
||||
|
||||
if (tokenType === "address") {
|
||||
return resolveCoursewareServiceAddress(runtimeConfig, serviceId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function replaceServiceTokens(
|
||||
root: HTMLElement,
|
||||
runtimeConfig: ResolvedCoursewareRuntimeConfig,
|
||||
) {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
if (!(node instanceof Text)) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
|
||||
if (!node.nodeValue?.includes("{{service-")) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
|
||||
const parent = node.parentElement;
|
||||
if (!parent) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
|
||||
if (parent.closest("[data-widget-enhanced='true']")) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
|
||||
const textNodes: Text[] = [];
|
||||
let currentNode = walker.nextNode();
|
||||
while (currentNode) {
|
||||
if (currentNode instanceof Text) {
|
||||
textNodes.push(currentNode);
|
||||
}
|
||||
currentNode = walker.nextNode();
|
||||
}
|
||||
|
||||
for (const textNode of textNodes) {
|
||||
const parent = textNode.parentElement;
|
||||
const nodeValue = textNode.nodeValue;
|
||||
if (!parent || !nodeValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const allowLinks = !parent.closest("code, pre, a");
|
||||
const nextTextValue = nodeValue.replace(
|
||||
serviceTokenPattern,
|
||||
(fullMatch, tokenType: string, serviceId: string, pathSuffix?: string) => {
|
||||
const replacement = resolveServiceTokenValue(
|
||||
runtimeConfig,
|
||||
tokenType,
|
||||
serviceId,
|
||||
pathSuffix,
|
||||
);
|
||||
return replacement ?? fullMatch;
|
||||
},
|
||||
);
|
||||
|
||||
if (!allowLinks) {
|
||||
if (nextTextValue !== nodeValue) {
|
||||
textNode.nodeValue = nextTextValue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
serviceTokenPattern.lastIndex = 0;
|
||||
let lastIndex = 0;
|
||||
let didReplace = false;
|
||||
const fragment = document.createDocumentFragment();
|
||||
let match = serviceTokenPattern.exec(nodeValue);
|
||||
|
||||
while (match) {
|
||||
const [fullMatch, tokenType, serviceId, pathSuffix] = match;
|
||||
const replacement = resolveServiceTokenValue(
|
||||
runtimeConfig,
|
||||
tokenType,
|
||||
serviceId,
|
||||
pathSuffix,
|
||||
);
|
||||
|
||||
if (replacement === null) {
|
||||
match = serviceTokenPattern.exec(nodeValue);
|
||||
continue;
|
||||
}
|
||||
|
||||
didReplace = true;
|
||||
if (match.index > lastIndex) {
|
||||
fragment.append(nodeValue.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
if (tokenType === "url") {
|
||||
const link = document.createElement("a");
|
||||
link.href = replacement;
|
||||
link.rel = "noreferrer";
|
||||
link.target = "_blank";
|
||||
link.textContent = replacement;
|
||||
fragment.append(link);
|
||||
} else {
|
||||
fragment.append(replacement);
|
||||
}
|
||||
|
||||
lastIndex = match.index + fullMatch.length;
|
||||
match = serviceTokenPattern.exec(nodeValue);
|
||||
}
|
||||
|
||||
if (!didReplace) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastIndex < nodeValue.length) {
|
||||
fragment.append(nodeValue.slice(lastIndex));
|
||||
}
|
||||
|
||||
textNode.replaceWith(fragment);
|
||||
}
|
||||
}
|
||||
|
||||
export function LabContent({ className, html }: LabContentProps) {
|
||||
const containerRef = useRef<HTMLElement>(null);
|
||||
const [zoomedImage, setZoomedImage] = useState<ZoomedImageState | null>(null);
|
||||
const [runtimeConfig, setRuntimeConfig] = useState(() =>
|
||||
normalizeCoursewareRuntimeConfig(),
|
||||
);
|
||||
const [isRuntimeResolved, setIsRuntimeResolved] = useState(false);
|
||||
|
||||
const renderedContent = html
|
||||
.split(
|
||||
@@ -314,9 +465,33 @@ export function LabContent({ className, html }: LabContentProps) {
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
void fetchCoursewareRuntimeConfig()
|
||||
.then((nextRuntimeConfig) => {
|
||||
if (isCancelled) return;
|
||||
setRuntimeConfig(nextRuntimeConfig);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isCancelled) return;
|
||||
setRuntimeConfig(normalizeCoursewareRuntimeConfig());
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCancelled) return;
|
||||
setIsRuntimeResolved(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
if (!root || !isRuntimeResolved) return;
|
||||
|
||||
replaceServiceTokens(root, runtimeConfig);
|
||||
|
||||
const preBlocks = root.querySelectorAll<HTMLPreElement>("pre");
|
||||
for (const pre of preBlocks) {
|
||||
@@ -388,7 +563,7 @@ export function LabContent({ className, html }: LabContentProps) {
|
||||
cleanupHarnessSelectors();
|
||||
root.removeEventListener("click", handleRootClick);
|
||||
};
|
||||
}, [html]);
|
||||
}, [html, isRuntimeResolved, runtimeConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!zoomedImage) return;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
COURSEWARE_SERVICE_DEFAULTS,
|
||||
getCoursewareServiceBaseUrl,
|
||||
getCoursewareServices,
|
||||
normalizeCoursewareRuntimeConfig,
|
||||
resolveCoursewareServiceAddress,
|
||||
resolveCoursewareServiceUrl,
|
||||
} from "~/lib/courseware-runtime";
|
||||
|
||||
describe("courseware service runtime config", () => {
|
||||
it("rewrites loopback service defaults to the current hostname", () => {
|
||||
const services = getCoursewareServices(undefined, "student-lab.example");
|
||||
|
||||
expect(services["open-webui"]).toBe("http://student-lab.example:8080/");
|
||||
expect(services["promptfoo"]).toBe("http://student-lab.example:15500/");
|
||||
expect(services["chunkviz"]).toBe("http://student-lab.example:3000/");
|
||||
expect(services["embedding-atlas"]).toBe("http://student-lab.example:5055/");
|
||||
expect(services["unsloth"]).toBe("http://student-lab.example:8888/");
|
||||
expect(services["ssh"]).toBe("ssh://student-lab.example:22");
|
||||
});
|
||||
|
||||
it("preserves explicit public service URLs without rewriting them", () => {
|
||||
const services = getCoursewareServices(
|
||||
{
|
||||
"open-webui": "https://labs.example/openwebui",
|
||||
"promptfoo": "https://eval.example/promptfoo",
|
||||
"ssh": "ssh://bastion.example:2222",
|
||||
},
|
||||
"student-lab.example",
|
||||
);
|
||||
|
||||
expect(services["open-webui"]).toBe("https://labs.example/openwebui");
|
||||
expect(services["promptfoo"]).toBe("https://eval.example/promptfoo");
|
||||
expect(services["ssh"]).toBe("ssh://bastion.example:2222");
|
||||
});
|
||||
|
||||
it("resolves service URLs with appended paths and address-only displays", () => {
|
||||
const runtimeConfig = normalizeCoursewareRuntimeConfig(
|
||||
{
|
||||
services: {
|
||||
"open-webui": "https://labs.example/openwebui",
|
||||
"ssh": "ssh://bastion.example:2222",
|
||||
},
|
||||
},
|
||||
"student-lab.example",
|
||||
);
|
||||
|
||||
expect(resolveCoursewareServiceUrl(runtimeConfig, "open-webui")).toBe(
|
||||
"https://labs.example/openwebui",
|
||||
);
|
||||
expect(resolveCoursewareServiceUrl(runtimeConfig, "open-webui", "/api")).toBe(
|
||||
"https://labs.example/openwebui/api",
|
||||
);
|
||||
expect(resolveCoursewareServiceAddress(runtimeConfig, "ssh")).toBe(
|
||||
"bastion.example:2222",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the default service map when a service is omitted", () => {
|
||||
expect(getCoursewareServiceBaseUrl("open-webui", undefined, "localhost")).toBe(
|
||||
"http://localhost:8080/",
|
||||
);
|
||||
expect(COURSEWARE_SERVICE_DEFAULTS["ssh"]).toBe("ssh://127.0.0.1:22");
|
||||
});
|
||||
});
|
||||
@@ -2,31 +2,56 @@ export const COURSEWARE_RUNTIME_CONFIG_PATH = "/courseware-runtime.json";
|
||||
export const LAB1_DEFAULT_NETRON_URL = "http://127.0.0.1:8338";
|
||||
export const LAB2_DEFAULT_OLLAMA_URL = "http://127.0.0.1:11434";
|
||||
export const LAB2_DEFAULT_OLLAMA_MODELS = [
|
||||
{
|
||||
label: "Gemma 4 E2B Q2",
|
||||
value: "cajina/gemma4_e2b-q2_k_xl:v01",
|
||||
},
|
||||
{
|
||||
label: "Gemma 4 E2B Q4",
|
||||
value: "batiai/gemma4-e2b:q4",
|
||||
},
|
||||
{
|
||||
label: "Gemma 4 E2B Q8",
|
||||
value: "bjoernb/gemma4-e2b-fast:latest",
|
||||
label: "Gemma 4 E2B Q6",
|
||||
value: "batiai/gemma4-e2b:q6",
|
||||
},
|
||||
] as const;
|
||||
export const LAB3_DEFAULT_TERMINAL_PATH = "/wetty";
|
||||
export const COURSEWARE_SERVICE_IDS = [
|
||||
"open-webui",
|
||||
"promptfoo",
|
||||
"chunkviz",
|
||||
"embedding-atlas",
|
||||
"unsloth",
|
||||
"ssh",
|
||||
] as const;
|
||||
|
||||
export type CoursewareRuntimeModelOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type CoursewareServiceId = (typeof COURSEWARE_SERVICE_IDS)[number];
|
||||
|
||||
export type CoursewareRuntimeServices = Partial<
|
||||
Record<CoursewareServiceId, string>
|
||||
>;
|
||||
|
||||
export type ResolvedCoursewareRuntimeServices = Record<
|
||||
CoursewareServiceId,
|
||||
string
|
||||
>;
|
||||
|
||||
export const COURSEWARE_SERVICE_DEFAULTS: ResolvedCoursewareRuntimeServices = {
|
||||
"chunkviz": "http://127.0.0.1:3000",
|
||||
"embedding-atlas": "http://127.0.0.1:5055",
|
||||
"open-webui": "http://127.0.0.1:8080",
|
||||
"promptfoo": "http://127.0.0.1:15500",
|
||||
"ssh": "ssh://127.0.0.1:22",
|
||||
"unsloth": "http://127.0.0.1:8888",
|
||||
};
|
||||
|
||||
export type CoursewareRuntimeConfig = {
|
||||
lab1NetronUrl?: string;
|
||||
lab2OllamaModels?: CoursewareRuntimeModelOption[];
|
||||
lab2OllamaUrl?: string;
|
||||
lab3TerminalUrl?: string;
|
||||
services?: CoursewareRuntimeServices;
|
||||
};
|
||||
|
||||
export type ResolvedCoursewareRuntimeConfig = {
|
||||
@@ -34,6 +59,7 @@ export type ResolvedCoursewareRuntimeConfig = {
|
||||
lab2OllamaModels: CoursewareRuntimeModelOption[];
|
||||
lab2OllamaUrl: string;
|
||||
lab3TerminalUrl: string;
|
||||
services: ResolvedCoursewareRuntimeServices;
|
||||
};
|
||||
|
||||
const loopbackHosts = new Set(["127.0.0.1", "localhost", "::1"]);
|
||||
@@ -61,6 +87,93 @@ function getCurrentHostname() {
|
||||
return hostname || undefined;
|
||||
}
|
||||
|
||||
export function isCoursewareServiceId(
|
||||
value: string,
|
||||
): value is CoursewareServiceId {
|
||||
return (COURSEWARE_SERVICE_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function getCoursewareServiceBaseUrl(
|
||||
serviceId: CoursewareServiceId,
|
||||
envValue?: string,
|
||||
currentHostname = getCurrentHostname(),
|
||||
) {
|
||||
const trimmedValue = envValue?.trim();
|
||||
const baseUrl = trimmedValue || COURSEWARE_SERVICE_DEFAULTS[serviceId];
|
||||
return rewriteLoopbackHost(baseUrl, currentHostname);
|
||||
}
|
||||
|
||||
export function getCoursewareServices(
|
||||
envValue?: CoursewareRuntimeServices,
|
||||
currentHostname = getCurrentHostname(),
|
||||
): ResolvedCoursewareRuntimeServices {
|
||||
const services = {} as ResolvedCoursewareRuntimeServices;
|
||||
|
||||
for (const serviceId of COURSEWARE_SERVICE_IDS) {
|
||||
services[serviceId] = getCoursewareServiceBaseUrl(
|
||||
serviceId,
|
||||
envValue?.[serviceId],
|
||||
currentHostname,
|
||||
);
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
function appendUrlPath(urlValue: string, pathSuffix: string) {
|
||||
const trimmedSuffix = pathSuffix.trim();
|
||||
if (!trimmedSuffix) {
|
||||
return urlValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(urlValue);
|
||||
const normalizedSuffix = trimmedSuffix.startsWith("/")
|
||||
? trimmedSuffix
|
||||
: `/${trimmedSuffix}`;
|
||||
const basePath = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
|
||||
|
||||
url.pathname = `${basePath}${normalizedSuffix}`;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return urlValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCoursewareServiceBaseUrl(
|
||||
runtimeConfig: Pick<ResolvedCoursewareRuntimeConfig, "services">,
|
||||
serviceId: CoursewareServiceId,
|
||||
) {
|
||||
return runtimeConfig.services[serviceId];
|
||||
}
|
||||
|
||||
export function resolveCoursewareServiceUrl(
|
||||
runtimeConfig: Pick<ResolvedCoursewareRuntimeConfig, "services">,
|
||||
serviceId: CoursewareServiceId,
|
||||
pathSuffix?: string,
|
||||
) {
|
||||
const baseUrl = resolveCoursewareServiceBaseUrl(runtimeConfig, serviceId);
|
||||
if (!pathSuffix?.trim()) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
return appendUrlPath(baseUrl, pathSuffix);
|
||||
}
|
||||
|
||||
export function resolveCoursewareServiceAddress(
|
||||
runtimeConfig: Pick<ResolvedCoursewareRuntimeConfig, "services">,
|
||||
serviceId: CoursewareServiceId,
|
||||
) {
|
||||
const baseUrl = resolveCoursewareServiceBaseUrl(runtimeConfig, serviceId);
|
||||
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
return url.port ? `${url.hostname}:${url.port}` : url.hostname;
|
||||
} catch {
|
||||
return baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLab1NetronUrl(
|
||||
envValue?: string,
|
||||
currentHostname = getCurrentHostname(),
|
||||
@@ -145,6 +258,7 @@ export function normalizeCoursewareRuntimeConfig(
|
||||
config?.lab3TerminalUrl,
|
||||
currentHostname,
|
||||
),
|
||||
services: getCoursewareServices(config?.services, currentHostname),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user