v0.27.0:0 - in-app settings gear + swap-lock route fix
Move the ~20 optional cluster knobs out of the StartOS "Configure Sparks"
action (now just the 4 required fields) and into a dashboard ⚙ Settings gear,
backed by a /data/app_settings.json overlay keyed by env-var names. One shared
mutable Settings instance + Settings.reload() applies edits live without a
restart; existing installs' values migrate automatically on first boot.
Also: support-service ports (parakeet/kokoro/embed/qdrant + vllm) are now
configurable, and GET /api/swap/lock no longer 404s (it was shadowed by the
/api/swap/{job_id} catch-all). WebhookNotifier is re-pointed on save so its
url/secret reload live too.
This commit is contained in:
@@ -3,6 +3,15 @@ import { sparkConfigYaml } from '../fileModels/sparkConfig.yaml'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
// This action is intentionally minimal: just the required wiring needed before
|
||||
// Spark Control can do anything — the two Spark node addresses and SSH users.
|
||||
// Every other knob (vLLM/service ports, container names, support-service hosts,
|
||||
// integrations, webhooks) now lives behind the ⚙ Settings gear in the dashboard
|
||||
// itself, which is where StartOS 0.4 expects routine config to live (and most
|
||||
// operators never open StartOS actions). The optional keys still exist in the
|
||||
// config.yaml schema (set by older versions); they're read into env at launch
|
||||
// and migrated into the in-app settings overlay on first boot, so nothing is
|
||||
// lost on upgrade — they're simply edited in the dashboard from now on.
|
||||
const inputSpec = InputSpec.of({
|
||||
spark1_host: Value.text({
|
||||
name: 'Spark 1 hostname or IP',
|
||||
@@ -40,164 +49,14 @@ const inputSpec = InputSpec.of({
|
||||
placeholder: 'your SSH username',
|
||||
masked: false,
|
||||
}),
|
||||
vllm_port: Value.text({
|
||||
name: 'vLLM port (optional)',
|
||||
description:
|
||||
"The port your vLLM server listens on, on Spark 1 — used by the health check and the chat proxy. Leave blank to use 8888, which is what the bundled launch-cluster.sh wrapper uses. Set this to 8000 (vLLM's own default) or another port if your vLLM listens elsewhere.",
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'leave blank for 8888',
|
||||
masked: false,
|
||||
}),
|
||||
vllm_container: Value.text({
|
||||
name: 'vLLM container name (optional)',
|
||||
description:
|
||||
'Docker container name for the swappable vLLM on Spark 1. Defaults to "vllm_node" (what the bundled launch-cluster.sh creates). Change this only if you run your vLLM under a different container name — the model-swap log view and the pre-flight validator exec into it by name.',
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'leave blank for vllm_node',
|
||||
masked: false,
|
||||
}),
|
||||
disabled_services: Value.text({
|
||||
name: 'Services to hide (optional)',
|
||||
description:
|
||||
"Comma-separated list of built-in services your cluster doesn't run, so Spark Control hides their tiles and stops probing them. Valid names: parakeet, kokoro, embeddings, qdrant. Example: if you only run vLLM, set this to 'parakeet,kokoro,embeddings,qdrant'. Leave blank to monitor all of them. (Useful when, say, your vLLM shares port 8000 with Parakeet's default — hide Parakeet so its probe doesn't hit vLLM.)",
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'e.g. parakeet,kokoro',
|
||||
masked: false,
|
||||
}),
|
||||
parakeet_host: Value.text({
|
||||
name: 'Parakeet host (optional)',
|
||||
description:
|
||||
"Override the host running the Parakeet STT container. Leave blank if Parakeet runs on Spark 2 — that's the default. Set this if you run Parakeet on Spark 1 or a different machine.",
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'leave blank to use Spark 2',
|
||||
masked: false,
|
||||
}),
|
||||
parakeet_container: Value.text({
|
||||
name: 'Parakeet container name (optional)',
|
||||
description:
|
||||
'Docker container name for Parakeet. Defaults to "parakeet-asr" — change only if you named yours something else.',
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'parakeet-asr',
|
||||
masked: false,
|
||||
}),
|
||||
kokoro_host: Value.text({
|
||||
name: 'Kokoro host (optional)',
|
||||
description:
|
||||
'Override the host running the Kokoro TTS container. Leave blank if Kokoro runs on Spark 2.',
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'leave blank to use Spark 2',
|
||||
masked: false,
|
||||
}),
|
||||
kokoro_container: Value.text({
|
||||
name: 'Kokoro container name (optional)',
|
||||
description: 'Docker container name for Kokoro. Defaults to "kokoro-tts".',
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'kokoro-tts',
|
||||
masked: false,
|
||||
}),
|
||||
embed_host: Value.text({
|
||||
name: 'Embedding server host (optional)',
|
||||
description:
|
||||
'Override the host running the spark-embed container (bge-m3 dense embeddings + reranker). Leave blank if it runs on Spark 2.',
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'leave blank to use Spark 2',
|
||||
masked: false,
|
||||
}),
|
||||
embed_container: Value.text({
|
||||
name: 'Embedding container name (optional)',
|
||||
description:
|
||||
'Docker container name for the embedding server. Defaults to "spark-embed".',
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'spark-embed',
|
||||
masked: false,
|
||||
}),
|
||||
qdrant_host: Value.text({
|
||||
name: 'Qdrant host (optional)',
|
||||
description:
|
||||
'Override the host running the Qdrant vector database. Leave blank if it runs on Spark 2.',
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'leave blank to use Spark 2',
|
||||
masked: false,
|
||||
}),
|
||||
qdrant_container: Value.text({
|
||||
name: 'Qdrant container name (optional)',
|
||||
description: 'Docker container name for Qdrant. Defaults to "qdrant".',
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'qdrant',
|
||||
masked: false,
|
||||
}),
|
||||
qdrant_collection: Value.text({
|
||||
name: 'Default Qdrant collection (optional)',
|
||||
description:
|
||||
'Default collection name used by /api/search when a request does not specify one. Leave blank to require callers to pass a collection.',
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'e.g. crm_chunks',
|
||||
masked: false,
|
||||
}),
|
||||
matrix_bridge_user: Value.text({
|
||||
name: 'matrix-bridge bot SSH user (optional)',
|
||||
description:
|
||||
"If you run the matrix-bridge Matrix bot on Spark 2, enter the SSH user that owns its ~/matrix-bridge folder (e.g. 'modelo'). Spark Control then shows a tile to update, restart, and view logs for the bot. Leave blank if you don't run the bot — the tile stays hidden. Note: this package's SSH public key must be authorized for that user (Show Public Key action) unless it's the same as your Spark 2 user.",
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'e.g. modelo',
|
||||
masked: false,
|
||||
}),
|
||||
open_webui_url: Value.text({
|
||||
name: 'Open WebUI URL (optional)',
|
||||
description:
|
||||
'If you also run Open WebUI on your LAN, paste its URL here. Spark Control will then show a one-click "Open chat" button next to the current model so you can jump straight to it.',
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'e.g. https://open-webui.yourserver.local',
|
||||
masked: false,
|
||||
}),
|
||||
ngc_api_key: Value.text({
|
||||
name: 'NGC API key (optional)',
|
||||
description:
|
||||
'NVIDIA NGC personal API key — needed to install NIM containers (Parakeet, etc.) from nvcr.io. Get one free at https://ngc.nvidia.com/setup/personal-key. Stored only on this Start9 server; passed to docker as the NGC_API_KEY env var when installing NIM services. (Kokoro TTS is Apache 2.0 and does not need an NGC key.)',
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'starts with "nvapi-..."',
|
||||
masked: true,
|
||||
}),
|
||||
swap_webhook_url: Value.text({
|
||||
name: 'Swap webhook URL (optional)',
|
||||
description:
|
||||
'If you run automation that needs to know when the loaded model changes, paste a URL here. Spark Control POSTs a small JSON event (swap_complete / swap_failed) to it after every model swap, so the consumer can re-point its config to the new model. Leave blank to disable. Only needed if something other than this dashboard cares about swaps.',
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'e.g. https://my-service.local/spark-swap',
|
||||
masked: false,
|
||||
}),
|
||||
swap_webhook_secret: Value.text({
|
||||
name: 'Swap webhook secret (optional)',
|
||||
description:
|
||||
'Optional shared secret. If set, each webhook is signed with an "X-Spark-Signature: sha256=…" header (HMAC of the body) so the receiver can verify it really came from Spark Control. Leave blank to send the webhook unsigned.',
|
||||
required: false,
|
||||
default: null,
|
||||
placeholder: 'a random string the receiver also knows',
|
||||
masked: true,
|
||||
}),
|
||||
})
|
||||
|
||||
export const configureSparks = sdk.Action.withInput(
|
||||
'configure-sparks',
|
||||
async () => ({
|
||||
name: 'Configure Sparks',
|
||||
description: 'Set the hostnames and SSH users for your two Spark nodes.',
|
||||
description:
|
||||
'Set your two Spark node addresses and SSH users — the required wiring. Everything else (ports, container names, support services, integrations) is configured under ⚙ Settings in the Spark Control dashboard.',
|
||||
warning: null,
|
||||
visibility: 'enabled',
|
||||
allowedStatuses: 'any',
|
||||
@@ -205,11 +64,19 @@ export const configureSparks = sdk.Action.withInput(
|
||||
}),
|
||||
async () => inputSpec,
|
||||
async ({ effects }) => {
|
||||
// Prefill from the saved config, but only the keys this (trimmed) form owns.
|
||||
const cfg = await sparkConfigYaml.read().once()
|
||||
return cfg ?? null
|
||||
if (!cfg) return null
|
||||
return {
|
||||
spark1_host: cfg.spark1_host,
|
||||
spark1_user: cfg.spark1_user,
|
||||
spark2_host: cfg.spark2_host,
|
||||
spark2_user: cfg.spark2_user,
|
||||
}
|
||||
},
|
||||
async ({ effects, input }) => {
|
||||
// Optional fields come through as `null`; coerce to empty string for the schema.
|
||||
// merge() only touches the four keys we submit, leaving any legacy optional
|
||||
// values already in config.yaml intact.
|
||||
const normalized = Object.fromEntries(
|
||||
Object.entries(input).map(([k, v]) => [k, v ?? '']),
|
||||
) as Record<string, string>
|
||||
|
||||
Reference in New Issue
Block a user