refactor: unify SSH machines as services

This commit is contained in:
Developer
2026-07-14 20:58:46 +00:00
parent fe90feb1b7
commit 37533dd219
42 changed files with 3103 additions and 4960 deletions
+229 -259
View File
@@ -3,312 +3,282 @@
*/
import type {
MediaCounts,
LibraryCount,
UserDirectoryResponse,
UserMessageResponse,
UserMessageQueueStatus,
NowPlayingSession,
AppVersionInfo,
SSHKey,
SSHKeyInput,
SSHKeyGenerated,
SavedTask,
SavedTaskInput,
SavedTaskRun,
MonitoringMachine,
MonitoringMachineInput,
MediaIndexStatus,
MediaIndexActionResponse,
MediaQueryResponse,
DirectoryListing,
JobTemplate,
JobResult,
ResolvedPath,
ResetLocalDatabaseInput,
ResetLocalDatabaseResponse,
SSHValidationResult,
DashboardShortcut,
DashboardShortcutInput,
AlertmanagerAlertSummary,
AlertmanagerStatus,
PrometheusStatus,
PrometheusTarget,
MediaCounts,
LibraryCount,
UserDirectoryResponse,
UserMessageResponse,
UserMessageQueueStatus,
NowPlayingSession,
AppVersionInfo,
SSHKey,
SSHKeyInput,
SSHKeyGenerated,
SavedTask,
SavedTaskInput,
SavedTaskRun,
MediaIndexStatus,
MediaIndexActionResponse,
MediaQueryResponse,
DirectoryListing,
JobTemplate,
JobResult,
ResolvedPath,
ResetLocalDatabaseInput,
ResetLocalDatabaseResponse,
DashboardShortcut,
DashboardShortcutInput,
AlertmanagerAlertSummary,
AlertmanagerStatus,
PrometheusStatus,
} from "../types";
import {
buildHeaders,
buildUrl,
del,
get,
post,
postForm,
readErrorDetail,
buildHeaders,
buildUrl,
del,
get,
post,
postForm,
readErrorDetail,
} from "./shared";
// Dashboard (Jellyfin-backed; selected via jellyfin_service_id)
export const fetchCounts = (jellyfinServiceId?: string) =>
get<MediaCounts>(
"/api/dashboard/counts",
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
get<MediaCounts>(
"/api/dashboard/counts",
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
export const fetchLibraries = (jellyfinServiceId?: string) =>
get<LibraryCount[]>(
"/api/dashboard/libraries",
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
get<LibraryCount[]>(
"/api/dashboard/libraries",
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
export const fetchActivity = (jellyfinServiceId?: string) =>
get<NowPlayingSession[]>(
"/api/dashboard/activity",
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
get<NowPlayingSession[]>(
"/api/dashboard/activity",
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
export const fetchUsers = (jellyfinServiceId?: string) =>
get<UserDirectoryResponse>(
"/api/users",
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
get<UserDirectoryResponse>(
"/api/users",
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
// Backward-compatible alias used by older hooks/components.
export const fetchNowPlaying = fetchActivity;
// Monitoring
export const fetchMonitoringMachines = () =>
get<MonitoringMachine[]>("/api/monitoring/machines");
// General
export const fetchAppVersion = () => get<AppVersionInfo>("/api/version");
export const fetchDashboardShortcuts = () =>
get<DashboardShortcut[]>("/api/dashboard/shortcuts");
get<DashboardShortcut[]>("/api/dashboard/shortcuts");
export const saveDashboardShortcut = (shortcut: DashboardShortcutInput) =>
fetch(
buildUrl(
shortcut.id
? `/api/dashboard/shortcuts/${encodeURIComponent(shortcut.id)}`
: "/api/dashboard/shortcuts",
),
{
method: shortcut.id ? "PUT" : "POST",
headers: buildHeaders(true),
body: JSON.stringify(shortcut),
},
).then(async (response) => {
if (!response.ok) {
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
}
return response.json() as Promise<DashboardShortcut>;
});
fetch(
buildUrl(
shortcut.id
? `/api/dashboard/shortcuts/${encodeURIComponent(shortcut.id)}`
: "/api/dashboard/shortcuts",
),
{
method: shortcut.id ? "PUT" : "POST",
headers: buildHeaders(true),
body: JSON.stringify(shortcut),
},
).then(async (response) => {
if (!response.ok) {
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
}
return response.json() as Promise<DashboardShortcut>;
});
export const deleteDashboardShortcut = (shortcutId: string) =>
del<{ status: string }>(
`/api/dashboard/shortcuts/${encodeURIComponent(shortcutId)}`,
);
export const fetchMonitoringSettings = () =>
get<MonitoringMachine[]>("/api/settings/machines");
del<{ status: string }>(
`/api/dashboard/shortcuts/${encodeURIComponent(shortcutId)}`,
);
export const fetchSSHKeys = () => get<SSHKey[]>("/api/settings/ssh-keys");
export const generateSSHKey = (payload: {
name: string;
passphrase: string;
notes: string;
bits?: number;
name: string;
passphrase: string;
notes: string;
bits?: number;
}) => post<SSHKeyGenerated>("/api/settings/ssh-keys/generate", payload);
export const saveSSHKey = (key: SSHKeyInput) =>
fetch(
buildUrl(
key.id
? `/api/settings/ssh-keys/${encodeURIComponent(key.id)}`
: "/api/settings/ssh-keys",
),
{
method: key.id ? "PUT" : "POST",
headers: buildHeaders(true),
body: JSON.stringify(key),
},
).then(async (response) => {
if (!response.ok) {
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
}
return response.json() as Promise<SSHKey>;
});
fetch(
buildUrl(
key.id
? `/api/settings/ssh-keys/${encodeURIComponent(key.id)}`
: "/api/settings/ssh-keys",
),
{
method: key.id ? "PUT" : "POST",
headers: buildHeaders(true),
body: JSON.stringify(key),
},
).then(async (response) => {
if (!response.ok) {
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
}
return response.json() as Promise<SSHKey>;
});
export const deleteSSHKey = (keyId: string) =>
del<{ status: string }>(
`/api/settings/ssh-keys/${encodeURIComponent(keyId)}`,
);
del<{ status: string }>(
`/api/settings/ssh-keys/${encodeURIComponent(keyId)}`,
);
export const fetchSavedTasks = () => get<SavedTask[]>("/api/tasks");
export const fetchSavedTaskRuns = (taskId: string, limit = 10) =>
get<{ items: SavedTaskRun[]; total: number }>(
`/api/tasks/${encodeURIComponent(taskId)}/runs`,
{
limit: String(limit),
},
);
export const fetchSavedTasks = (serviceId: string) =>
get<SavedTask[]>("/api/tasks", { service_id: serviceId });
export const fetchSavedTaskRuns = (
taskId: string,
serviceId: string,
limit = 10,
) =>
get<{ items: SavedTaskRun[]; total: number }>(
`/api/tasks/${encodeURIComponent(taskId)}/runs`,
{
service_id: serviceId,
limit: String(limit),
},
);
export const saveTask = (task: SavedTaskInput) =>
fetch(
buildUrl(
task.id ? `/api/tasks/${encodeURIComponent(task.id)}` : "/api/tasks",
),
{
method: task.id ? "PUT" : "POST",
headers: buildHeaders(true),
body: JSON.stringify(task),
},
).then(async (response) => {
if (!response.ok) {
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
}
return response.json() as Promise<SavedTask>;
});
export const deleteTask = (taskId: string) =>
del<{ status: string }>(`/api/tasks/${encodeURIComponent(taskId)}`);
export const runTask = (taskId: string, serviceId?: string) =>
post<{
task_id: string;
task_name: string;
service_id: string;
service_name: string;
task_type: string;
exit_status: number;
stdout: string;
stderr: string;
}>(
serviceId
? `/api/tasks/run?service_id=${encodeURIComponent(serviceId)}`
: "/api/tasks/run",
{ task_id: taskId },
);
fetch(
buildUrl(
task.id ? `/api/tasks/${encodeURIComponent(task.id)}` : "/api/tasks",
),
{
method: task.id ? "PUT" : "POST",
headers: buildHeaders(true),
body: JSON.stringify(task),
},
).then(async (response) => {
if (!response.ok) {
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
}
return response.json() as Promise<SavedTask>;
});
export const deleteTask = (taskId: string, serviceId: string) =>
del<{ status: string }>(
`/api/tasks/${encodeURIComponent(taskId)}?service_id=${encodeURIComponent(serviceId)}`,
);
export const runTask = (taskId: string, serviceId: string) =>
post<{
task_id: string;
task_name: string;
service_id: string;
service_name: string;
task_type: string;
exit_status: number;
stdout: string;
stderr: string;
}>(`/api/tasks/run?service_id=${encodeURIComponent(serviceId)}`, {
task_id: taskId,
});
export const saveMonitoringMachine = (machine: MonitoringMachineInput) =>
fetch(
buildUrl(
machine.id
? `/api/settings/machines/${encodeURIComponent(machine.id)}`
: "/api/settings/machines",
),
{
method: machine.id ? "PUT" : "POST",
headers: buildHeaders(true),
body: JSON.stringify(machine),
},
).then(async (response) => {
if (!response.ok) {
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
}
return response.json() as Promise<MonitoringMachine>;
});
export const testMonitoringMachineSSH = (machine: MonitoringMachineInput) =>
post<SSHValidationResult>("/api/settings/machines/test-ssh", machine);
export const deleteMonitoringMachine = (machineId: string) =>
del<{ status: string }>(
`/api/settings/machines/${encodeURIComponent(machineId)}`,
);
export const resetLocalDatabase = (payload: ResetLocalDatabaseInput) =>
post<ResetLocalDatabaseResponse>(
"/api/settings/reset-local-database",
payload,
);
post<ResetLocalDatabaseResponse>(
"/api/settings/reset-local-database",
payload,
);
// Media
export const fetchMediaStatus = (jellyfinServiceId?: string) =>
get<MediaIndexStatus>(
"/api/media/status",
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
get<MediaIndexStatus>(
"/api/media/status",
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
export const buildMediaIndex = (jellyfinServiceId?: string) =>
post<MediaIndexActionResponse>(
jellyfinServiceId
? `/api/media/build?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/build",
);
post<MediaIndexActionResponse>(
jellyfinServiceId
? `/api/media/build?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/build",
);
export const stopMediaIndexBuild = (jellyfinServiceId?: string) =>
post<MediaIndexActionResponse>(
jellyfinServiceId
? `/api/media/stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/stop",
);
post<MediaIndexActionResponse>(
jellyfinServiceId
? `/api/media/stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/stop",
);
export const forceStopMediaIndexBuild = (jellyfinServiceId?: string) =>
post<MediaIndexActionResponse>(
jellyfinServiceId
? `/api/media/force-stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/force-stop",
);
post<MediaIndexActionResponse>(
jellyfinServiceId
? `/api/media/force-stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/force-stop",
);
export const queryMedia = (params: {
libraries?: string;
types?: string;
search?: string;
hdr_filter?: string;
sort_key?: string;
sort_order?: string;
limit?: number;
offset?: number;
jellyfinServiceId?: string;
libraries?: string;
types?: string;
search?: string;
hdr_filter?: string;
sort_key?: string;
sort_order?: string;
limit?: number;
offset?: number;
jellyfinServiceId?: string;
}) =>
get<MediaQueryResponse>("/api/media/query", {
libraries: params.libraries || "",
types: params.types || "Movie,Episode",
search: params.search || "",
hdr_filter: params.hdr_filter || "All",
sort_key: params.sort_key || "title",
sort_order: params.sort_order || "Ascending",
limit: String(params.limit || 100),
offset: String(params.offset || 0),
...(params.jellyfinServiceId
? { jellyfin_service_id: params.jellyfinServiceId }
: {}),
});
get<MediaQueryResponse>("/api/media/query", {
libraries: params.libraries || "",
types: params.types || "Movie,Episode",
search: params.search || "",
hdr_filter: params.hdr_filter || "All",
sort_key: params.sort_key || "title",
sort_order: params.sort_order || "Ascending",
limit: String(params.limit || 100),
offset: String(params.offset || 0),
...(params.jellyfinServiceId
? { jellyfin_service_id: params.jellyfinServiceId }
: {}),
});
// Files
export const fetchDirectoryListing = (path: string, machineId?: string) =>
get<DirectoryListing>("/api/files/list", {
path,
...(machineId ? { machine_id: machineId } : {}),
});
export const fetchFfprobe = (path: string, machineId?: string) =>
get<Record<string, unknown>>("/api/files/ffprobe", {
path,
...(machineId ? { machine_id: machineId } : {}),
});
export const fetchStat = (path: string, machineId?: string) =>
get<{ path: string; output: string }>("/api/files/stat", {
path,
...(machineId ? { machine_id: machineId } : {}),
});
export const resolvePath = (path: string, machineId?: string) =>
get<ResolvedPath>("/api/files/resolve-path", {
path,
...(machineId ? { machine_id: machineId } : {}),
});
export const fetchDirectoryListing = (path: string, serviceId?: string) =>
get<DirectoryListing>("/api/files/list", {
path,
...(serviceId ? { service_id: serviceId } : {}),
});
export const fetchFfprobe = (path: string, serviceId?: string) =>
get<Record<string, unknown>>("/api/files/ffprobe", {
path,
...(serviceId ? { service_id: serviceId } : {}),
});
export const fetchStat = (path: string, serviceId?: string) =>
get<{ path: string; output: string }>("/api/files/stat", {
path,
...(serviceId ? { service_id: serviceId } : {}),
});
export const resolvePath = (path: string, serviceId?: string) =>
get<ResolvedPath>("/api/files/resolve-path", {
path,
...(serviceId ? { service_id: serviceId } : {}),
});
// Jobs
export const fetchJobTemplates = () =>
get<JobTemplate[]>("/api/jobs/templates");
export const runJob = (jobKey: string, path: string, machineId?: string) =>
post<JobResult>(
machineId
? `/api/jobs/run?machine_id=${encodeURIComponent(machineId)}`
: "/api/jobs/run",
{ job_key: jobKey, path },
);
get<JobTemplate[]>("/api/jobs/templates");
export const runJob = (jobKey: string, path: string, serviceId?: string) =>
post<JobResult>(
serviceId
? `/api/jobs/run?service_id=${encodeURIComponent(serviceId)}`
: "/api/jobs/run",
{ job_key: jobKey, path },
);
export const fetchUserMessageQueueStatus = () =>
get<UserMessageQueueStatus>("/api/users/message/status");
get<UserMessageQueueStatus>("/api/users/message/status");
export const sendUserMessage = (formData: FormData) =>
postForm<UserMessageResponse>("/api/users/message", formData);
postForm<UserMessageResponse>("/api/users/message", formData);
// Observability summary endpoints
export const fetchAlertmanagerAlerts = (serviceId?: string) =>
get<AlertmanagerAlertSummary>(
"/api/monitoring/alerts",
serviceId ? { service_id: serviceId } : undefined,
);
get<AlertmanagerAlertSummary>(
"/api/monitoring/alerts",
serviceId ? { service_id: serviceId } : undefined,
);
export const fetchAlertmanagerStatus = (serviceId?: string) =>
get<AlertmanagerStatus>(
"/api/monitoring/alertmanager-status",
serviceId ? { service_id: serviceId } : undefined,
);
get<AlertmanagerStatus>(
"/api/monitoring/alertmanager-status",
serviceId ? { service_id: serviceId } : undefined,
);
export const fetchPrometheusStatus = (serviceId?: string) =>
get<PrometheusStatus>(
"/api/monitoring/prometheus-status",
serviceId ? { service_id: serviceId } : undefined,
);
export const fetchPrometheusTargets = () =>
get<PrometheusTarget[]>("/api/monitoring/prometheus-targets");
get<PrometheusStatus>(
"/api/monitoring/prometheus-status",
serviceId ? { service_id: serviceId } : undefined,
);
File diff suppressed because it is too large Load Diff
+35 -35
View File
@@ -1,49 +1,49 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import {
fetchDirectoryListing,
fetchFfprobe,
fetchStat,
fetchJobTemplates,
runJob,
fetchDirectoryListing,
fetchFfprobe,
fetchStat,
fetchJobTemplates,
runJob,
} from "../api/client";
export function useDirectoryListing(path: string, machineId?: string) {
return useQuery({
queryKey: ["files", "list", path, machineId ?? "default"],
queryFn: () => fetchDirectoryListing(path, machineId),
enabled: !!path,
staleTime: 30_000,
});
export function useDirectoryListing(path: string, serviceId?: string) {
return useQuery({
queryKey: ["files", "list", path, serviceId ?? "default"],
queryFn: () => fetchDirectoryListing(path, serviceId),
enabled: !!path,
staleTime: 30_000,
});
}
export function useFfprobe(path: string, enabled = false, machineId?: string) {
return useQuery({
queryKey: ["files", "ffprobe", path, machineId ?? "default"],
queryFn: () => fetchFfprobe(path, machineId),
enabled: enabled && !!path,
staleTime: 5 * 60_000,
});
export function useFfprobe(path: string, enabled = false, serviceId?: string) {
return useQuery({
queryKey: ["files", "ffprobe", path, serviceId ?? "default"],
queryFn: () => fetchFfprobe(path, serviceId),
enabled: enabled && !!path,
staleTime: 5 * 60_000,
});
}
export function useStat(path: string, enabled = false, machineId?: string) {
return useQuery({
queryKey: ["files", "stat", path, machineId ?? "default"],
queryFn: () => fetchStat(path, machineId),
enabled: enabled && !!path,
});
export function useStat(path: string, enabled = false, serviceId?: string) {
return useQuery({
queryKey: ["files", "stat", path, serviceId ?? "default"],
queryFn: () => fetchStat(path, serviceId),
enabled: enabled && !!path,
});
}
export function useJobTemplates() {
return useQuery({
queryKey: ["jobs", "templates"],
queryFn: fetchJobTemplates,
staleTime: 60_000,
});
return useQuery({
queryKey: ["jobs", "templates"],
queryFn: fetchJobTemplates,
staleTime: 60_000,
});
}
export function useRunJob(machineId?: string) {
return useMutation({
mutationFn: ({ jobKey, path }: { jobKey: string; path: string }) =>
runJob(jobKey, path, machineId),
});
export function useRunJob(serviceId?: string) {
return useMutation({
mutationFn: ({ jobKey, path }: { jobKey: string; path: string }) =>
runJob(jobKey, path, serviceId),
});
}
+24 -46
View File
@@ -1,58 +1,36 @@
import { useQuery } from "@tanstack/react-query";
import {
fetchAlertmanagerAlerts,
fetchAlertmanagerStatus,
fetchPrometheusStatus,
fetchPrometheusTargets,
fetchMonitoringMachines,
fetchAlertmanagerAlerts,
fetchAlertmanagerStatus,
fetchPrometheusStatus,
} from "../api/client";
export function useAlertmanagerAlerts(serviceId?: string) {
return useQuery({
queryKey: ["observability", "alerts", serviceId ?? ""],
queryFn: () => fetchAlertmanagerAlerts(serviceId),
retry: 2,
staleTime: 10_000,
refetchInterval: 15_000,
});
return useQuery({
queryKey: ["observability", "alerts", serviceId ?? ""],
queryFn: () => fetchAlertmanagerAlerts(serviceId),
retry: 2,
staleTime: 10_000,
refetchInterval: 15_000,
});
}
export function useAlertmanagerStatus(serviceId?: string) {
return useQuery({
queryKey: ["observability", "alertmanager-status", serviceId ?? ""],
queryFn: () => fetchAlertmanagerStatus(serviceId),
retry: 2,
staleTime: 10_000,
refetchInterval: 30_000,
});
return useQuery({
queryKey: ["observability", "alertmanager-status", serviceId ?? ""],
queryFn: () => fetchAlertmanagerStatus(serviceId),
retry: 2,
staleTime: 10_000,
refetchInterval: 30_000,
});
}
export function usePrometheusStatus(serviceId?: string) {
return useQuery({
queryKey: ["observability", "prometheus-status", serviceId ?? ""],
queryFn: () => fetchPrometheusStatus(serviceId),
retry: 2,
staleTime: 10_000,
refetchInterval: 30_000,
});
}
export function usePrometheusTargets() {
return useQuery({
queryKey: ["observability", "prometheus-targets"],
queryFn: fetchPrometheusTargets,
retry: 2,
staleTime: 10_000,
refetchInterval: 30_000,
});
}
export function useMonitoringMachines() {
return useQuery({
queryKey: ["monitoring", "machines"],
queryFn: fetchMonitoringMachines,
retry: 2,
staleTime: 10_000,
refetchInterval: 30_000,
});
return useQuery({
queryKey: ["observability", "prometheus-status", serviceId ?? ""],
queryFn: () => fetchPrometheusStatus(serviceId),
retry: 2,
staleTime: 10_000,
refetchInterval: 30_000,
});
}
+99 -135
View File
@@ -1,168 +1,132 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
deleteMonitoringMachine,
deleteSSHKey,
fetchMonitoringSettings,
fetchSSHKeys,
fetchSavedTaskRuns,
fetchSavedTasks,
generateSSHKey,
resetLocalDatabase,
saveMonitoringMachine,
saveSSHKey,
saveTask,
deleteTask,
runTask,
testMonitoringMachineSSH,
deleteSSHKey,
fetchSSHKeys,
fetchSavedTaskRuns,
fetchSavedTasks,
generateSSHKey,
resetLocalDatabase,
saveSSHKey,
saveTask,
deleteTask,
runTask,
} from "../api/client";
import type {
MonitoringMachineInput,
ResetLocalDatabaseInput,
SavedTaskInput,
SSHKeyInput,
SSHValidationResult,
ResetLocalDatabaseInput,
SavedTaskInput,
SSHKeyInput,
} from "../types";
export function useMonitoringSettings() {
return useQuery({
queryKey: ["settings", "monitoring-machines"],
queryFn: fetchMonitoringSettings,
refetchInterval: 30_000,
});
}
export function useSSHKeys() {
return useQuery({
queryKey: ["settings", "ssh-keys"],
queryFn: fetchSSHKeys,
refetchInterval: 30_000,
});
return useQuery({
queryKey: ["settings", "ssh-keys"],
queryFn: fetchSSHKeys,
refetchInterval: 30_000,
});
}
export function useGenerateSSHKey() {
return useMutation({
mutationFn: (payload: {
name: string;
passphrase: string;
notes: string;
bits?: number;
}) => generateSSHKey(payload),
});
return useMutation({
mutationFn: (payload: {
name: string;
passphrase: string;
notes: string;
bits?: number;
}) => generateSSHKey(payload),
});
}
export function useSaveSSHKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (key: SSHKeyInput) => saveSSHKey(key),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (key: SSHKeyInput) => saveSSHKey(key),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
}
export function useDeleteSSHKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (keyId: string) => deleteSSHKey(keyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (keyId: string) => deleteSSHKey(keyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
},
});
}
export function useTasks() {
return useQuery({
queryKey: ["tasks"],
queryFn: fetchSavedTasks,
refetchInterval: 30_000,
});
export function useTasks(serviceId?: string) {
return useQuery({
queryKey: ["tasks", serviceId ?? "none"],
queryFn: () => fetchSavedTasks(serviceId ?? ""),
enabled: Boolean(serviceId),
refetchInterval: 30_000,
});
}
export function useTaskRuns(taskId?: string) {
return useQuery({
queryKey: ["tasks", taskId ?? "none", "runs"],
queryFn: () => fetchSavedTaskRuns(taskId ?? ""),
enabled: Boolean(taskId),
refetchInterval: 30_000,
});
export function useTaskRuns(taskId?: string, serviceId?: string) {
return useQuery({
queryKey: ["tasks", serviceId ?? "none", taskId ?? "none", "runs"],
queryFn: () => fetchSavedTaskRuns(taskId ?? "", serviceId ?? ""),
enabled: Boolean(taskId && serviceId),
refetchInterval: 30_000,
});
}
export function useSaveTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (task: SavedTaskInput) => saveTask(task),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (task: SavedTaskInput) => saveTask(task),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
}
export function useDeleteTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (taskId: string) => deleteTask(taskId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
taskId,
serviceId,
}: {
taskId: string;
serviceId: string;
}) => deleteTask(taskId, serviceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
}
export function useRunTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
taskId,
serviceId,
}: {
taskId: string;
serviceId?: string;
}) => runTask(taskId, serviceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
}
export function useSaveMonitoringMachine() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (machine: MonitoringMachineInput) =>
saveMonitoringMachine(machine),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
},
});
}
export function useTestMonitoringMachineSSH() {
return useMutation<SSHValidationResult, Error, MonitoringMachineInput>({
mutationFn: testMonitoringMachineSSH,
});
}
export function useDeleteMonitoringMachine() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (machineId: string) => deleteMonitoringMachine(machineId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
taskId,
serviceId,
}: {
taskId: string;
serviceId: string;
}) => runTask(taskId, serviceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
}
export function useResetLocalDatabase() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: ResetLocalDatabaseInput) =>
resetLocalDatabase(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
queryClient.invalidateQueries({ queryKey: ["media"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: ResetLocalDatabaseInput) =>
resetLocalDatabase(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
queryClient.invalidateQueries({ queryKey: ["media"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, expect, it } from "vitest";
import { configuredNavEntries, SERVICE_TYPE_NAV_ENTRIES } from "../navEntries";
describe("navEntries", () => {
@@ -13,37 +13,44 @@ describe("navEntries", () => {
expect(entries[0].path).toBe("/services/jellyfin");
});
it("returns one SSH Tasks entry when ssh_tasks is configured", () => {
const entries = configuredNavEntries(new Set(["ssh_tasks"]));
expect(entries).toHaveLength(1);
expect(entries[0].label).toBe("SSH Tasks");
it("does not expose remote machines as a top-level entry", () => {
expect(configuredNavEntries(new Set(["remote_machine"]))).toEqual([]);
});
it("returns all observability entries", () => {
const entries = configuredNavEntries(
new Set(["alertmanager", "prometheus"]),
);
expect(entries.map((e) => e.label)).toEqual(["Alertmanager", "Prometheus"]);
expect(entries.map((entry) => entry.label)).toEqual([
"Alertmanager",
"Prometheus",
]);
});
it("returns Backups + Authentik when configured", () => {
const entries = configuredNavEntries(new Set(["backups", "authentik"]));
expect(entries.map((e) => e.label)).toEqual(["Backups", "Authentik"]);
expect(entries.map((entry) => entry.label)).toEqual([
"Backups",
"Authentik",
]);
});
it("nextcloud has no nav entries in the static map", () => {
it("keeps non-operational service types out of the static map", () => {
expect(
SERVICE_TYPE_NAV_ENTRIES.filter((e) => e.serviceType === "nextcloud"),
SERVICE_TYPE_NAV_ENTRIES.filter(
(entry) =>
entry.serviceType === "nextcloud" ||
entry.serviceType === "remote_machine",
),
).toEqual([]);
});
it("preserves declaration order across mixed types", () => {
it("preserves declaration order across mixed navigable types", () => {
const entries = configuredNavEntries(
new Set(["authentik", "ssh_tasks", "jellyfin"]),
new Set(["authentik", "remote_machine", "jellyfin"]),
);
expect(entries.map((e) => e.label)).toEqual([
expect(entries.map((entry) => entry.label)).toEqual([
"Jellyfin",
"SSH Tasks",
"Authentik",
]);
});
-7
View File
@@ -12,7 +12,6 @@ import {
GanttChartSquare,
Magnet,
Monitor,
Server,
Users,
type LucideIcon,
} from "lucide-react";
@@ -37,12 +36,6 @@ export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
icon: Monitor,
path: "/services/jellyfin",
},
{
serviceType: "ssh_tasks",
label: "SSH Tasks",
icon: Server,
path: "/services/ssh_tasks",
},
{
serviceType: "alertmanager",
label: "Alertmanager",
+2 -2
View File
@@ -16,7 +16,7 @@ describe("service registry", () => {
"nextcloud",
"prometheus",
"qbittorrent",
"ssh_tasks",
"remote_machine",
]);
});
@@ -30,7 +30,7 @@ describe("service registry", () => {
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
"active_alerts",
]);
expect(SERVICE_REGISTRY.ssh_tasks.widgets.map((w) => w.kind)).toEqual([
expect(SERVICE_REGISTRY.remote_machine.widgets.map((w) => w.kind)).toEqual([
"task_output",
]);
expect(SERVICE_REGISTRY.nextcloud.widgets).toEqual([]);
+4 -4
View File
@@ -310,10 +310,10 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
description: "Self-hosted files and collaboration.",
widgets: [],
},
ssh_tasks: {
serviceType: "ssh_tasks",
name: "SSH task runner",
description: "Run reusable saved tasks over SSH and keep run history.",
remote_machine: {
serviceType: "remote_machine",
name: "Remote machine",
description: "SSH transport for files and reusable actions.",
widgets: [
{
kind: "task_output",
+2 -2
View File
@@ -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) =>
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: {
+17 -103
View File
@@ -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();
});
});
+404 -391
View File
@@ -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>
);
}
+3 -3
View File
@@ -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.
+6 -6
View File
@@ -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)}`);
};
+24 -105
View File
@@ -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();
});
});
+1 -1
View File
@@ -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 },
+353 -402
View File
@@ -3,541 +3,492 @@
*/
export interface MediaCounts {
movies: number;
series: number;
episodes: number;
movies: number;
series: number;
episodes: number;
}
export interface LibraryCount {
library: string;
type: string;
movies: number;
series: number;
episodes: number;
total: number;
library: string;
type: string;
movies: number;
series: number;
episodes: number;
total: number;
}
export interface UserDirectoryItem {
jellyfin_id: string;
username: string;
display_name: string;
email: string;
email_source: string;
avatar: string;
avatar_source: string;
contactable: boolean;
source: string;
source_summary: string;
name_source: string;
access_source: string;
jellyseerr_user_id: number | null;
jellyseerr_username: string;
user_type: number | null;
user_type_label: string;
role: string;
permissions: number;
permissions_label: string;
request_count: number | null;
jellyfin_id: string;
username: string;
display_name: string;
email: string;
email_source: string;
avatar: string;
avatar_source: string;
contactable: boolean;
source: string;
source_summary: string;
name_source: string;
access_source: string;
jellyseerr_user_id: number | null;
jellyseerr_username: string;
user_type: number | null;
user_type_label: string;
role: string;
permissions: number;
permissions_label: string;
request_count: number | null;
}
export interface UserDirectoryResponse {
items: UserDirectoryItem[];
total: number;
jellyseerr_configured: boolean;
jellyseerr_available: boolean;
jellyseerr_error: string;
jellyseerr_jellyfin_user_count: number;
jellyseerr_user_count: number;
enriched_count: number;
items: UserDirectoryItem[];
total: number;
jellyseerr_configured: boolean;
jellyseerr_available: boolean;
jellyseerr_error: string;
jellyseerr_jellyfin_user_count: number;
jellyseerr_user_count: number;
enriched_count: number;
}
export interface UserMessageResponse {
status: string;
request_id: string;
subject: string;
from_address: string;
recipient_count: number;
attachment_count: number;
recipient_labels: string[];
skipped: Array<{ jellyfin_id: string; reason: string }>;
status: string;
request_id: string;
subject: string;
from_address: string;
recipient_count: number;
attachment_count: number;
recipient_labels: string[];
skipped: Array<{ jellyfin_id: string; reason: string }>;
}
export interface UserMessageQueueStatus {
state: "idle" | "busy" | "error" | "stopped";
worker_running: boolean;
stop_requested: boolean;
pending_count: number;
active_request_id: string | null;
last_request_id: string | null;
last_result: string | null;
last_error: string;
last_error_at: number | null;
last_success_at: number | null;
last_activity_at: number | null;
sent_count: number;
failed_count: number;
state: "idle" | "busy" | "error" | "stopped";
worker_running: boolean;
stop_requested: boolean;
pending_count: number;
active_request_id: string | null;
last_request_id: string | null;
last_result: string | null;
last_error: string;
last_error_at: number | null;
last_success_at: number | null;
last_activity_at: number | null;
sent_count: number;
failed_count: number;
}
export interface NowPlayingSession {
user: string;
title: string;
type: string;
state: string;
transcoding: string;
transcoding_type: string;
device: string;
session_id: string;
user: string;
title: string;
type: string;
state: string;
transcoding: string;
transcoding_type: string;
device: string;
session_id: string;
}
export interface SSHKey {
id: string;
name: string;
private_key_set: boolean;
passphrase_set: boolean;
public_key: string;
fingerprint: string;
usage_count: number;
notes: string;
id: string;
name: string;
private_key_set: boolean;
passphrase_set: boolean;
public_key: string;
fingerprint: string;
usage_count: number;
notes: string;
}
export interface SSHKeyInput {
id?: string | null;
name: string;
private_key: string;
passphrase: string;
public_key: string;
fingerprint: string;
notes: string;
id?: string | null;
name: string;
private_key: string;
passphrase: string;
public_key: string;
fingerprint: string;
notes: string;
}
export interface SSHKeyGenerated {
name: string;
private_key: string;
passphrase: string;
notes: string;
public_key: string;
fingerprint: string;
usage_count: number;
name: string;
private_key: string;
passphrase: string;
notes: string;
public_key: string;
fingerprint: string;
usage_count: number;
}
export interface SavedTask {
id: string;
name: string;
task_type: "shell" | "python";
content: string;
enabled: boolean;
default_service_id: string;
notes: string;
created_at: number;
updated_at: number;
id: string;
name: string;
task_type: "shell" | "python";
content: string;
enabled: boolean;
service_id: string;
notes: string;
created_at: number;
updated_at: number;
}
export interface SavedTaskInput {
id?: string | null;
name: string;
task_type: "shell" | "python";
content: string;
enabled: boolean;
default_service_id: string;
notes: string;
id?: string | null;
name: string;
task_type: "shell" | "python";
content: string;
enabled: boolean;
service_id: string;
notes: string;
}
export interface SavedTaskRun {
id: string;
task_id: string;
service_id: string;
status: "success" | "failure" | "error" | "timeout" | string;
exit_status: number | null;
created_at: number;
duration_ms: number;
stdout_tail: string;
stderr_tail: string;
error: string;
}
export interface MonitoringMachine {
id: string;
name: string;
mode: "local" | "ssh";
enabled: boolean;
services: string[];
host: string;
port: number;
username: string;
key_directory: string;
key_name: string;
ssh_key_id: string;
ssh_private_key_set: boolean;
ssh_private_key_passphrase_set: boolean;
password_set: boolean;
notes: string;
}
export interface MonitoringMachineInput {
id?: string | null;
name: string;
mode: "local" | "ssh";
enabled: boolean;
services: string[];
host: string;
port: number;
username: string;
key_directory: string;
key_name: string;
ssh_key_id: string;
ssh_private_key: string;
ssh_private_key_passphrase: string;
password: string;
notes: string;
id: string;
task_id: string;
service_id: string;
status: "success" | "failure" | "error" | "timeout" | string;
exit_status: number | null;
created_at: number;
duration_ms: number;
stdout_tail: string;
stderr_tail: string;
error: string;
}
export interface ResetLocalDatabaseInput {
confirm_phrase: string;
acknowledge_settings_loss: boolean;
acknowledge_media_index_loss: boolean;
acknowledge_irreversible: boolean;
confirm_phrase: string;
acknowledge_settings_loss: boolean;
acknowledge_media_index_loss: boolean;
acknowledge_irreversible: boolean;
}
export interface ResetLocalDatabaseResponse {
status: string;
settings_db_removed: boolean;
media_index_removed: boolean;
settings_files: string[];
media_index_files: string[];
}
export interface SSHValidationResult {
status: string;
message: string;
host: string;
port: number;
known_hosts_updated: boolean;
status: string;
settings_db_removed: boolean;
media_index_removed: boolean;
settings_files: string[];
media_index_files: string[];
}
export interface AppVersionInfo {
app: string;
backend_version: string;
backend_build: string;
backend_label: string;
app: string;
backend_version: string;
backend_build: string;
backend_label: string;
}
export interface MediaIndexStatus {
exists: boolean;
item_count: number;
updated_at: number | null;
updated_at_label: string;
build_duration_seconds: number | null;
build_running: boolean;
build_stage: string;
build_message: string;
build_progress: number | null;
build_items_processed: number;
build_items_total: number;
build_current_library: string;
build_library_index: number;
build_libraries_total: number;
build_library_progress: number | null;
build_library_items_processed: number;
build_library_items_total: number;
build_elapsed_seconds: number | null;
build_eta_seconds: number | null;
build_library_elapsed_seconds: number | null;
build_library_eta_seconds: number | null;
build_cancel_requested: boolean;
build_pid: number | null;
build_error: string;
exists: boolean;
item_count: number;
updated_at: number | null;
updated_at_label: string;
build_duration_seconds: number | null;
build_running: boolean;
build_stage: string;
build_message: string;
build_progress: number | null;
build_items_processed: number;
build_items_total: number;
build_current_library: string;
build_library_index: number;
build_libraries_total: number;
build_library_progress: number | null;
build_library_items_processed: number;
build_library_items_total: number;
build_elapsed_seconds: number | null;
build_eta_seconds: number | null;
build_library_elapsed_seconds: number | null;
build_library_eta_seconds: number | null;
build_cancel_requested: boolean;
build_pid: number | null;
build_error: string;
}
export interface MediaIndexActionResponse {
status: string;
build_running: boolean;
build_stage: string;
build_message: string;
build_progress: number | null;
build_items_processed: number;
build_items_total: number;
build_current_library: string;
build_library_index: number;
build_libraries_total: number;
build_library_progress: number | null;
build_library_items_processed: number;
build_library_items_total: number;
build_elapsed_seconds: number | null;
build_eta_seconds: number | null;
build_library_elapsed_seconds: number | null;
build_library_eta_seconds: number | null;
build_cancel_requested: boolean;
build_pid: number | null;
build_error: string;
status: string;
build_running: boolean;
build_stage: string;
build_message: string;
build_progress: number | null;
build_items_processed: number;
build_items_total: number;
build_current_library: string;
build_library_index: number;
build_libraries_total: number;
build_library_progress: number | null;
build_library_items_processed: number;
build_library_items_total: number;
build_elapsed_seconds: number | null;
build_eta_seconds: number | null;
build_library_elapsed_seconds: number | null;
build_library_eta_seconds: number | null;
build_cancel_requested: boolean;
build_pid: number | null;
build_error: string;
}
export interface MediaItem {
id: string;
title: string;
series: string;
season: string;
episode: number | null;
type: string;
year: number | null;
runtime_min: number | null;
size: string;
bitrate: string;
hdr: string;
video: string;
resolution: string;
date_added: string;
library: string;
path: string;
id: string;
title: string;
series: string;
season: string;
episode: number | null;
type: string;
year: number | null;
runtime_min: number | null;
size: string;
bitrate: string;
hdr: string;
video: string;
resolution: string;
date_added: string;
library: string;
path: string;
}
export interface MediaQueryResponse {
items: MediaItem[];
total: number;
limit: number;
offset: number;
items: MediaItem[];
total: number;
limit: number;
offset: number;
}
export interface FileEntry {
type: string;
size: number;
mtime: number;
name: string;
type: string;
size: number;
mtime: number;
name: string;
}
export interface DirectoryListing {
path: string;
entries: FileEntry[];
count: number;
path: string;
entries: FileEntry[];
count: number;
}
export interface JobTemplate {
key: string;
name: string;
description: string;
key: string;
name: string;
description: string;
}
export interface JobResult {
job_key: string;
path: string;
exit_status: number;
stdout: string;
stderr: string;
job_key: string;
path: string;
exit_status: number;
stdout: string;
stderr: string;
}
export interface ResolvedPath {
original: string;
resolved: string;
original: string;
resolved: string;
}
export interface DashboardShortcut {
id: string;
label: string;
shortcut_type: "website" | "action" | "user";
enabled: boolean;
icon: string;
url: string;
task_id: string;
machine_id: string;
user_id: string;
notes: string;
created_at: number;
updated_at: number;
id: string;
label: string;
shortcut_type: "website" | "action" | "user";
enabled: boolean;
icon: string;
url: string;
task_id: string;
machine_id: string;
user_id: string;
notes: string;
created_at: number;
updated_at: number;
}
export interface DashboardShortcutInput {
id?: string | null;
label: string;
shortcut_type: "website" | "action" | "user";
enabled: boolean;
icon: string;
url: string;
task_id: string;
machine_id: string;
user_id: string;
notes: string;
id?: string | null;
label: string;
shortcut_type: "website" | "action" | "user";
enabled: boolean;
icon: string;
url: string;
task_id: string;
machine_id: string;
user_id: string;
notes: string;
}
export interface AlertmanagerAlert {
name: string;
severity: string;
category: string;
job_name: string;
summary: string;
description: string;
active_since: string;
state: string;
labels: Record<string, string>;
name: string;
severity: string;
category: string;
job_name: string;
summary: string;
description: string;
active_since: string;
state: string;
labels: Record<string, string>;
}
export interface AlertmanagerAlertSummary {
total: number;
by_severity: Record<string, number>;
alerts: AlertmanagerAlert[];
error?: string;
total: number;
by_severity: Record<string, number>;
alerts: AlertmanagerAlert[];
error?: string;
}
export interface AlertmanagerStatus {
up: boolean;
version: string;
uptime: string;
name: string;
peers: string[];
service_id?: string;
error?: string | null;
up: boolean;
version: string;
uptime: string;
name: string;
peers: string[];
service_id?: string;
error?: string | null;
}
export interface PrometheusStatus {
up: boolean;
version: string;
service_id: string;
name: string;
error?: string | null;
}
export interface PrometheusTarget {
labels: Record<string, string>;
targets: string[];
up: boolean;
version: string;
service_id: string;
name: string;
error?: string | null;
}
export interface WidgetInstance {
id: string;
service_id: string | null;
widget_kind: string;
title: string;
config: Record<string, unknown>;
enabled: boolean;
sort_order: number;
created_at: number;
updated_at: number;
id: string;
service_id: string | null;
widget_kind: string;
title: string;
config: Record<string, unknown>;
enabled: boolean;
sort_order: number;
created_at: number;
updated_at: number;
}
export interface WidgetInstanceInput {
id?: string | null;
service_id: string | null;
widget_kind: string;
title: string;
config: Record<string, unknown>;
enabled: boolean;
sort_order: number;
id?: string | null;
service_id: string | null;
widget_kind: string;
title: string;
config: Record<string, unknown>;
enabled: boolean;
sort_order: number;
}
export interface WidgetDataResponse {
widget_id: string;
data: Record<string, unknown> | null;
error: string | null;
fetched_at: number;
widget_id: string;
data: Record<string, unknown> | null;
error: string | null;
fetched_at: number;
}
export interface SecretFieldInfo {
key: string;
label: string;
required: boolean;
helper?: string | null;
key: string;
label: string;
required: boolean;
helper?: string | null;
}
export interface ServiceWidgetKindInfo {
kind: string;
name: string;
description: string;
config_schema: Record<string, unknown>;
default_config: Record<string, unknown>;
refresh_interval_ms: number;
kind: string;
name: string;
description: string;
config_schema: Record<string, unknown>;
default_config: Record<string, unknown>;
refresh_interval_ms: number;
}
export interface ServiceTypeInfo {
service_type: string;
name: string;
description: string;
config_schema: Record<string, unknown>;
secret_fields: SecretFieldInfo[];
widget_kinds: ServiceWidgetKindInfo[];
service_type: string;
name: string;
description: string;
config_schema: Record<string, unknown>;
secret_fields: SecretFieldInfo[];
widget_kinds: ServiceWidgetKindInfo[];
}
export interface ServiceInstance {
id: string;
service_type: string;
name: string;
config: Record<string, unknown>;
secrets_set: Record<string, boolean>;
enabled: boolean;
created_at: number;
updated_at: number;
id: string;
service_type: string;
name: string;
config: Record<string, unknown>;
secrets_set: Record<string, boolean>;
enabled: boolean;
created_at: number;
updated_at: number;
}
export interface SchedulerStatus {
service_id: string;
action_key: string;
worker_running: boolean;
enabled: boolean;
running: boolean;
poll_interval_seconds: number;
sample_retention_seconds: number;
sample_max_rows: number;
next_run_at: number | null;
last_attempt_at: number | null;
last_success_at: number | null;
last_error: string;
consecutive_failures: number;
backoff_until: number | null;
is_stale: boolean;
service_id: string;
action_key: string;
worker_running: boolean;
enabled: boolean;
running: boolean;
poll_interval_seconds: number;
sample_retention_seconds: number;
sample_max_rows: number;
next_run_at: number | null;
last_attempt_at: number | null;
last_success_at: number | null;
last_error: string;
consecutive_failures: number;
backoff_until: number | null;
is_stale: boolean;
}
export interface SchedulerRun {
id: string;
service_id: string;
action_key: string;
trigger: "schedule" | "manual";
started_at: number;
finished_at: number | null;
status: "running" | "success" | "failure" | "cancelled";
attempt: number;
duration_ms: number | null;
error: string;
created_at: number;
id: string;
service_id: string;
action_key: string;
trigger: "schedule" | "manual";
started_at: number;
finished_at: number | null;
status: "running" | "success" | "failure" | "cancelled";
attempt: number;
duration_ms: number | null;
error: string;
created_at: number;
}
export interface SchedulerRunsResponse {
items: SchedulerRun[];
total: number;
limit: number;
offset: number;
items: SchedulerRun[];
total: number;
limit: number;
offset: number;
}
export interface SchedulerSamplesResponse {
service_id: string;
window_seconds: number;
samples: Array<{
ts: number;
dl_speed: number;
up_speed: number;
}>;
service_id: string;
window_seconds: number;
samples: Array<{
ts: number;
dl_speed: number;
up_speed: number;
}>;
}
export interface SchedulerManualRunResponse {
run: SchedulerRun;
status: SchedulerStatus;
run: SchedulerRun;
status: SchedulerStatus;
}
export interface ServiceInstanceInput {
id?: string | null;
service_type: string;
name: string;
config: Record<string, unknown>;
secrets: Record<string, string>;
enabled: boolean;
id?: string | null;
service_type: string;
name: string;
config: Record<string, unknown>;
secrets: Record<string, string>;
enabled: boolean;
}
export interface ServiceTestResult {
ok: boolean;
detail: string;
evidence: string | null;
ok: boolean;
detail: string;
evidence: string | null;
}
export interface BuiltinWidgetKindInfo {
kind: string;
name: string;
description: string;
config_schema: Record<string, unknown>;
default_config: Record<string, unknown>;
refresh_interval_ms: number;
kind: string;
name: string;
description: string;
config_schema: Record<string, unknown>;
default_config: Record<string, unknown>;
refresh_interval_ms: number;
}