diff --git a/image/app/health.py b/image/app/health.py index 16ab92a..698f623 100644 --- a/image/app/health.py +++ b/image/app/health.py @@ -7,16 +7,26 @@ _TIMEOUT = 3.0 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: - return {"ok": False, "error": "spark1 not configured"} + return {"ok": False, "error": "spark1 not configured", "base_url": base_url} try: async with httpx.AsyncClient(timeout=_TIMEOUT) as c: r = await c.get(f"http://{settings.spark1_host}:{settings.vllm_port}/v1/models") r.raise_for_status() 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: - return {"ok": False, "error": str(e)} + return {"ok": False, "error": str(e), "base_url": base_url} async def check_parakeet(settings: Settings) -> dict: diff --git a/image/app/static/app.js b/image/app/static/app.js index 2a2fa49..427c34e 100644 --- a/image/app/static/app.js +++ b/image/app/static/app.js @@ -83,6 +83,52 @@ function renderCurrent(status) { c.innerHTML = `${label}`; } +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 setDot(id, ok, payload) { const item = el(id); @@ -221,6 +267,7 @@ async function pollStatus() { state.configured = status.configured; renderBanner(status); renderCurrent(status); + renderEndpoint(status); renderHealth(status); if (status.current_swap_job && status.current_swap_job !== state.swap_job_id) { attachToSwap(status.current_swap_job, /*needsBackfill=*/true); @@ -342,6 +389,7 @@ function appendLog(line) { } async function init() { + setupCopyButtons(); await loadModels(); await pollStatus(); setInterval(pollStatus, 5000); diff --git a/image/app/static/index.html b/image/app/static/index.html index 7710ee4..98c4190 100644 --- a/image/app/static/index.html +++ b/image/app/static/index.html @@ -24,6 +24,25 @@ Run the Configure Sparks action in StartOS to set hostnames, then run Test Connection. + + OpenAI-compatible endpoint + + Base URL + — + Copy + + + Model ID + — + Copy + + + curl example + + Copy snippet + + + diff --git a/image/app/static/style.css b/image/app/static/style.css index ddf57b2..3bc426a 100644 --- a/image/app/static/style.css +++ b/image/app/static/style.css @@ -63,6 +63,77 @@ main { } .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 { diff --git a/package/startos/actions/configureSparks.ts b/package/startos/actions/configureSparks.ts index 3f58bd9..8f046fd 100644 --- a/package/startos/actions/configureSparks.ts +++ b/package/startos/actions/configureSparks.ts @@ -6,15 +6,17 @@ const { InputSpec, Value } = sdk const inputSpec = InputSpec.of({ spark1_host: Value.text({ name: 'Spark 1 hostname or IP', - description: 'Head node. Example: ', + 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, default: null, - placeholder: '', + placeholder: 'e.g. 192.168.1.10', masked: false, }), spark1_user: Value.text({ name: 'Spark 1 SSH user', - description: 'Usually "".', + description: + 'The user account on Spark 1 to SSH in as. DGX Sparks ship with "" as the default user — change only if you customized yours.', required: true, default: '', placeholder: '', @@ -22,15 +24,17 @@ const inputSpec = InputSpec.of({ }), spark2_host: Value.text({ name: 'Spark 2 hostname or IP', - description: 'Worker node. Example: ', + 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, default: null, - placeholder: '', + placeholder: 'e.g. 192.168.1.11', masked: false, }), spark2_user: Value.text({ name: 'Spark 2 SSH user', - description: 'Usually "".', + description: + 'The user account on Spark 2 to SSH in as. Usually the same as Spark 1.', required: true, default: '', placeholder: '', diff --git a/package/startos/actions/showPublicKey.ts b/package/startos/actions/showPublicKey.ts index 6427006..10dbc74 100644 --- a/package/startos/actions/showPublicKey.ts +++ b/package/startos/actions/showPublicKey.ts @@ -1,22 +1,21 @@ import { sdk } from '../sdk' import { promises as fs } from 'fs' import * as path from 'path' +import { sparkConfigYaml } from '../fileModels/sparkConfig.yaml' export const showPublicKey = sdk.Action.withoutInput( 'show-public-key', async () => ({ name: 'Show Public Key', 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, visibility: 'enabled', allowedStatuses: 'any', group: null, }), - async () => { + async ({ effects }) => { // 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( sdk.volumes.main.path, 'ssh', @@ -28,22 +27,50 @@ export const showPublicKey = sdk.Action.withoutInput( } catch (e) { return { version: '1' as const, - title: 'Public Key Not Found', + title: 'Public Key Not Ready', 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, } } + + // 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 || '', host: cfg.spark1_host }) + if (cfg.spark2_host) hosts.push({ user: cfg.spark2_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 { version: '1' as const, title: 'SSH Public Key', - message: - 'Append this single line to ~/.ssh/authorized_keys on EACH Spark ( user):\n\n' + - key, + message, result: { type: 'single' as const, - name: 'Public Key', - description: 'Copy this line to each Spark.', + name: 'Raw Public Key', + description: + 'The bare public key line. Copy the full message above for the ready-to-paste install command.', value: key, masked: false, copyable: true, diff --git a/package/startos/versions/v0_1_0.ts b/package/startos/versions/v0_1_0.ts index c3fa04a..d8bf50a 100644 --- a/package/startos/versions/v0_1_0.ts +++ b/package/startos/versions/v0_1_0.ts @@ -1,9 +1,10 @@ import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' export const v0_1_0 = VersionInfo.of({ - version: '0.1.0:0', + version: '0.1.0:1', 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: { up: async ({ effects }) => {},
—