refactor: unify SSH machines as services
This commit is contained in:
@@ -124,14 +124,14 @@ function ServiceConfigFields({
|
||||
<Field
|
||||
key={key}
|
||||
label={
|
||||
type.service_type === "ssh_tasks" && key === "ssh_key_id"
|
||||
type.service_type === "remote_machine" && key === "ssh_key_id"
|
||||
? "SSH key"
|
||||
: key
|
||||
}
|
||||
htmlFor={`cfg-${key}`}
|
||||
helper={schema.description}
|
||||
>
|
||||
{type.service_type === "ssh_tasks" && key === "ssh_key_id" ? (
|
||||
{type.service_type === "remote_machine" && key === "ssh_key_id" ? (
|
||||
<Select
|
||||
value={String(config[key] ?? "") || noSSHKey}
|
||||
onValueChange={(value) =>
|
||||
|
||||
+797
-1631
File diff suppressed because it is too large
Load Diff
@@ -81,11 +81,11 @@ describe("ServicePage tab skeleton", () => {
|
||||
});
|
||||
|
||||
it("does NOT render Media/Requests for non-jellyfin types", () => {
|
||||
const sshInstance = { ...instance, service_type: "ssh_tasks", id: "ssh-1" };
|
||||
const sshInstance = { ...instance, service_type: "remote_machine", id: "ssh-1" };
|
||||
(
|
||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||
).__svcInstances = [sshInstance];
|
||||
renderServicePage("/services/ssh_tasks/ssh-1");
|
||||
renderServicePage("/services/remote_machine/ssh-1");
|
||||
expect(screen.getByRole("tab", { name: "Files" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Actions" })).toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
@@ -76,7 +76,7 @@ vi.mock("../../hooks/useServices", () => ({
|
||||
widget_kinds: [],
|
||||
},
|
||||
{
|
||||
service_type: "ssh_tasks",
|
||||
service_type: "remote_machine",
|
||||
name: "SSH task runner",
|
||||
description: "Run saved tasks over SSH",
|
||||
config_schema: {
|
||||
|
||||
@@ -1,125 +1,39 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Settings } from "../Settings";
|
||||
import type { MonitoringMachine } from "../../types";
|
||||
|
||||
const saveMachineMutate = vi.fn().mockResolvedValue({});
|
||||
const deleteMachineMutate = vi.fn();
|
||||
const testSSHMutate = vi.fn().mockResolvedValue({
|
||||
message: "SSH auth succeeded",
|
||||
known_hosts_updated: true,
|
||||
});
|
||||
|
||||
let machines: MonitoringMachine[] = [];
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({ data: machines }),
|
||||
useSSHKeys: () => ({ data: [] }),
|
||||
useSaveMonitoringMachine: () => ({
|
||||
mutateAsync: saveMachineMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteMonitoringMachine: () => ({ mutate: deleteMachineMutate }),
|
||||
useTestMonitoringMachineSSH: () => ({
|
||||
mutateAsync: testSSHMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useResetLocalDatabase: () => ({}),
|
||||
useSaveSSHKey: () => ({ mutateAsync: vi.fn() }),
|
||||
useGenerateSSHKey: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useDeleteSSHKey: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
function localMachine(
|
||||
overrides: Partial<MonitoringMachine> = {},
|
||||
): MonitoringMachine {
|
||||
return {
|
||||
id: "m1",
|
||||
name: "This machine",
|
||||
mode: "local",
|
||||
enabled: true,
|
||||
services: ["monitoring", "files", "jellyfin"],
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
key_directory: "",
|
||||
key_name: "",
|
||||
ssh_key_id: "",
|
||||
ssh_private_key_set: false,
|
||||
ssh_private_key_passphrase_set: false,
|
||||
password_set: false,
|
||||
notes: "Primary node",
|
||||
...overrides,
|
||||
} as MonitoringMachine;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
saveMachineMutate.mockClear();
|
||||
deleteMachineMutate.mockClear();
|
||||
testSSHMutate.mockClear();
|
||||
machines = [];
|
||||
});
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: [] }),
|
||||
useServiceTypes: () => ({ data: [] }),
|
||||
useSaveServiceInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useDeleteServiceInstance: () => ({ mutate: vi.fn() }),
|
||||
useTestServiceInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
describe("Settings", () => {
|
||||
it("renders the machine list from the mocked store", () => {
|
||||
machines = [localMachine()];
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Settings />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByText("local · Enabled")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves a machine via the editor dialog (controlled useState parity)", async () => {
|
||||
machines = [localMachine()];
|
||||
it("uses Services instead of a standalone Machines tab", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Settings />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// The detail-pane "Edit" has visible text "Edit"; the rail hover edit
|
||||
// affordance is icon-only (aria-label "Edit") — disambiguate by text.
|
||||
const detailEdit = screen
|
||||
.getAllByRole("button", { name: "Edit" })
|
||||
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
||||
await userEvent.click(detailEdit);
|
||||
|
||||
expect(screen.getByText("Edit machine")).toBeInTheDocument();
|
||||
|
||||
// Rename through the labeled field, then save.
|
||||
const nameInput = screen.getByLabelText("Name");
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "Worker node");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save machine" }));
|
||||
|
||||
expect(saveMachineMutate).toHaveBeenCalledTimes(1);
|
||||
const saved = saveMachineMutate.mock.calls[0][0];
|
||||
expect(saved.name).toBe("Worker node");
|
||||
expect(saved.mode).toBe("local");
|
||||
});
|
||||
|
||||
it("deletes a machine through the confirm dialog", async () => {
|
||||
machines = [localMachine()];
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Settings />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Detail-pane "Delete" opens the confirm dialog.
|
||||
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(screen.getByText("Delete machine?")).toBeInTheDocument();
|
||||
|
||||
// Confirm (the confirm dialog's "Delete" is the last one rendered).
|
||||
const deletes = screen.getAllByRole("button", { name: "Delete" });
|
||||
await userEvent.click(deletes[deletes.length - 1]);
|
||||
|
||||
expect(deleteMachineMutate).toHaveBeenCalledTimes(1);
|
||||
expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
|
||||
expect(screen.getByRole("tab", { name: "Services" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "SSH Keys" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: "Machines" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("No service instances configured yet."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* ActionsTab — operational content for the ssh_tasks service page.
|
||||
* ActionsTab — operational content for the remote_machine service page.
|
||||
*
|
||||
* Lifted from the old top-level `pages/Actions.tsx`. The `instance` prop
|
||||
* provides the active ssh_tasks service id, which is used as the default run
|
||||
* provides the active remote_machine service id, which is used as the default run
|
||||
* service. The page-level header is removed (the service page provides it).
|
||||
* The task editor dialog, saved-task rail, and run history are preserved.
|
||||
*/
|
||||
@@ -10,11 +10,11 @@ import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../../types";
|
||||
import {
|
||||
useDeleteTask,
|
||||
useRunTask,
|
||||
useSaveTask,
|
||||
useTaskRuns,
|
||||
useTasks,
|
||||
useDeleteTask,
|
||||
useRunTask,
|
||||
useSaveTask,
|
||||
useTaskRuns,
|
||||
useTasks,
|
||||
} from "../../hooks/useSettings";
|
||||
import { DialogFooter } from "../../components/DialogFooter";
|
||||
import { HoverEditButton } from "../../components/HoverEditButton";
|
||||
@@ -25,20 +25,20 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -47,410 +47,423 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
type ActionTab = "new" | string;
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
htmlFor,
|
||||
helperText,
|
||||
children,
|
||||
label,
|
||||
htmlFor,
|
||||
helperText,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
helperText?: string;
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
helperText?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor={htmlFor} className="mb-1">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
{helperText ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{helperText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor={htmlFor} className="mb-1">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
{helperText ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{helperText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function emptyTask(): SavedTaskInput {
|
||||
return {
|
||||
id: null,
|
||||
name: "",
|
||||
task_type: "shell",
|
||||
content: "",
|
||||
enabled: true,
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
};
|
||||
return {
|
||||
id: null,
|
||||
name: "",
|
||||
task_type: "shell",
|
||||
content: "",
|
||||
enabled: true,
|
||||
service_id: "",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
function sameTask(a: SavedTaskInput, b: SavedTaskInput) {
|
||||
return (
|
||||
a.id === b.id &&
|
||||
a.name === b.name &&
|
||||
a.task_type === b.task_type &&
|
||||
a.content === b.content &&
|
||||
a.enabled === b.enabled &&
|
||||
a.default_service_id === b.default_service_id &&
|
||||
a.notes === b.notes
|
||||
);
|
||||
return (
|
||||
a.id === b.id &&
|
||||
a.name === b.name &&
|
||||
a.task_type === b.task_type &&
|
||||
a.content === b.content &&
|
||||
a.enabled === b.enabled &&
|
||||
a.service_id === b.service_id &&
|
||||
a.notes === b.notes
|
||||
);
|
||||
}
|
||||
|
||||
function initialFromTask(task: SavedTask): SavedTaskInput {
|
||||
return {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
task_type: task.task_type,
|
||||
content: task.content,
|
||||
enabled: task.enabled,
|
||||
default_service_id: task.default_service_id,
|
||||
notes: task.notes,
|
||||
};
|
||||
return {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
task_type: task.task_type,
|
||||
content: task.content,
|
||||
enabled: task.enabled,
|
||||
service_id: task.service_id,
|
||||
notes: task.notes,
|
||||
};
|
||||
}
|
||||
|
||||
function TaskEditor({
|
||||
task,
|
||||
onChange,
|
||||
task,
|
||||
onChange,
|
||||
}: {
|
||||
task: SavedTaskInput;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
task: SavedTaskInput;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold">
|
||||
{task.id ? "Edit action" : "New action"}
|
||||
</p>
|
||||
<Badge variant="outline">{task.task_type}</Badge>
|
||||
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField label="Name" htmlFor="task-name">
|
||||
<Input
|
||||
id="task-name"
|
||||
value={task.name}
|
||||
onChange={(e) => onChange({ ...task, name: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
<div className="min-w-[180px] flex-1">
|
||||
<FormField label="Type">
|
||||
<Select
|
||||
value={task.task_type}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
...task,
|
||||
task_type: value as SavedTaskInput["task_type"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="shell">Shell</SelectItem>
|
||||
<SelectItem value="python">Python</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<FormField label="Notes">
|
||||
<Input
|
||||
value={task.notes}
|
||||
onChange={(e) => onChange({ ...task, notes: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={
|
||||
task.task_type === "python" ? "Python script" : "Shell command"
|
||||
}
|
||||
helperText={
|
||||
task.task_type === "python"
|
||||
? "Python is run as `python3 -c`."
|
||||
: "Shell commands are run through `/bin/sh -c`."
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
rows={9}
|
||||
value={task.content}
|
||||
onChange={(e) => onChange({ ...task, content: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold">
|
||||
{task.id ? "Edit action" : "New action"}
|
||||
</p>
|
||||
<Badge variant="outline">{task.task_type}</Badge>
|
||||
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField label="Name" htmlFor="task-name">
|
||||
<Input
|
||||
id="task-name"
|
||||
value={task.name}
|
||||
onChange={(e) => onChange({ ...task, name: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
<div className="min-w-[180px] flex-1">
|
||||
<FormField label="Type">
|
||||
<Select
|
||||
value={task.task_type}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
...task,
|
||||
task_type: value as SavedTaskInput["task_type"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="shell">Shell</SelectItem>
|
||||
<SelectItem value="python">Python</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<FormField label="Notes">
|
||||
<Input
|
||||
value={task.notes}
|
||||
onChange={(e) => onChange({ ...task, notes: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={
|
||||
task.task_type === "python" ? "Python script" : "Shell command"
|
||||
}
|
||||
helperText={
|
||||
task.task_type === "python"
|
||||
? "Python is run as `python3 -c`."
|
||||
: "Shell commands are run through `/bin/sh -c`."
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
rows={9}
|
||||
value={task.content}
|
||||
onChange={(e) => onChange({ ...task, content: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDialog({
|
||||
open,
|
||||
task,
|
||||
baseline,
|
||||
onClose,
|
||||
onChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
open,
|
||||
task,
|
||||
baseline,
|
||||
onClose,
|
||||
onChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
open: boolean;
|
||||
task: SavedTaskInput;
|
||||
baseline: SavedTaskInput;
|
||||
onClose: () => void;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
open: boolean;
|
||||
task: SavedTaskInput;
|
||||
baseline: SavedTaskInput;
|
||||
onClose: () => void;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const requestClose = () => {
|
||||
if (
|
||||
!sameTask(task, baseline) &&
|
||||
!window.confirm("Discard unsaved changes?")
|
||||
)
|
||||
return;
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) requestClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save a reusable server task. Shell commands run via{" "}
|
||||
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
|
||||
Runs execute on this SSH task service instance.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TaskEditor task={task} onChange={onChange} />
|
||||
<DialogFooter
|
||||
onCancel={requestClose}
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={onSave}
|
||||
confirmLabel="Save action"
|
||||
confirmBusyLabel="Save action"
|
||||
secondaryAction={
|
||||
onDelete ? (
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
const requestClose = () => {
|
||||
if (
|
||||
!sameTask(task, baseline) &&
|
||||
!window.confirm("Discard unsaved changes?")
|
||||
)
|
||||
return;
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) requestClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save a reusable server task. Shell commands run via{" "}
|
||||
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
|
||||
Runs execute on this SSH task service instance.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TaskEditor task={task} onChange={onChange} />
|
||||
<DialogFooter
|
||||
onCancel={requestClose}
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={onSave}
|
||||
confirmLabel="Save action"
|
||||
confirmBusyLabel="Save action"
|
||||
secondaryAction={
|
||||
onDelete ? (
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionsTab({ instance }: { instance: ServiceInstance }) {
|
||||
const { data: tasks = [] } = useTasks();
|
||||
const saveTask = useSaveTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
const runTask = useRunTask();
|
||||
const [tab, setTab] = useState<ActionTab>("new");
|
||||
const [draft, setDraft] = useState<SavedTaskInput>(emptyTask());
|
||||
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
|
||||
emptyTask(),
|
||||
);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const { data: tasks = [] } = useTasks(instance.id);
|
||||
const saveTask = useSaveTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
const runTask = useRunTask();
|
||||
const [tab, setTab] = useState<ActionTab>("new");
|
||||
const [draft, setDraft] = useState<SavedTaskInput>(() => ({
|
||||
...emptyTask(),
|
||||
service_id: instance.id,
|
||||
}));
|
||||
const [draftBaseline, setDraftBaseline] =
|
||||
useState<SavedTaskInput>(emptyTask());
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
// Default to this instance's service id for task runs.
|
||||
const runServiceId = instance.id;
|
||||
// Default to this instance's service id for task runs.
|
||||
const runServiceId = instance.id;
|
||||
|
||||
const selectedTask = useMemo(
|
||||
() => tasks.find((task) => task.id === tab) ?? null,
|
||||
[tasks, tab],
|
||||
);
|
||||
const selectedRuns = useTaskRuns(selectedTask?.id);
|
||||
const selectedTask = useMemo(
|
||||
() => tasks.find((task) => task.id === tab) ?? null,
|
||||
[tasks, tab],
|
||||
);
|
||||
const selectedRuns = useTaskRuns(selectedTask?.id, instance.id);
|
||||
|
||||
const openEdit = (initial: SavedTaskInput) => {
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setEditOpen(true);
|
||||
};
|
||||
const openEdit = (initial: SavedTaskInput) => {
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const saveDraft = async () => {
|
||||
const saved = await saveTask.mutateAsync(draft);
|
||||
setTab(saved.id);
|
||||
setEditOpen(false);
|
||||
const nextDraft = {
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
task_type: saved.task_type,
|
||||
content: saved.content,
|
||||
enabled: saved.enabled,
|
||||
default_service_id: saved.default_service_id,
|
||||
notes: saved.notes,
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setDraftBaseline(nextDraft);
|
||||
};
|
||||
const saveDraft = async () => {
|
||||
const saved = await saveTask.mutateAsync({
|
||||
...draft,
|
||||
service_id: instance.id,
|
||||
});
|
||||
setTab(saved.id);
|
||||
setEditOpen(false);
|
||||
const nextDraft = {
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
task_type: saved.task_type,
|
||||
content: saved.content,
|
||||
enabled: saved.enabled,
|
||||
service_id: saved.service_id,
|
||||
notes: saved.notes,
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setDraftBaseline(nextDraft);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{saveTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(saveTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{deleteTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(deleteTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{runTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(runTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{saveTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(saveTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{deleteTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(deleteTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{runTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(runTask.error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<SelectionRailCard
|
||||
title="Saved actions"
|
||||
description="Pick a saved task, then edit or run it from the detail pane."
|
||||
contentSx={{}}
|
||||
footer={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => openEdit(emptyTask())}
|
||||
>
|
||||
Add action
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value)}
|
||||
orientation="vertical"
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList variant="line" className="h-fit w-full justify-start">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="group relative w-full group-hover:[&_.rail-edit]:opacity-100"
|
||||
>
|
||||
<TabsTrigger
|
||||
value={task.id}
|
||||
className="w-full justify-start pr-9"
|
||||
onClick={() => setTab(task.id)}
|
||||
onDoubleClick={() => openEdit(initialFromTask(task))}
|
||||
>
|
||||
{task.name}
|
||||
</TabsTrigger>
|
||||
<div className="absolute top-1/2 right-1 -translate-y-1/2">
|
||||
<HoverEditButton
|
||||
onClick={() => openEdit(initialFromTask(task))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</SelectionRailCard>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<SelectionRailCard
|
||||
title="Saved actions"
|
||||
description="Pick a saved task, then edit or run it from the detail pane."
|
||||
contentSx={{}}
|
||||
footer={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
openEdit({ ...emptyTask(), service_id: instance.id })
|
||||
}
|
||||
>
|
||||
Add action
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value)}
|
||||
orientation="vertical"
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList variant="line" className="h-fit w-full justify-start">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="group relative w-full group-hover:[&_.rail-edit]:opacity-100"
|
||||
>
|
||||
<TabsTrigger
|
||||
value={task.id}
|
||||
className="w-full justify-start pr-9"
|
||||
onClick={() => setTab(task.id)}
|
||||
onDoubleClick={() => openEdit(initialFromTask(task))}
|
||||
>
|
||||
{task.name}
|
||||
</TabsTrigger>
|
||||
<div className="absolute top-1/2 right-1 -translate-y-1/2">
|
||||
<HoverEditButton
|
||||
onClick={() => openEdit(initialFromTask(task))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</SelectionRailCard>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedTask ? (
|
||||
<SectionCard
|
||||
title={selectedTask.name}
|
||||
description="Open the editor popup to modify this action."
|
||||
action={
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openEdit(initialFromTask(selectedTask))}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
disabled={runTask.isPending}
|
||||
onClick={async () => {
|
||||
await runTask.mutateAsync({
|
||||
taskId: selectedTask.id,
|
||||
serviceId: runServiceId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{runTask.isPending ? "Running..." : "Run action"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Separator />
|
||||
<p className="text-sm font-semibold">Recent runs</p>
|
||||
{selectedRuns.data?.items?.length ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{selectedRuns.data.items.map((run) => (
|
||||
<Card key={run.id}>
|
||||
<CardContent className="flex flex-col gap-2 p-3">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{run.status}</Badge>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(run.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{run.stdout_tail && (
|
||||
<div className="rounded-lg border border-border px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
stdout
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
{run.stdout_tail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{run.stderr_tail && (
|
||||
<div className="rounded-lg border border-border px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
stderr
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
{run.stderr_tail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{run.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{run.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No runs yet.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
) : (
|
||||
<SectionCard
|
||||
title="No action selected"
|
||||
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup."
|
||||
>
|
||||
{tasks[0] && (
|
||||
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
|
||||
Select first action
|
||||
</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedTask ? (
|
||||
<SectionCard
|
||||
title={selectedTask.name}
|
||||
description="Open the editor popup to modify this action."
|
||||
action={
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openEdit(initialFromTask(selectedTask))}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
disabled={runTask.isPending}
|
||||
onClick={async () => {
|
||||
await runTask.mutateAsync({
|
||||
taskId: selectedTask.id,
|
||||
serviceId: runServiceId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{runTask.isPending ? "Running..." : "Run action"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Separator />
|
||||
<p className="text-sm font-semibold">Recent runs</p>
|
||||
{selectedRuns.data?.items?.length ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{selectedRuns.data.items.map((run) => (
|
||||
<Card key={run.id}>
|
||||
<CardContent className="flex flex-col gap-2 p-3">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{run.status}</Badge>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(run.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{run.stdout_tail && (
|
||||
<div className="rounded-lg border border-border px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
stdout
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
{run.stdout_tail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{run.stderr_tail && (
|
||||
<div className="rounded-lg border border-border px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
stderr
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
{run.stderr_tail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{run.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{run.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No runs yet.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
) : (
|
||||
<SectionCard
|
||||
title="No action selected"
|
||||
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup."
|
||||
>
|
||||
{tasks[0] && (
|
||||
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
|
||||
Select first action
|
||||
</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskDialog
|
||||
open={editOpen}
|
||||
task={draft}
|
||||
baseline={draftBaseline}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onChange={setDraft}
|
||||
onSave={saveDraft}
|
||||
onDelete={
|
||||
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
<TaskDialog
|
||||
open={editOpen}
|
||||
task={draft}
|
||||
baseline={draftBaseline}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onChange={setDraft}
|
||||
onSave={saveDraft}
|
||||
onDelete={
|
||||
draft.id
|
||||
? () =>
|
||||
deleteTask.mutate({
|
||||
taskId: String(draft.id),
|
||||
serviceId: instance.id,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* FilesTab — operational content for the ssh_tasks service page.
|
||||
* FilesTab — operational content for the remote_machine service page.
|
||||
*
|
||||
* Lifted from the old top-level `pages/FileBrowser.impl.tsx`. The machine
|
||||
* selector and `useMonitoringSettings` are removed; the active ssh_tasks
|
||||
* instance id (from the `instance` prop) replaces the machine_id. The initial
|
||||
* selector and `useMonitoringSettings` are removed; the active remote_machine
|
||||
* instance id (from the `instance` prop) replaces the service_id. The initial
|
||||
* path is read from `?path=` search param for deep-link support (resolves the
|
||||
* MediaTab row-click navigation from slice 5). Everything else — directory
|
||||
* listing, path bar, ffprobe preview, job execution — is preserved.
|
||||
|
||||
@@ -202,7 +202,7 @@ function BuildProgress({ value }: { value: number | null }) {
|
||||
|
||||
export function MediaTab({ instance }: { instance: ServiceInstance }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
|
||||
const { data: sshServices = [] } = useServiceInstances("remote_machine");
|
||||
const isSmall = usePrefersSmallScreen();
|
||||
const isMobile = useIsMobile();
|
||||
const serviceId = instance.id;
|
||||
@@ -278,13 +278,13 @@ export function MediaTab({ instance }: { instance: ServiceInstance }) {
|
||||
}, [mediaState.columnVisibility, isSmall]);
|
||||
|
||||
const handleRowClick = (row: MediaItem) => {
|
||||
// Navigate to the ssh_tasks service page with the path query param.
|
||||
// If an ssh_tasks instance exists, open its Files tab; otherwise land
|
||||
// on the ssh_tasks type page (empty state / ServiceTypePage resolver).
|
||||
// Navigate to the remote_machine service page with the path query param.
|
||||
// If an remote_machine instance exists, open its Files tab; otherwise land
|
||||
// on the remote_machine type page (empty state / ServiceTypePage resolver).
|
||||
const sshInstance = sshServices.find((s) => s.enabled);
|
||||
const base = sshInstance
|
||||
? `/services/ssh_tasks/${sshInstance.id}`
|
||||
: "/services/ssh_tasks";
|
||||
? `/services/remote_machine/${sshInstance.id}`
|
||||
: "/services/remote_machine";
|
||||
navigate(`${base}?path=${encodeURIComponent(row.path)}`);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,110 +1,29 @@
|
||||
/**
|
||||
* Prometheus Metrics tab (spec R2.4, R8.2).
|
||||
*
|
||||
* Instance-scoped tab showing Prometheus service health.
|
||||
* usePrometheusStatus is scoped by instance.id; usePrometheusTargets
|
||||
* stays global (returns Node Exporter scrape targets for external Prom).
|
||||
*/
|
||||
import { Radio } from "lucide-react";
|
||||
import {
|
||||
usePrometheusStatus,
|
||||
usePrometheusTargets,
|
||||
} from "../../hooks/useObservability";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { usePrometheusStatus } from "../../hooks/useObservability";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { PrometheusTarget, ServiceInstance } from "../../types";
|
||||
|
||||
function TargetsTable({ targets }: { targets: PrometheusTarget[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{targets.map((target, idx) => (
|
||||
<div key={idx} className="rounded-lg border p-3">
|
||||
<div className="font-mono text-sm">{target.targets.join(", ")}</div>
|
||||
{target.labels && Object.keys(target.labels).length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{Object.entries(target.labels).map(([key, value]) => (
|
||||
<Badge key={key} variant="outline" className="text-[10px]">
|
||||
{key}: {value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import type { ServiceInstance } from "../../types";
|
||||
|
||||
export function MetricsTab({ instance }: { instance: ServiceInstance }) {
|
||||
const {
|
||||
data: status,
|
||||
isLoading: statusLoading,
|
||||
error: statusError,
|
||||
} = usePrometheusStatus(instance.id);
|
||||
const {
|
||||
data: targets,
|
||||
isLoading: targetsLoading,
|
||||
error: targetsError,
|
||||
} = usePrometheusTargets();
|
||||
|
||||
const statusDetail = status?.up
|
||||
? status.version
|
||||
? `version ${status.version}`
|
||||
: "reachable"
|
||||
: statusLoading
|
||||
? "checking…"
|
||||
: "unreachable";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Radio className="h-4 w-4" />
|
||||
Prometheus {statusDetail}
|
||||
</div>
|
||||
|
||||
{statusError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to reach Prometheus</AlertTitle>
|
||||
<AlertDescription>{statusError.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Radio className="h-4 w-4" />
|
||||
Node Exporter Targets ({targets?.length ?? 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{targetsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : !targets || targets.length === 0 ? (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<Radio className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">No Node Exporter targets</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
Enable Node Exporter on an SSH machine in Settings to populate
|
||||
Prometheus scrape targets.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TargetsTable targets={targets} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{targetsError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load targets</AlertTitle>
|
||||
<AlertDescription>{targetsError.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const { data: status, isLoading, error } = usePrometheusStatus(instance.id);
|
||||
const detail = status?.up
|
||||
? status.version
|
||||
? `version ${status.version}`
|
||||
: "reachable"
|
||||
: isLoading
|
||||
? "checking…"
|
||||
: "unreachable";
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Radio className="h-4 w-4" />
|
||||
Prometheus {detail}
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to reach Prometheus</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "ssh-1",
|
||||
service_type: "ssh_tasks",
|
||||
service_type: "remote_machine",
|
||||
name: "Storage Server",
|
||||
config: {},
|
||||
secrets_set: {},
|
||||
@@ -24,7 +24,7 @@ vi.mock("../../../hooks/useSettings", () => ({
|
||||
task_type: "shell",
|
||||
content: "df -h",
|
||||
enabled: true,
|
||||
default_service_id: "",
|
||||
service_id: "",
|
||||
notes: "",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "ssh-1",
|
||||
service_type: "ssh_tasks",
|
||||
service_type: "remote_machine",
|
||||
name: "Storage Server",
|
||||
config: {},
|
||||
secrets_set: {},
|
||||
@@ -40,7 +40,7 @@ vi.mock("../../../hooks/usePersistentState", () => ({
|
||||
]),
|
||||
}));
|
||||
|
||||
function renderTab(path = "/services/ssh_tasks/ssh-1") {
|
||||
function renderTab(path = "/services/remote_machine/ssh-1") {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<FilesTab instance={instance} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MetricsTab } from "../MetricsTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
@@ -25,27 +25,13 @@ vi.mock("../../../hooks/useObservability", () => ({
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
usePrometheusTargets: () => ({
|
||||
data: [
|
||||
{
|
||||
targets: ["10.0.0.5:9100"],
|
||||
labels: { instance: "storage", job: "node_exporter" },
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("MetricsTab", () => {
|
||||
it("renders the Prometheus version and target list", () => {
|
||||
it("renders the Prometheus version without Manage-owned target discovery", () => {
|
||||
render(<MetricsTab instance={instance} />);
|
||||
expect(screen.getByText(/version 2\.52\.0/)).toBeInTheDocument();
|
||||
expect(screen.getByText("10.0.0.5:9100")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the target count in the heading", () => {
|
||||
render(<MetricsTab instance={instance} />);
|
||||
expect(screen.getByText(/Node Exporter Targets \(1\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/version 2\.52\.0/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Node Exporter Targets/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ export function serviceContentTabs(serviceType: string): ContentTab[] {
|
||||
{ label: "Media", Component: MediaTab },
|
||||
{ label: "Requests", Component: RequestsTab },
|
||||
];
|
||||
case "ssh_tasks":
|
||||
case "remote_machine":
|
||||
return [
|
||||
{ label: "Files", Component: FilesTab },
|
||||
{ label: "Actions", Component: ActionsTab },
|
||||
|
||||
Reference in New Issue
Block a user