fix: use saved SSH keys for task runners

This commit is contained in:
Developer
2026-07-14 17:20:27 +00:00
parent 230b4b8533
commit fe90feb1b7
3 changed files with 178 additions and 87 deletions
+35 -2
View File
@@ -34,6 +34,7 @@ import {
useTestServiceInstance, useTestServiceInstance,
} from "../hooks/useServices"; } from "../hooks/useServices";
import { useServiceTypes } from "../hooks/useServices"; import { useServiceTypes } from "../hooks/useServices";
import { useSSHKeys } from "../hooks/useSettings";
import { import {
useDashboards, useDashboards,
useDeleteDashboard, useDeleteDashboard,
@@ -45,6 +46,7 @@ import type {
ServiceInstanceInput, ServiceInstanceInput,
ServiceTestResult, ServiceTestResult,
ServiceTypeInfo, ServiceTypeInfo,
SSHKey,
} from "../types"; } from "../types";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
@@ -92,10 +94,12 @@ function ServiceConfigFields({
type, type,
config, config,
onChange, onChange,
sshKeys,
}: { }: {
type: ServiceTypeInfo; type: ServiceTypeInfo;
config: Record<string, unknown>; config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void; onChange: (config: Record<string, unknown>) => void;
sshKeys: SSHKey[];
}) { }) {
const properties = const properties =
( (
@@ -109,6 +113,7 @@ function ServiceConfigFields({
// Multi-line resizable textarea for fields that hold complex values (opt-in // Multi-line resizable textarea for fields that hold complex values (opt-in
// via `format: "textarea"`, or well-known multi-line keys). // via `format: "textarea"`, or well-known multi-line keys).
const TEXTAREA_KEYS = new Set(["notes", "command"]); const TEXTAREA_KEYS = new Set(["notes", "command"]);
const noSSHKey = "__no_ssh_key__";
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{Object.entries(properties).map(([key, schema]) => { {Object.entries(properties).map(([key, schema]) => {
@@ -118,11 +123,37 @@ function ServiceConfigFields({
return ( return (
<Field <Field
key={key} key={key}
label={key} label={
type.service_type === "ssh_tasks" && key === "ssh_key_id"
? "SSH key"
: key
}
htmlFor={`cfg-${key}`} htmlFor={`cfg-${key}`}
helper={schema.description} helper={schema.description}
> >
{isTextarea ? ( {type.service_type === "ssh_tasks" && key === "ssh_key_id" ? (
<Select
value={String(config[key] ?? "") || noSSHKey}
onValueChange={(value) =>
onChange({
...config,
[key]: value === noSSHKey ? "" : value,
})
}
>
<SelectTrigger id={`cfg-${key}`} className="w-full">
<SelectValue placeholder="Select an SSH key" />
</SelectTrigger>
<SelectContent>
<SelectItem value={noSSHKey}>No key selected</SelectItem>
{sshKeys.map((sshKey) => (
<SelectItem key={sshKey.id} value={sshKey.id}>
{sshKey.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : isTextarea ? (
<Textarea <Textarea
id={`cfg-${key}`} id={`cfg-${key}`}
rows={6} rows={6}
@@ -195,6 +226,7 @@ export function CreateServiceDialog({
onClose: () => void; onClose: () => void;
}) { }) {
const { data: types = [] } = useServiceTypes(); const { data: types = [] } = useServiceTypes();
const { data: sshKeys = [] } = useSSHKeys();
const saveService = useSaveServiceInstance(); const saveService = useSaveServiceInstance();
const testService = useTestServiceInstance(); const testService = useTestServiceInstance();
const [draft, setDraft] = useState<CreateDraft | null>(null); const [draft, setDraft] = useState<CreateDraft | null>(null);
@@ -306,6 +338,7 @@ export function CreateServiceDialog({
<ServiceConfigFields <ServiceConfigFields
type={selectedType} type={selectedType}
config={draft.config} config={draft.config}
sshKeys={sshKeys}
onChange={(config) => setDraft({ ...draft, config })} onChange={(config) => setDraft({ ...draft, config })}
/> />
) : null} ) : null}
+117 -84
View File
@@ -1390,75 +1390,75 @@ function ServicesAdminCard({
return ( return (
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex justify-end"> <div className="flex justify-end">
<Button variant="outline" onClick={() => setCreateOpen(true)}> <Button variant="outline" onClick={() => setCreateOpen(true)}>
Add service Add service
</Button> </Button>
</div> </div>
{sortedServices.length === 0 ? ( {sortedServices.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No service instances configured yet. No service instances configured yet.
</p> </p>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]"> <div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
<SelectionRailCard <SelectionRailCard
title="Services" title="Services"
description="Select a service to edit its configuration." description="Select a service to edit its configuration."
minHeight={420} minHeight={420}
> >
{sortedServices.map((svc) => { {sortedServices.map((svc) => {
const active = svc.id === (selectedService?.id ?? ""); const active = svc.id === (selectedService?.id ?? "");
const typeName = const typeName =
types.find((t) => t.service_type === svc.service_type) types.find((t) => t.service_type === svc.service_type)
?.name ?? svc.service_type; ?.name ?? svc.service_type;
return ( return (
<div <div
key={svc.id} key={svc.id}
onClick={() => setSelectedServiceId(svc.id)} onClick={() => setSelectedServiceId(svc.id)}
className={cn( className={cn(
"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5", "group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5",
active ? "bg-muted" : "bg-card hover:bg-muted/50", active ? "bg-muted" : "bg-card hover:bg-muted/50",
)} )}
> >
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-semibold">{svc.name}</p> <p className="truncate font-semibold">{svc.name}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{typeName} · {svc.enabled ? "Enabled" : "Disabled"} {typeName} · {svc.enabled ? "Enabled" : "Disabled"}
</p> </p>
</div> </div>
<Badge variant={svc.enabled ? "default" : "secondary"}> <Badge variant={svc.enabled ? "default" : "secondary"}>
{svc.enabled ? "on" : "off"} {svc.enabled ? "on" : "off"}
</Badge> </Badge>
</div> </div>
); );
})} })}
</SelectionRailCard> </SelectionRailCard>
<SectionCard <SectionCard
title={selectedService?.name ?? "No service selected"} title={selectedService?.name ?? "No service selected"}
description={ description={
selectedTypeInfo?.description ?? selectedTypeInfo?.description ??
"Select a service on the left to edit its configuration." "Select a service on the left to edit its configuration."
} }
> >
{selectedService ? ( {selectedService ? (
<ServiceConfigEditor <ServiceConfigEditor
// Remount on service switch so useState initializers (name, config, // Remount on service switch so useState initializers (name, config,
// secrets) re-run for the new instance. Without this key, switching // secrets) re-run for the new instance. Without this key, switching
// services in the rail keeps the previous service's editable state // services in the rail keeps the previous service's editable state
// and a save writes stale name/config onto the new service's row. // and a save writes stale name/config onto the new service's row.
key={selectedService.id} key={selectedService.id}
instance={selectedService} instance={selectedService}
typeInfo={selectedTypeInfo} typeInfo={selectedTypeInfo}
/> />
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Select a service on the left. Select a service on the left.
</p> </p>
)} )}
</SectionCard> </SectionCard>
</div>
)}
</div> </div>
)}
</div>
<CreateServiceDialog <CreateServiceDialog
open={createOpen} open={createOpen}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}
@@ -1477,6 +1477,7 @@ function ServiceConfigEditor({
const saveService = useSaveServiceInstance(); const saveService = useSaveServiceInstance();
const deleteService = useDeleteServiceInstance(); const deleteService = useDeleteServiceInstance();
const testService = useTestServiceInstance(); const testService = useTestServiceInstance();
const { data: sshKeys = [] } = useSSHKeys();
const [name, setName] = useState(instance.name); const [name, setName] = useState(instance.name);
const [enabled, setEnabled] = useState(instance.enabled); const [enabled, setEnabled] = useState(instance.enabled);
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({ const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({
@@ -1598,25 +1599,57 @@ function ServiceConfigEditor({
return ( return (
<FormField <FormField
key={key} key={key}
label={key} label={
instance.service_type === "ssh_tasks" && key === "ssh_key_id"
? "SSH key"
: key
}
htmlFor={`svc-cfg-${instance.id}-${key}`} htmlFor={`svc-cfg-${instance.id}-${key}`}
helperText={schema.description} helperText={schema.description}
> >
<Input {instance.service_type === "ssh_tasks" &&
id={`svc-cfg-${instance.id}-${key}`} key === "ssh_key_id" ? (
type={isNumber ? "number" : "text"} <Select
value={String(draftConfig[key] ?? "")} value={String(draftConfig[key] ?? "") || NONE}
onChange={(e) => onValueChange={(value) =>
setDraftConfig({ setDraftConfig({
...draftConfig, ...draftConfig,
[key]: isNumber [key]: value === NONE ? "" : value,
? e.target.value === "" })
? undefined }
: Number(e.target.value) >
: e.target.value, <SelectTrigger
}) id={`svc-cfg-${instance.id}-${key}`}
} className="w-full"
/> >
<SelectValue placeholder="Select an SSH key" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>No key selected</SelectItem>
{sshKeys.map((sshKey) => (
<SelectItem key={sshKey.id} value={sshKey.id}>
{sshKey.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={`svc-cfg-${instance.id}-${key}`}
type={isNumber ? "number" : "text"}
value={String(draftConfig[key] ?? "")}
onChange={(e) =>
setDraftConfig({
...draftConfig,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
)}
</FormField> </FormField>
); );
})} })}
@@ -22,7 +22,7 @@ const saveServiceMutate = vi.fn().mockResolvedValue({});
// tab, so stub them out to keep the render focused on the Services editor. // tab, so stub them out to keep the render focused on the Services editor.
vi.mock("../../hooks/useSettings", () => ({ vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [] }), useMonitoringSettings: () => ({ data: [] }),
useSSHKeys: () => ({ data: [] }), useSSHKeys: () => ({ data: [{ id: "key-1", name: "Production key" }] }),
useSaveMonitoringMachine: () => ({ mutateAsync: vi.fn(), isPending: false }), useSaveMonitoringMachine: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteMonitoringMachine: () => ({ mutate: vi.fn() }), useDeleteMonitoringMachine: () => ({ mutate: vi.fn() }),
useTestMonitoringMachineSSH: () => ({ useTestMonitoringMachineSSH: () => ({
@@ -75,6 +75,22 @@ vi.mock("../../hooks/useServices", () => ({
], ],
widget_kinds: [], widget_kinds: [],
}, },
{
service_type: "ssh_tasks",
name: "SSH task runner",
description: "Run saved tasks over SSH",
config_schema: {
type: "object",
properties: {
host: { type: "string" },
port: { type: "integer" },
username: { type: "string" },
ssh_key_id: { type: "string" },
},
},
secret_fields: [],
widget_kinds: [],
},
], ],
}), }),
useServiceInstances: () => ({ useServiceInstances: () => ({
@@ -156,6 +172,15 @@ describe("Settings > Services editor", () => {
await userEvent.click(screen.getByRole("button", { name: "Add service" })); await userEvent.click(screen.getByRole("button", { name: "Add service" }));
expect(screen.getByText("New service")).toBeInTheDocument(); expect(screen.getByText("New service")).toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", { name: "SSH task runner" }),
);
const sshKeySelect = screen.getByRole("combobox", { name: "SSH key" });
await userEvent.click(sshKeySelect);
expect(
screen.getByRole("option", { name: "Production key" }),
).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await userEvent.keyboard("{Escape}"); await userEvent.keyboard("{Escape}");
await userEvent.click(screen.getByRole("tab", { name: "Dashboards" })); await userEvent.click(screen.getByRole("tab", { name: "Dashboards" }));