updateRow(i, 'service', e.target.value)}
placeholder="service"
+ list={services.length > 0 ? serviceListId : undefined}
spellCheck={false}
autoComplete="off"
aria-label={`Service for device ${i + 1}`}
@@ -156,65 +174,96 @@ export function isDeviceRowBlank(row: DeviceMappingRow): boolean {
}
export interface GpuServiceEditorProps {
- /** DRAFT rows including the trailing blank row — same contract as DeviceMappingEditor. */
+ /**
+ * Services the stack is known to have, from its deployed containers. Empty for a stack that has
+ * never been deployed (or whose containers are gone), which is why the "other service" row below
+ * always exists — the setting has to be configurable before the first deploy.
+ */
+ services: string[]
+ /** The selected service names. */
value: string[]
- onChange: (rows: string[]) => void
+ onChange: (next: string[]) => void
className?: string
}
/**
- * Controlled editor for the services that receive the host's GPUs (ADR-0031). One column of
- * compose service names; the actual devices are resolved by probing the host on every deploy, so
- * there is nothing else to configure. To persist, drop blank rows.
+ * Controlled picker for the services that receive the host's GPUs (ADR-0031). A toggle per known
+ * service rather than a typed name: Watchtower knows the stack's services, and the devices
+ * themselves are not a choice — the deploy probes the host and maps whatever mappable GPUs it
+ * finds, so "which services" is the only question this control can ask.
+ *
+ * A selected service the engine does not currently report still gets a row, marked as such: it may
+ * be profile-gated or simply not deployed yet, and silently dropping it would erase a stored
+ * setting the operator cannot see.
*/
-export function GpuServiceEditor({ value, onChange, className }: GpuServiceEditorProps) {
- function updateRow(i: number, val: string) {
- const next = value.map((r, idx) => (idx === i ? val : r))
- if (next.at(-1)?.trim() !== '') next.push('')
- onChange(next)
+export function GpuServiceEditor({ value, services, onChange, className }: GpuServiceEditorProps) {
+ const [extra, setExtra] = useState('')
+ const selected = new Set(value)
+ const known = new Set(services)
+ const rows = [...services, ...value.filter((s) => !known.has(s)).sort()]
+
+ function toggle(service: string, on: boolean) {
+ onChange(on ? [...value, service] : value.filter((s) => s !== service))
}
- function removeRow(i: number) {
- const next = value.filter((_, idx) => idx !== i)
- if (next.length === 0 || next.at(-1)?.trim() !== '') next.push('')
- onChange(next)
+ function addExtra() {
+ const service = extra.trim()
+ if (service === '' || selected.has(service)) {
+ setExtra('')
+ return
+ }
+ onChange([...value, service])
+ setExtra('')
}
return (
+ setExtra(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ // The editor lives inside the Settings form; Enter here means "add", not "save".
+ e.preventDefault()
+ addExtra()
+ }
+ }}
+ placeholder={rows.length === 0 ? 'service name' : 'another service…'}
+ spellCheck={false}
+ autoComplete="off"
+ aria-label="Add a service for GPU passthrough"
+ className="w-full rounded bg-surface-2 px-3 py-1.5 font-mono text-[13px] text-text outline-none placeholder:text-text-3 focus-visible:shadow-[var(--sh-focus)]"
+ />
+
+
)
}
diff --git a/src/watchtower-web/src/lib/api.ts b/src/watchtower-web/src/lib/api.ts
index 4f65eb4..689de2a 100644
--- a/src/watchtower-web/src/lib/api.ts
+++ b/src/watchtower-web/src/lib/api.ts
@@ -342,6 +342,9 @@ export const api = {
gpuServices,
})) as StackDevices,
hostGpus: async () => (await rpc('stacks.hostGpus', {})) as HostGpus,
+ services: async (id: number) =>
+ (await rpc('stacks.services', { stackId: id })).services as string[],
+
checkUpdates: async (id: number) => (await rpc('stacks.checkUpdates', { id })).stack as Stack,
/**
diff --git a/src/watchtower-web/src/modules/stacks/SettingsTab.tsx b/src/watchtower-web/src/modules/stacks/SettingsTab.tsx
index c01e04b..0b07420 100644
--- a/src/watchtower-web/src/modules/stacks/SettingsTab.tsx
+++ b/src/watchtower-web/src/modules/stacks/SettingsTab.tsx
@@ -52,6 +52,13 @@ export function SettingsTab({ stack }: { stack: Stack }) {
queryFn: () => api.stacks.getDevices(stackId),
})
+ // The stack's own compose services, so both device editors offer them instead of asking for a
+ // name from memory. Empty for a never-deployed stack, which both editors handle.
+ const servicesQuery = useQuery({
+ queryKey: ['stacks', stackId, 'services'],
+ queryFn: () => api.stacks.services(stackId),
+ })
+
// Host-wide, not per stack — what "map host GPU(s)" would resolve to on this Docker host.
const hostGpusQuery = useQuery({
queryKey: ['host', 'gpus'],
@@ -103,7 +110,7 @@ export function SettingsTab({ stack }: { stack: Stack }) {
]
const [gpuDraft, setGpuDraft] = useState(null)
- const gpuRows: string[] = gpuDraft ?? [...(devicesQuery.data?.gpuServices ?? []), '']
+ const gpuRows: string[] = gpuDraft ?? (devicesQuery.data?.gpuServices ?? [])
const [confirmDelete, setConfirmDelete] = useState(false)
@@ -459,8 +466,17 @@ export function SettingsTab({ stack }: { stack: Stack }) {
<>
{/* GPU passthrough (ADR-0031): a host-neutral intent — the deploy probes the host and
maps whatever mappable render nodes exist, plus their owning groups. */}
-
GPU passthrough
-
+
GPU passthrough
+
+ Turn this on for the services that should see the host’s GPUs. The devices
+ themselves aren’t a choice — every deploy probes this host and maps what it
+ finds.
+