refactor: unify SSH machines as services
This commit is contained in:
+229
-259
@@ -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
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
+353
-402
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user