fix: use saved SSH keys for task runners
This commit is contained in:
@@ -34,6 +34,7 @@ import {
|
||||
useTestServiceInstance,
|
||||
} from "../hooks/useServices";
|
||||
import { useServiceTypes } from "../hooks/useServices";
|
||||
import { useSSHKeys } from "../hooks/useSettings";
|
||||
import {
|
||||
useDashboards,
|
||||
useDeleteDashboard,
|
||||
@@ -45,6 +46,7 @@ import type {
|
||||
ServiceInstanceInput,
|
||||
ServiceTestResult,
|
||||
ServiceTypeInfo,
|
||||
SSHKey,
|
||||
} from "../types";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
@@ -92,10 +94,12 @@ function ServiceConfigFields({
|
||||
type,
|
||||
config,
|
||||
onChange,
|
||||
sshKeys,
|
||||
}: {
|
||||
type: ServiceTypeInfo;
|
||||
config: Record<string, unknown>;
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
sshKeys: SSHKey[];
|
||||
}) {
|
||||
const properties =
|
||||
(
|
||||
@@ -109,6 +113,7 @@ function ServiceConfigFields({
|
||||
// Multi-line resizable textarea for fields that hold complex values (opt-in
|
||||
// via `format: "textarea"`, or well-known multi-line keys).
|
||||
const TEXTAREA_KEYS = new Set(["notes", "command"]);
|
||||
const noSSHKey = "__no_ssh_key__";
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Object.entries(properties).map(([key, schema]) => {
|
||||
@@ -118,11 +123,37 @@ function ServiceConfigFields({
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={key}
|
||||
label={
|
||||
type.service_type === "ssh_tasks" && key === "ssh_key_id"
|
||||
? "SSH key"
|
||||
: key
|
||||
}
|
||||
htmlFor={`cfg-${key}`}
|
||||
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
|
||||
id={`cfg-${key}`}
|
||||
rows={6}
|
||||
@@ -195,6 +226,7 @@ export function CreateServiceDialog({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data: types = [] } = useServiceTypes();
|
||||
const { data: sshKeys = [] } = useSSHKeys();
|
||||
const saveService = useSaveServiceInstance();
|
||||
const testService = useTestServiceInstance();
|
||||
const [draft, setDraft] = useState<CreateDraft | null>(null);
|
||||
@@ -306,6 +338,7 @@ export function CreateServiceDialog({
|
||||
<ServiceConfigFields
|
||||
type={selectedType}
|
||||
config={draft.config}
|
||||
sshKeys={sshKeys}
|
||||
onChange={(config) => setDraft({ ...draft, config })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
+117
-84
@@ -1390,75 +1390,75 @@ function ServicesAdminCard({
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
||||
Add service
|
||||
</Button>
|
||||
</div>
|
||||
{sortedServices.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No service instances configured yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<SelectionRailCard
|
||||
title="Services"
|
||||
description="Select a service to edit its configuration."
|
||||
minHeight={420}
|
||||
>
|
||||
{sortedServices.map((svc) => {
|
||||
const active = svc.id === (selectedService?.id ?? "");
|
||||
const typeName =
|
||||
types.find((t) => t.service_type === svc.service_type)
|
||||
?.name ?? svc.service_type;
|
||||
return (
|
||||
<div
|
||||
key={svc.id}
|
||||
onClick={() => setSelectedServiceId(svc.id)}
|
||||
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",
|
||||
active ? "bg-muted" : "bg-card hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{svc.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{typeName} · {svc.enabled ? "Enabled" : "Disabled"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={svc.enabled ? "default" : "secondary"}>
|
||||
{svc.enabled ? "on" : "off"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SelectionRailCard>
|
||||
<SectionCard
|
||||
title={selectedService?.name ?? "No service selected"}
|
||||
description={
|
||||
selectedTypeInfo?.description ??
|
||||
"Select a service on the left to edit its configuration."
|
||||
}
|
||||
>
|
||||
{selectedService ? (
|
||||
<ServiceConfigEditor
|
||||
// Remount on service switch so useState initializers (name, config,
|
||||
// secrets) re-run for the new instance. Without this key, switching
|
||||
// services in the rail keeps the previous service's editable state
|
||||
// and a save writes stale name/config onto the new service's row.
|
||||
key={selectedService.id}
|
||||
instance={selectedService}
|
||||
typeInfo={selectedTypeInfo}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a service on the left.
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
||||
Add service
|
||||
</Button>
|
||||
</div>
|
||||
{sortedServices.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No service instances configured yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<SelectionRailCard
|
||||
title="Services"
|
||||
description="Select a service to edit its configuration."
|
||||
minHeight={420}
|
||||
>
|
||||
{sortedServices.map((svc) => {
|
||||
const active = svc.id === (selectedService?.id ?? "");
|
||||
const typeName =
|
||||
types.find((t) => t.service_type === svc.service_type)
|
||||
?.name ?? svc.service_type;
|
||||
return (
|
||||
<div
|
||||
key={svc.id}
|
||||
onClick={() => setSelectedServiceId(svc.id)}
|
||||
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",
|
||||
active ? "bg-muted" : "bg-card hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{svc.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{typeName} · {svc.enabled ? "Enabled" : "Disabled"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={svc.enabled ? "default" : "secondary"}>
|
||||
{svc.enabled ? "on" : "off"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SelectionRailCard>
|
||||
<SectionCard
|
||||
title={selectedService?.name ?? "No service selected"}
|
||||
description={
|
||||
selectedTypeInfo?.description ??
|
||||
"Select a service on the left to edit its configuration."
|
||||
}
|
||||
>
|
||||
{selectedService ? (
|
||||
<ServiceConfigEditor
|
||||
// Remount on service switch so useState initializers (name, config,
|
||||
// secrets) re-run for the new instance. Without this key, switching
|
||||
// services in the rail keeps the previous service's editable state
|
||||
// and a save writes stale name/config onto the new service's row.
|
||||
key={selectedService.id}
|
||||
instance={selectedService}
|
||||
typeInfo={selectedTypeInfo}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a service on the left.
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CreateServiceDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
@@ -1477,6 +1477,7 @@ function ServiceConfigEditor({
|
||||
const saveService = useSaveServiceInstance();
|
||||
const deleteService = useDeleteServiceInstance();
|
||||
const testService = useTestServiceInstance();
|
||||
const { data: sshKeys = [] } = useSSHKeys();
|
||||
const [name, setName] = useState(instance.name);
|
||||
const [enabled, setEnabled] = useState(instance.enabled);
|
||||
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({
|
||||
@@ -1598,25 +1599,57 @@ function ServiceConfigEditor({
|
||||
return (
|
||||
<FormField
|
||||
key={key}
|
||||
label={key}
|
||||
label={
|
||||
instance.service_type === "ssh_tasks" && key === "ssh_key_id"
|
||||
? "SSH key"
|
||||
: key
|
||||
}
|
||||
htmlFor={`svc-cfg-${instance.id}-${key}`}
|
||||
helperText={schema.description}
|
||||
>
|
||||
<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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{instance.service_type === "ssh_tasks" &&
|
||||
key === "ssh_key_id" ? (
|
||||
<Select
|
||||
value={String(draftConfig[key] ?? "") || NONE}
|
||||
onValueChange={(value) =>
|
||||
setDraftConfig({
|
||||
...draftConfig,
|
||||
[key]: value === NONE ? "" : 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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -22,7 +22,7 @@ const saveServiceMutate = vi.fn().mockResolvedValue({});
|
||||
// tab, so stub them out to keep the render focused on the Services editor.
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({ data: [] }),
|
||||
useSSHKeys: () => ({ data: [] }),
|
||||
useSSHKeys: () => ({ data: [{ id: "key-1", name: "Production key" }] }),
|
||||
useSaveMonitoringMachine: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useDeleteMonitoringMachine: () => ({ mutate: vi.fn() }),
|
||||
useTestMonitoringMachineSSH: () => ({
|
||||
@@ -75,6 +75,22 @@ vi.mock("../../hooks/useServices", () => ({
|
||||
],
|
||||
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: () => ({
|
||||
@@ -156,6 +172,15 @@ describe("Settings > Services editor", () => {
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add service" }));
|
||||
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.click(screen.getByRole("tab", { name: "Dashboards" }));
|
||||
|
||||
Reference in New Issue
Block a user