Bump to 0.1.0:1 — portability + endpoint display

- configureSparks.ts: generic placeholders (e.g. 192.168.1.10), no Alice-specific IPs; descriptions explain the role of each node instead of naming his hardware
- showPublicKey.ts: reads sparkConfig.yaml; emits a ready-to-paste one-liner (KEY='...' followed by 'ssh user@host "echo $KEY >> authorized_keys"' for each configured Spark). Falls back to generic instructions if Configure Sparks hasn't been run yet.
- /api/status now includes vllm.base_url for the OpenAI endpoint
- New endpoint panel in UI: base URL + model ID rows with copy buttons + collapsible curl example
- Bump version to 0.1.0:1
This commit is contained in:
Grant
2026-05-12 10:38:18 -05:00
parent 87334f85f0
commit 0ddab99468
7 changed files with 202 additions and 22 deletions
+13 -3
View File
@@ -7,16 +7,26 @@ _TIMEOUT = 3.0
async def check_vllm(settings: Settings) -> dict: async def check_vllm(settings: Settings) -> dict:
base_url = (
f"http://{settings.spark1_host}:{settings.vllm_port}/v1"
if settings.spark1_host
else None
)
if not settings.spark1_host: if not settings.spark1_host:
return {"ok": False, "error": "spark1 not configured"} return {"ok": False, "error": "spark1 not configured", "base_url": base_url}
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as c: async with httpx.AsyncClient(timeout=_TIMEOUT) as c:
r = await c.get(f"http://{settings.spark1_host}:{settings.vllm_port}/v1/models") r = await c.get(f"http://{settings.spark1_host}:{settings.vllm_port}/v1/models")
r.raise_for_status() r.raise_for_status()
ids = [m["id"] for m in r.json().get("data", [])] ids = [m["id"] for m in r.json().get("data", [])]
return {"ok": True, "current_model": ids[0] if ids else None, "all": ids} return {
"ok": True,
"current_model": ids[0] if ids else None,
"all": ids,
"base_url": base_url,
}
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} return {"ok": False, "error": str(e), "base_url": base_url}
async def check_parakeet(settings: Settings) -> dict: async def check_parakeet(settings: Settings) -> dict:
+48
View File
@@ -83,6 +83,52 @@ function renderCurrent(status) {
c.innerHTML = `<strong>${label}</strong>`; c.innerHTML = `<strong>${label}</strong>`;
} }
function renderEndpoint(status) {
const v = status.vllm || {};
const panel = el('#endpoint-panel');
const ready = v.ok && v.current_model && v.base_url;
panel.classList.toggle('hidden', !ready);
if (!ready) return;
el('#ep-url').textContent = v.base_url;
el('#ep-model').textContent = v.current_model;
const snippet =
`curl -s ${v.base_url}/chat/completions \\
-H 'content-type: application/json' \\
-d '{
"model": "${v.current_model}",
"messages": [{"role": "user", "content": "Hello"}]
}'`;
el('#ep-curl-snippet').textContent = snippet;
}
function setupCopyButtons() {
document.body.addEventListener('click', async (e) => {
const btn = e.target.closest('.copy-btn');
if (!btn) return;
const targetSel = btn.dataset.copy;
if (!targetSel) return;
const target = el(targetSel);
if (!target) return;
const text = target.textContent;
try {
await navigator.clipboard.writeText(text);
const original = btn.textContent;
btn.classList.add('copied');
btn.textContent = 'Copied';
setTimeout(() => {
btn.classList.remove('copied');
btn.textContent = original;
}, 1400);
} catch {
// Clipboard API may fail over plain HTTP; fall back to selection
const range = document.createRange();
range.selectNode(target);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
}
});
}
function renderHealth(status) { function renderHealth(status) {
function setDot(id, ok, payload) { function setDot(id, ok, payload) {
const item = el(id); const item = el(id);
@@ -221,6 +267,7 @@ async function pollStatus() {
state.configured = status.configured; state.configured = status.configured;
renderBanner(status); renderBanner(status);
renderCurrent(status); renderCurrent(status);
renderEndpoint(status);
renderHealth(status); renderHealth(status);
if (status.current_swap_job && status.current_swap_job !== state.swap_job_id) { if (status.current_swap_job && status.current_swap_job !== state.swap_job_id) {
attachToSwap(status.current_swap_job, /*needsBackfill=*/true); attachToSwap(status.current_swap_job, /*needsBackfill=*/true);
@@ -342,6 +389,7 @@ function appendLog(line) {
} }
async function init() { async function init() {
setupCopyButtons();
await loadModels(); await loadModels();
await pollStatus(); await pollStatus();
setInterval(pollStatus, 5000); setInterval(pollStatus, 5000);
+19
View File
@@ -24,6 +24,25 @@
<span>Run the <em>Configure Sparks</em> action in StartOS to set hostnames, then run <em>Test Connection</em>.</span> <span>Run the <em>Configure Sparks</em> action in StartOS to set hostnames, then run <em>Test Connection</em>.</span>
</section> </section>
<section id="endpoint-panel" class="endpoint-panel hidden">
<div class="ep-title muted small">OpenAI-compatible endpoint</div>
<div class="ep-row">
<span class="ep-label">Base URL</span>
<code class="ep-value" id="ep-url"></code>
<button class="copy-btn" data-copy="#ep-url" title="Copy base URL">Copy</button>
</div>
<div class="ep-row">
<span class="ep-label">Model ID</span>
<code class="ep-value" id="ep-model"></code>
<button class="copy-btn" data-copy="#ep-model" title="Copy model ID">Copy</button>
</div>
<details class="ep-curl">
<summary class="muted small">curl example</summary>
<pre id="ep-curl-snippet" class="snippet"></pre>
<button class="copy-btn small" data-copy="#ep-curl-snippet">Copy snippet</button>
</details>
</section>
<section id="swap-panel" class="swap-panel hidden"> <section id="swap-panel" class="swap-panel hidden">
<div class="swap-header"> <div class="swap-header">
<span class="spinner"></span> <span class="spinner"></span>
+71
View File
@@ -63,6 +63,77 @@ main {
} }
.banner em { font-style: normal; background: rgba(245, 158, 11, 0.15); padding: 2px 6px; border-radius: 4px; } .banner em { font-style: normal; background: rgba(245, 158, 11, 0.15); padding: 2px 6px; border-radius: 4px; }
/* ===== Endpoint panel ===== */
.endpoint-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
margin-bottom: 16px;
}
.ep-title { margin-bottom: 8px; letter-spacing: 0.05em; text-transform: uppercase; }
.ep-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
}
.ep-row + .ep-row { border-top: 1px solid var(--border); }
.ep-label {
color: var(--muted);
font-size: 12px;
min-width: 78px;
flex-shrink: 0;
}
.ep-value {
flex: 1;
font: 13px/1.4 ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
background: var(--surface-2);
padding: 4px 8px;
border-radius: 5px;
border: 1px solid var(--border);
color: var(--text);
overflow-x: auto;
white-space: nowrap;
}
.copy-btn {
appearance: none;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--muted);
padding: 4px 10px;
border-radius: 5px;
font: 12px/1 inherit;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
flex-shrink: 0;
}
.copy-btn:hover { color: var(--text); border-color: #34343c; }
.copy-btn.copied {
color: var(--accent);
border-color: rgba(74, 222, 128, 0.4);
background: rgba(74, 222, 128, 0.08);
}
.copy-btn.small { padding: 3px 8px; font-size: 11px; }
.ep-curl { margin-top: 8px; }
.ep-curl summary { cursor: pointer; padding: 4px 0; }
.ep-curl[open] summary { margin-bottom: 6px; }
.snippet {
background: #08080b;
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
margin: 0 0 8px;
font: 12px/1.55 ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
color: #c7c7d1;
max-height: 200px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* ===== Swap panel ===== */ /* ===== Swap panel ===== */
.swap-panel { .swap-panel {
+10 -6
View File
@@ -6,15 +6,17 @@ const { InputSpec, Value } = sdk
const inputSpec = InputSpec.of({ const inputSpec = InputSpec.of({
spark1_host: Value.text({ spark1_host: Value.text({
name: 'Spark 1 hostname or IP', name: 'Spark 1 hostname or IP',
description: 'Head node. Example: <spark-1-ip>', description:
'The head node of your DGX Spark cluster — the one that has ~/spark-vllm-docker cloned and runs the vLLM container. Enter its LAN IP (recommended) or hostname.',
required: true, required: true,
default: null, default: null,
placeholder: '<spark-1-ip>', placeholder: 'e.g. 192.168.1.10',
masked: false, masked: false,
}), }),
spark1_user: Value.text({ spark1_user: Value.text({
name: 'Spark 1 SSH user', name: 'Spark 1 SSH user',
description: 'Usually "<spark-user>".', description:
'The user account on Spark 1 to SSH in as. DGX Sparks ship with "<spark-user>" as the default user — change only if you customized yours.',
required: true, required: true,
default: '<spark-user>', default: '<spark-user>',
placeholder: '<spark-user>', placeholder: '<spark-user>',
@@ -22,15 +24,17 @@ const inputSpec = InputSpec.of({
}), }),
spark2_host: Value.text({ spark2_host: Value.text({
name: 'Spark 2 hostname or IP', name: 'Spark 2 hostname or IP',
description: 'Worker node. Example: <spark-2-ip>', description:
'The worker node of your DGX Spark cluster (also runs always-on services like Parakeet/Magpie). Enter its LAN IP or hostname.',
required: true, required: true,
default: null, default: null,
placeholder: '<spark-2-ip>', placeholder: 'e.g. 192.168.1.11',
masked: false, masked: false,
}), }),
spark2_user: Value.text({ spark2_user: Value.text({
name: 'Spark 2 SSH user', name: 'Spark 2 SSH user',
description: 'Usually "<spark-user>".', description:
'The user account on Spark 2 to SSH in as. Usually the same as Spark 1.',
required: true, required: true,
default: '<spark-user>', default: '<spark-user>',
placeholder: '<spark-user>', placeholder: '<spark-user>',
+38 -11
View File
@@ -1,22 +1,21 @@
import { sdk } from '../sdk' import { sdk } from '../sdk'
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import * as path from 'path' import * as path from 'path'
import { sparkConfigYaml } from '../fileModels/sparkConfig.yaml'
export const showPublicKey = sdk.Action.withoutInput( export const showPublicKey = sdk.Action.withoutInput(
'show-public-key', 'show-public-key',
async () => ({ async () => ({
name: 'Show Public Key', name: 'Show Public Key',
description: description:
'Display the SSH public key. Paste it into ~/.ssh/authorized_keys on each Spark to grant access.', 'Display the SSH public key and a ready-to-paste install command for granting this package SSH access to your Sparks.',
warning: null, warning: null,
visibility: 'enabled', visibility: 'enabled',
allowedStatuses: 'any', allowedStatuses: 'any',
group: null, group: null,
}), }),
async () => { async ({ effects }) => {
// The container generates the key under /data/ssh/id_ed25519.pub on first boot. // The container generates the key under /data/ssh/id_ed25519.pub on first boot.
// The volume "main" is mounted at the host path that StartOS exposes via `sdk.volumes.main`.
// For an Action running in the host, we read the file directly through the volume path.
const pubKeyPath = path.join( const pubKeyPath = path.join(
sdk.volumes.main.path, sdk.volumes.main.path,
'ssh', 'ssh',
@@ -28,22 +27,50 @@ export const showPublicKey = sdk.Action.withoutInput(
} catch (e) { } catch (e) {
return { return {
version: '1' as const, version: '1' as const,
title: 'Public Key Not Found', title: 'Public Key Not Ready',
message: message:
'The container has not yet generated its SSH keypair. Start the service, wait a few seconds, and try again.', 'The service has not generated its SSH keypair yet. Start the service from the dashboard, wait a few seconds, then run this action again.',
result: null, result: null,
} }
} }
// If hosts are configured, construct a ready-to-paste one-liner.
const cfg = await sparkConfigYaml.read().once()
const hosts: Array<{ user: string; host: string }> = []
if (cfg) {
if (cfg.spark1_host) hosts.push({ user: cfg.spark1_user || '<spark-user>', host: cfg.spark1_host })
if (cfg.spark2_host) hosts.push({ user: cfg.spark2_user || '<spark-user>', host: cfg.spark2_host })
}
let message: string
if (hosts.length > 0) {
const sshLines = hosts
.map(
(h) =>
`ssh ${h.user}@${h.host} "mkdir -p ~/.ssh && echo '$KEY' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"`,
)
.join('\n')
const oneLiner = `KEY='${key}'\n${sshLines}`
message =
'Paste this block into any machine that already has SSH access to your Sparks (your laptop or one of the Sparks itself). It grants this Spark Control service permission to log in.\n\n' +
oneLiner +
'\n\nYou will be prompted for the SSH password of each Spark once. After both commands succeed, run "Test Connection" or open the Web Interface to verify.'
} else {
message =
'Run the "Configure Sparks" action first, then come back to this one — once your Spark hostnames are saved, this message will include a ready-to-paste install command.\n\nFor reference, the raw public key is:\n\n' +
key
}
return { return {
version: '1' as const, version: '1' as const,
title: 'SSH Public Key', title: 'SSH Public Key',
message: message,
'Append this single line to ~/.ssh/authorized_keys on EACH Spark (<spark-user> user):\n\n' +
key,
result: { result: {
type: 'single' as const, type: 'single' as const,
name: 'Public Key', name: 'Raw Public Key',
description: 'Copy this line to each Spark.', description:
'The bare public key line. Copy the full message above for the ready-to-paste install command.',
value: key, value: key,
masked: false, masked: false,
copyable: true, copyable: true,
+3 -2
View File
@@ -1,9 +1,10 @@
import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk'
export const v0_1_0 = VersionInfo.of({ export const v0_1_0 = VersionInfo.of({
version: '0.1.0:0', version: '0.1.0:1',
releaseNotes: { releaseNotes: {
en_US: 'Initial release: swap UI, status, health for Parakeet/Magpie.', en_US:
'Generic placeholders in Configure Sparks; Show Public Key emits a ready-to-paste install command; dashboard shows the OpenAI-compatible base URL + current model ID with one-click copy and a curl example.',
}, },
migrations: { migrations: {
up: async ({ effects }) => {}, up: async ({ effects }) => {},