From e8e76511b903972ec864c086e53eabaf89cb02f8 Mon Sep 17 00:00:00 2001 From: Simon Wimmesberger Date: Sat, 29 Aug 2026 12:57:34 +0200 Subject: [PATCH] feat(stacks): pick GPU services from the stack's own services, not free text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GPU control asked for a service name in a free-text field sitting under a "GPU passthrough" heading, so it read as "type a GPU here" — and even read correctly, it asked an operator to recall a compose service name. Watchtower knows the stack's services from its containers' labels, so the control becomes a toggle per service. - stacks.services: the stack's compose service names from com.docker.compose.service labels of its project's containers (one cheap Docker call, all states); a Docker outage is an empty list, not an error, because this is an input aid - GpuServiceEditor is a toggle per known service; a selected service the engine no longer reports still gets a row marked "not deployed", so a stored setting is never silently dropped - an "another service…" row keeps every case configurable, including a stack that has never been deployed (no containers, no known services) - the literal device editor's service column gets the same names as a datalist, and both sub-sections gained a line saying what they take --- rpc-schema.json | 27 ++++ .../Stacks/Handlers/ListStackServices.cs | 54 +++++++ .../Modules/Stacks/StacksJsonContext.cs | 2 + .../src/components/device-mapping-editor.tsx | 145 ++++++++++++------ src/watchtower-web/src/lib/api.ts | 3 + .../src/modules/stacks/SettingsTab.tsx | 33 +++- 6 files changed, 211 insertions(+), 53 deletions(-) create mode 100644 src/Watchtower.Application/Modules/Stacks/Handlers/ListStackServices.cs diff --git a/rpc-schema.json b/rpc-schema.json index 59a50e3..3034e1d 100644 --- a/rpc-schema.json +++ b/rpc-schema.json @@ -11304,6 +11304,33 @@ ] } }, + "stacks.services": { + "params": { + "type": "object", + "properties": { + "stackId": { + "type": "integer" + } + }, + "required": [ + "stackId" + ] + }, + "result": { + "type": "object", + "properties": { + "services": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "services" + ] + } + }, "stacks.setAppApi": { "params": { "type": "object", diff --git a/src/Watchtower.Application/Modules/Stacks/Handlers/ListStackServices.cs b/src/Watchtower.Application/Modules/Stacks/Handlers/ListStackServices.cs new file mode 100644 index 0000000..1daac59 --- /dev/null +++ b/src/Watchtower.Application/Modules/Stacks/Handlers/ListStackServices.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; +using Watchtower.Application.Persistence; +using Watchtower.Application.Services; + +namespace Watchtower.Application.Modules.Stacks.Handlers; + +/// +/// The compose service names of a stack, read from its containers' com.docker.compose.service +/// labels — what the device and GPU editors offer instead of asking an operator to type a service +/// name from memory (ADR-0030/0031). +/// +/// +/// Deliberately the deployed services rather than the repository's compose file: the file +/// would have to be cloned and resolved per keystroke, while the labels are one cheap Docker call. +/// The consequence is that a never-deployed stack reports nothing, which the UI renders as a +/// free-text fallback — the setting has to be configurable before the first deploy, and a service +/// the engine cannot see is exactly the case the deploy already warns about. +/// +/// All states, so a stopped stack still lists its services ( keeps +/// containers). A Docker outage is an empty list, not an error: this is an input aid, and failing it +/// would take the whole Settings tab down with it. +/// +/// +[Handler("stacks.services")] +public sealed class ListStackServices(WatchtowerDbContext db, DockerEngineClient docker) + : IHandler> { + public sealed record Query(int StackId); + /// Distinct service names, ordered; empty when the stack has no containers. + public sealed record Response(IReadOnlyList Services); + + public async ValueTask> HandleAsync(Query query, CancellationToken ct) { + var projectName = await db.Stacks.AsNoTracking() + .Where(s => s.Id == query.StackId) + .Select(s => s.ComposeProjectName) + .FirstOrDefaultAsync(ct); + if (projectName is null) + return AppError.NotFound($"Stack {query.StackId} not found"); + + IReadOnlyList containers; + try { + containers = await docker.ListContainersByLabelsAsync( + [$"{AppApiService.ProjectLabel}={projectName}"], ct); + } catch (HttpRequestException) { + return new Response([]); + } + + return new Response([.. containers + .Select(c => c.Labels.GetValueOrDefault(AppApiService.ServiceLabel)) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s!.Trim()) + .Distinct(StringComparer.Ordinal) + .OrderBy(s => s, StringComparer.Ordinal)]); + } +} diff --git a/src/Watchtower.Application/Modules/Stacks/StacksJsonContext.cs b/src/Watchtower.Application/Modules/Stacks/StacksJsonContext.cs index 76a915a..cde5499 100644 --- a/src/Watchtower.Application/Modules/Stacks/StacksJsonContext.cs +++ b/src/Watchtower.Application/Modules/Stacks/StacksJsonContext.cs @@ -35,6 +35,8 @@ namespace Watchtower.Application.Modules.Stacks; [JsonSerializable(typeof(StackDeviceMappingDto))] [JsonSerializable(typeof(StackDeviceMappingInput))] [JsonSerializable(typeof(HostGpuDto))] +[JsonSerializable(typeof(ListStackServices.Query), TypeInfoPropertyName = "ListStackServicesQuery")] +[JsonSerializable(typeof(ListStackServices.Response), TypeInfoPropertyName = "ListStackServicesResponse")] [JsonSerializable(typeof(GetHostGpus.Query), TypeInfoPropertyName = "GetHostGpusQuery")] [JsonSerializable(typeof(GetHostGpus.Response), TypeInfoPropertyName = "GetHostGpusResponse")] [JsonSerializable(typeof(GetStackDevices.Query), TypeInfoPropertyName = "GetStackDevicesQuery")] diff --git a/src/watchtower-web/src/components/device-mapping-editor.tsx b/src/watchtower-web/src/components/device-mapping-editor.tsx index 694fc0b..89abf6e 100644 --- a/src/watchtower-web/src/components/device-mapping-editor.tsx +++ b/src/watchtower-web/src/components/device-mapping-editor.tsx @@ -1,4 +1,6 @@ +import { useState } from 'react' import { Plus, Trash2 } from 'lucide-react' +import { Switch } from '@/components/ui/switch' import { cn } from '@/lib/utils' /** One draft row; all fields plain strings so the inputs stay controlled. */ @@ -31,6 +33,8 @@ export interface DeviceMappingEditorProps { * the array and passes it straight back. Start with `[blankDeviceRow]`. */ value: DeviceMappingRow[] + /** The stack's known compose services, offered as suggestions on the service column. */ + services?: string[] onChange: (rows: DeviceMappingRow[]) => void className?: string } @@ -46,7 +50,13 @@ const cellClass = * * To persist, drop fully blank rows: `value.filter(r => !isRowBlank(r))` via the parent. */ -export function DeviceMappingEditor({ value, onChange, className }: DeviceMappingEditorProps) { +export function DeviceMappingEditor({ + value, + services = [], + onChange, + className, +}: DeviceMappingEditorProps) { + const serviceListId = 'device-services' function updateRow(i: number, field: keyof DeviceMappingRow, val: string) { const next = value.map((r, idx) => (idx === i ? { ...r, [field]: val } : r)) const last = next.at(-1) @@ -65,6 +75,13 @@ export function DeviceMappingEditor({ value, onChange, className }: DeviceMappin return (
+ {services.length > 0 && ( + + {services.map((service) => ( + + )} {/* Header (desktop only) */}
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 (
- {value.map((row, i) => { - const isBlankTrailer = i === value.length - 1 - return ( -
- updateRow(i, e.target.value)} - placeholder="service receiving host GPUs" - spellCheck={false} - autoComplete="off" - aria-label={`GPU service ${i + 1}`} - className={cn(cellClass, 'md:border-r-0')} - /> -
- {!isBlankTrailer ? ( - - ) : ( - - )} -
-
- ) - })} + {rows.map((service) => ( + + ))} + +
+ 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. +

+

{hostGpusQuery.data?.error != null ? ( <>Couldn’t inspect this host’s GPUs: {hostGpusQuery.data.error} @@ -489,8 +505,15 @@ export function SettingsTab({ stack }: { stack: Stack }) { )}

-

Specific devices

- +

Specific devices

+

+ Any other host device, by path — serial adapters, TPUs, /dev/fuse. +

+

Access is some combination of read,{' '} write and mknod;