Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions rpc-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore;
using Watchtower.Application.Persistence;
using Watchtower.Application.Services;

namespace Watchtower.Application.Modules.Stacks.Handlers;

/// <summary>
/// The compose service names of a stack, read from its containers' <c>com.docker.compose.service</c>
/// labels — what the device and GPU editors offer instead of asking an operator to type a service
/// name from memory (ADR-0030/0031).
/// </summary>
/// <remarks>
/// Deliberately the <em>deployed</em> 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.
/// <para>
/// All states, so a stopped stack still lists its services (<see cref="StopStack"/> 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.
/// </para>
/// </remarks>
[Handler("stacks.services")]
public sealed class ListStackServices(WatchtowerDbContext db, DockerEngineClient docker)
: IHandler<ListStackServices.Query, Result<ListStackServices.Response>> {
public sealed record Query(int StackId);
/// <param name="Services">Distinct service names, ordered; empty when the stack has no containers.</param>
public sealed record Response(IReadOnlyList<string> Services);

public async ValueTask<Result<Response>> 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<DockerContainerInfo> 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)]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
145 changes: 97 additions & 48 deletions src/watchtower-web/src/components/device-mapping-editor.tsx
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand All @@ -65,6 +75,13 @@ export function DeviceMappingEditor({ value, onChange, className }: DeviceMappin

return (
<div className={cn('overflow-hidden rounded-md border border-border', className)}>
{services.length > 0 && (
<datalist id={serviceListId}>
{services.map((service) => (
<option key={service} value={service} />
))}
</datalist>
)}
{/* Header (desktop only) */}
<div
className={cn(
Expand Down Expand Up @@ -96,6 +113,7 @@ export function DeviceMappingEditor({ value, onChange, className }: DeviceMappin
value={row.service}
onChange={(e) => 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}`}
Expand Down Expand Up @@ -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 (
<div className={cn('overflow-hidden rounded-md border border-border', className)}>
{value.map((row, i) => {
const isBlankTrailer = i === value.length - 1
return (
<div
key={i}
className="flex items-center border-b border-border last:border-b-0 md:grid md:grid-cols-[1fr_2.5rem]"
>
<input
value={row}
onChange={(e) => 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')}
/>
<div className="flex items-center justify-end p-1 md:justify-center md:p-0">
{!isBlankTrailer ? (
<button
type="button"
onClick={() => removeRow(i)}
aria-label={`Remove GPU passthrough for ${row || `service ${i + 1}`}`}
className="rounded p-1.5 text-danger transition-colors hover:bg-danger-bg"
>
<Trash2 className="size-3.5" />
</button>
) : (
<Plus className="size-3.5 text-text-3" aria-hidden />
)}
</div>
</div>
)
})}
{rows.map((service) => (
<label
key={service}
className="flex items-center justify-between gap-3 border-b border-border px-3 py-2.5 last:border-b-0"
>
<span className="min-w-0 truncate font-mono text-[13px] text-text">
{service}
{!known.has(service) && (
<span className="ml-2 font-sans text-[12px] text-text-3">not deployed</span>
)}
</span>
<Switch
checked={selected.has(service)}
onCheckedChange={(on) => toggle(service, on)}
aria-label={`Map host GPUs into ${service}`}
/>
</label>
))}

<div className="flex items-center gap-2 px-3 py-2.5">
<input
value={extra}
onChange={(e) => 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)]"
/>
<button
type="button"
onClick={addExtra}
disabled={extra.trim() === ''}
className="shrink-0 rounded p-1.5 text-text-3 transition-colors hover:text-text disabled:opacity-40"
aria-label="Add service"
>
<Plus className="size-3.5" />
</button>
</div>
</div>
)
}
3 changes: 3 additions & 0 deletions src/watchtower-web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

/**
Expand Down
33 changes: 28 additions & 5 deletions src/watchtower-web/src/modules/stacks/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -103,7 +110,7 @@ export function SettingsTab({ stack }: { stack: Stack }) {
]

const [gpuDraft, setGpuDraft] = useState<string[] | null>(null)
const gpuRows: string[] = gpuDraft ?? [...(devicesQuery.data?.gpuServices ?? []), '']
const gpuRows: string[] = gpuDraft ?? (devicesQuery.data?.gpuServices ?? [])

const [confirmDelete, setConfirmDelete] = useState(false)

Expand Down Expand Up @@ -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. */}
<p className="mb-1.5 text-sm font-medium text-text">GPU passthrough</p>
<GpuServiceEditor value={gpuRows} onChange={setGpuDraft} />
<p className="text-sm font-medium text-text">GPU passthrough</p>
<p className="mb-1.5 text-[13px] text-text-2">
Turn this on for the services that should see the host&rsquo;s GPUs. The devices
themselves aren&rsquo;t a choice — every deploy probes this host and maps what it
finds.
</p>
<GpuServiceEditor
value={gpuRows}
services={servicesQuery.data ?? []}
onChange={setGpuDraft}
/>
<p className="mt-2 text-[13px] text-text-2">
{hostGpusQuery.data?.error != null ? (
<>Couldn’t inspect this host’s GPUs: {hostGpusQuery.data.error}</>
Expand Down Expand Up @@ -489,8 +505,15 @@ export function SettingsTab({ stack }: { stack: Stack }) {
)}
</p>

<p className="mb-1.5 mt-5 text-sm font-medium text-text">Specific devices</p>
<DeviceMappingEditor value={deviceRows} onChange={setDeviceDraft} />
<p className="mt-5 text-sm font-medium text-text">Specific devices</p>
<p className="mb-1.5 text-[13px] text-text-2">
Any other host device, by path — serial adapters, TPUs, /dev/fuse.
</p>
<DeviceMappingEditor
value={deviceRows}
services={servicesQuery.data ?? []}
onChange={setDeviceDraft}
/>
<p className="mt-2 text-[13px] text-text-2">
Access is some combination of <span className="font-mono">r</span>ead,{' '}
<span className="font-mono">w</span>rite and <span className="font-mono">m</span>knod;
Expand Down