parent
413e86b8b9
commit
12d0eda6ef
17 changed files with 566 additions and 6 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
// @ts-nocheck
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,9 +23,11 @@ import {
|
||||||
deleteSnapshot,
|
deleteSnapshot,
|
||||||
deleteSnapshots,
|
deleteSnapshots,
|
||||||
deleteVolume,
|
deleteVolume,
|
||||||
|
devPanelExec,
|
||||||
downloadResticPassword,
|
downloadResticPassword,
|
||||||
getBackupSchedule,
|
getBackupSchedule,
|
||||||
getBackupScheduleForVolume,
|
getBackupScheduleForVolume,
|
||||||
|
getDevPanel,
|
||||||
getMirrorCompatibility,
|
getMirrorCompatibility,
|
||||||
getNotificationDestination,
|
getNotificationDestination,
|
||||||
getRegistrationStatus,
|
getRegistrationStatus,
|
||||||
|
|
@ -92,12 +95,16 @@ import type {
|
||||||
DeleteSnapshotsResponse,
|
DeleteSnapshotsResponse,
|
||||||
DeleteVolumeData,
|
DeleteVolumeData,
|
||||||
DeleteVolumeResponse,
|
DeleteVolumeResponse,
|
||||||
|
DevPanelExecData,
|
||||||
|
DevPanelExecResponse,
|
||||||
DownloadResticPasswordData,
|
DownloadResticPasswordData,
|
||||||
DownloadResticPasswordResponse,
|
DownloadResticPasswordResponse,
|
||||||
GetBackupScheduleData,
|
GetBackupScheduleData,
|
||||||
GetBackupScheduleForVolumeData,
|
GetBackupScheduleForVolumeData,
|
||||||
GetBackupScheduleForVolumeResponse,
|
GetBackupScheduleForVolumeResponse,
|
||||||
GetBackupScheduleResponse,
|
GetBackupScheduleResponse,
|
||||||
|
GetDevPanelData,
|
||||||
|
GetDevPanelResponse,
|
||||||
GetMirrorCompatibilityData,
|
GetMirrorCompatibilityData,
|
||||||
GetMirrorCompatibilityResponse,
|
GetMirrorCompatibilityResponse,
|
||||||
GetNotificationDestinationData,
|
GetNotificationDestinationData,
|
||||||
|
|
@ -917,6 +924,25 @@ export const tagSnapshotsMutation = (
|
||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a restic command against a repository (dev panel only)
|
||||||
|
*/
|
||||||
|
export const devPanelExecMutation = (
|
||||||
|
options?: Partial<Options<DevPanelExecData>>,
|
||||||
|
): UseMutationOptions<DevPanelExecResponse, DefaultError, Options<DevPanelExecData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<DevPanelExecResponse, DefaultError, Options<DevPanelExecData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await devPanelExec({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
|
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
|
||||||
createQueryKey("listBackupSchedules", options);
|
createQueryKey("listBackupSchedules", options);
|
||||||
|
|
||||||
|
|
@ -1524,3 +1550,22 @@ export const downloadResticPasswordMutation = (
|
||||||
};
|
};
|
||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getDevPanelQueryKey = (options?: Options<GetDevPanelData>) => createQueryKey("getDevPanel", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the dev panel status
|
||||||
|
*/
|
||||||
|
export const getDevPanelOptions = (options?: Options<GetDevPanelData>) =>
|
||||||
|
queryOptions<GetDevPanelResponse, DefaultError, GetDevPanelResponse, ReturnType<typeof getDevPanelQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await getDevPanel({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: getDevPanelQueryKey(options),
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// @ts-nocheck
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import { type ClientOptions, type Config, createClient, createConfig } from "./client";
|
import { type ClientOptions, type Config, createClient, createConfig } from "./client";
|
||||||
|
|
@ -15,4 +16,4 @@ export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (
|
||||||
override?: Config<ClientOptions & T>,
|
override?: Config<ClientOptions & T>,
|
||||||
) => Config<Required<ClientOptions> & T>;
|
) => Config<Required<ClientOptions> & T>;
|
||||||
|
|
||||||
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: "http://192.168.2.42:4096" }));
|
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: "http://localhost:4096" }));
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// @ts-nocheck
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
@ -13,9 +14,11 @@ export {
|
||||||
deleteSnapshot,
|
deleteSnapshot,
|
||||||
deleteSnapshots,
|
deleteSnapshots,
|
||||||
deleteVolume,
|
deleteVolume,
|
||||||
|
devPanelExec,
|
||||||
downloadResticPassword,
|
downloadResticPassword,
|
||||||
getBackupSchedule,
|
getBackupSchedule,
|
||||||
getBackupScheduleForVolume,
|
getBackupScheduleForVolume,
|
||||||
|
getDevPanel,
|
||||||
getMirrorCompatibility,
|
getMirrorCompatibility,
|
||||||
getNotificationDestination,
|
getNotificationDestination,
|
||||||
getRegistrationStatus,
|
getRegistrationStatus,
|
||||||
|
|
@ -98,6 +101,10 @@ export type {
|
||||||
DeleteVolumeData,
|
DeleteVolumeData,
|
||||||
DeleteVolumeResponse,
|
DeleteVolumeResponse,
|
||||||
DeleteVolumeResponses,
|
DeleteVolumeResponses,
|
||||||
|
DevPanelExecData,
|
||||||
|
DevPanelExecErrors,
|
||||||
|
DevPanelExecResponse,
|
||||||
|
DevPanelExecResponses,
|
||||||
DownloadResticPasswordData,
|
DownloadResticPasswordData,
|
||||||
DownloadResticPasswordResponse,
|
DownloadResticPasswordResponse,
|
||||||
DownloadResticPasswordResponses,
|
DownloadResticPasswordResponses,
|
||||||
|
|
@ -107,6 +114,9 @@ export type {
|
||||||
GetBackupScheduleForVolumeResponses,
|
GetBackupScheduleForVolumeResponses,
|
||||||
GetBackupScheduleResponse,
|
GetBackupScheduleResponse,
|
||||||
GetBackupScheduleResponses,
|
GetBackupScheduleResponses,
|
||||||
|
GetDevPanelData,
|
||||||
|
GetDevPanelResponse,
|
||||||
|
GetDevPanelResponses,
|
||||||
GetMirrorCompatibilityData,
|
GetMirrorCompatibilityData,
|
||||||
GetMirrorCompatibilityResponse,
|
GetMirrorCompatibilityResponse,
|
||||||
GetMirrorCompatibilityResponses,
|
GetMirrorCompatibilityResponses,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// @ts-nocheck
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { Client, Options as Options2, TDataShape } from "./client";
|
import type { Client, Options as Options2, TDataShape } from "./client";
|
||||||
|
|
@ -29,12 +30,17 @@ import type {
|
||||||
DeleteSnapshotsResponses,
|
DeleteSnapshotsResponses,
|
||||||
DeleteVolumeData,
|
DeleteVolumeData,
|
||||||
DeleteVolumeResponses,
|
DeleteVolumeResponses,
|
||||||
|
DevPanelExecData,
|
||||||
|
DevPanelExecErrors,
|
||||||
|
DevPanelExecResponses,
|
||||||
DownloadResticPasswordData,
|
DownloadResticPasswordData,
|
||||||
DownloadResticPasswordResponses,
|
DownloadResticPasswordResponses,
|
||||||
GetBackupScheduleData,
|
GetBackupScheduleData,
|
||||||
GetBackupScheduleForVolumeData,
|
GetBackupScheduleForVolumeData,
|
||||||
GetBackupScheduleForVolumeResponses,
|
GetBackupScheduleForVolumeResponses,
|
||||||
GetBackupScheduleResponses,
|
GetBackupScheduleResponses,
|
||||||
|
GetDevPanelData,
|
||||||
|
GetDevPanelResponses,
|
||||||
GetMirrorCompatibilityData,
|
GetMirrorCompatibilityData,
|
||||||
GetMirrorCompatibilityResponses,
|
GetMirrorCompatibilityResponses,
|
||||||
GetNotificationDestinationData,
|
GetNotificationDestinationData,
|
||||||
|
|
@ -469,6 +475,19 @@ export const tagSnapshots = <ThrowOnError extends boolean = false>(options: Opti
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a restic command against a repository (dev panel only)
|
||||||
|
*/
|
||||||
|
export const devPanelExec = <ThrowOnError extends boolean = false>(options: Options<DevPanelExecData, ThrowOnError>) =>
|
||||||
|
(options.client ?? client).sse.post<DevPanelExecResponses, DevPanelExecErrors, ThrowOnError>({
|
||||||
|
url: "/api/v1/repositories/{id}/exec",
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all backup schedules
|
* List all backup schedules
|
||||||
*/
|
*/
|
||||||
|
|
@ -788,3 +807,12 @@ export const downloadResticPassword = <ThrowOnError extends boolean = false>(
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the dev panel status
|
||||||
|
*/
|
||||||
|
export const getDevPanel = <ThrowOnError extends boolean = false>(options?: Options<GetDevPanelData, ThrowOnError>) =>
|
||||||
|
(options?.client ?? client).get<GetDevPanelResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/api/v1/system/dev-panel",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
// @ts-nocheck
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
export type ClientOptions = {
|
export type ClientOptions = {
|
||||||
baseUrl: "http://192.168.2.42:4096" | (string & {});
|
baseUrl: "http://localhost:4096" | (string & {});
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetStatusData = {
|
export type GetStatusData = {
|
||||||
|
|
@ -1880,6 +1881,34 @@ export type TagSnapshotsResponses = {
|
||||||
|
|
||||||
export type TagSnapshotsResponse = TagSnapshotsResponses[keyof TagSnapshotsResponses];
|
export type TagSnapshotsResponse = TagSnapshotsResponses[keyof TagSnapshotsResponses];
|
||||||
|
|
||||||
|
export type DevPanelExecData = {
|
||||||
|
body?: {
|
||||||
|
command: string;
|
||||||
|
args?: Array<string>;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: "/api/v1/repositories/{id}/exec";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DevPanelExecErrors = {
|
||||||
|
/**
|
||||||
|
* Dev panel not enabled
|
||||||
|
*/
|
||||||
|
403: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DevPanelExecResponses = {
|
||||||
|
/**
|
||||||
|
* Command output stream (SSE)
|
||||||
|
*/
|
||||||
|
200: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DevPanelExecResponse = DevPanelExecResponses[keyof DevPanelExecResponses];
|
||||||
|
|
||||||
export type ListBackupSchedulesData = {
|
export type ListBackupSchedulesData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|
@ -4385,3 +4414,21 @@ export type DownloadResticPasswordResponses = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[keyof DownloadResticPasswordResponses];
|
export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[keyof DownloadResticPasswordResponses];
|
||||||
|
|
||||||
|
export type GetDevPanelData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: "/api/v1/system/dev-panel";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetDevPanelResponses = {
|
||||||
|
/**
|
||||||
|
* Dev panel status
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetDevPanelResponse = GetDevPanelResponses[keyof GetDevPanelResponses];
|
||||||
|
|
|
||||||
53
app/client/components/dev-panel-listener.tsx
Normal file
53
app/client/components/dev-panel-listener.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getDevPanelOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { DevPanel } from "./dev-panel";
|
||||||
|
|
||||||
|
export function DevPanelListener() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const pressedKeysRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const { data: devPanelStatus } = useQuery({
|
||||||
|
...getDevPanelOptions(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEnabled = devPanelStatus?.enabled ?? false;
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
pressedKeysRef.current.add(e.key.toLowerCase());
|
||||||
|
|
||||||
|
const keys = pressedKeysRef.current;
|
||||||
|
if (keys.has("d") && keys.has("e") && keys.has("v")) {
|
||||||
|
setIsOpen(true);
|
||||||
|
pressedKeysRef.current.clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isEnabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyUp = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
pressedKeysRef.current.delete(e.key.toLowerCase());
|
||||||
|
},
|
||||||
|
[isEnabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
window.addEventListener("keyup", handleKeyUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
|
};
|
||||||
|
}, [handleKeyDown, handleKeyUp]);
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DevPanel open={isOpen} onOpenChange={setIsOpen} />;
|
||||||
|
}
|
||||||
229
app/client/components/dev-panel.tsx
Normal file
229
app/client/components/dev-panel.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { useRef, useState, useCallback } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Play, Loader2, Trash2, Terminal } from "lucide-react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { cn } from "~/client/lib/utils";
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "./ui/sheet";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
import { Textarea } from "./ui/textarea";
|
||||||
|
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { devPanelExec } from "~/client/api-client/sdk.gen";
|
||||||
|
import { parseError } from "../lib/errors";
|
||||||
|
|
||||||
|
type DevPanelProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SseOutputEvent = {
|
||||||
|
type: "stdout" | "stderr";
|
||||||
|
line: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SseDoneEvent = {
|
||||||
|
type: "done";
|
||||||
|
exitCode: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SseErrorEvent = {
|
||||||
|
type: "error";
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SseEvent = SseOutputEvent | SseDoneEvent | SseErrorEvent;
|
||||||
|
|
||||||
|
export function DevPanel({ open, onOpenChange }: DevPanelProps) {
|
||||||
|
const { data: repositories = [] } = useQuery({
|
||||||
|
...listRepositoriesOptions(),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedRepoId, setSelectedRepoId] = useState<string>("");
|
||||||
|
const [commandLine, setCommandLine] = useState("snapshots");
|
||||||
|
const [output, setOutput] = useState<string[]>([]);
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const outputRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
if (outputRef.current) {
|
||||||
|
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const appendOutput = useCallback(
|
||||||
|
(line: string) => {
|
||||||
|
setOutput((prev) => {
|
||||||
|
const newOutput = [...prev, line];
|
||||||
|
setTimeout(scrollToBottom, 0);
|
||||||
|
return newOutput;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[scrollToBottom],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRun = async () => {
|
||||||
|
if (!selectedRepoId || !commandLine.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOutput([]);
|
||||||
|
setIsRunning(true);
|
||||||
|
appendOutput(`$ restic ${commandLine}`.trim());
|
||||||
|
appendOutput("---");
|
||||||
|
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
const trimmedLine = commandLine.trim();
|
||||||
|
const parts = trimmedLine.split(/\s+/);
|
||||||
|
const command = parts[0];
|
||||||
|
const argsArray = parts.slice(1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await devPanelExec({
|
||||||
|
path: { id: selectedRepoId },
|
||||||
|
body: { command, args: argsArray.length > 0 ? argsArray : undefined },
|
||||||
|
signal: abortControllerRef.current.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const event of result.stream) {
|
||||||
|
if (abortControllerRef.current.signal.aborted) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const data = event as unknown as SseEvent;
|
||||||
|
|
||||||
|
if (!data || typeof data !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "stdout" || data.type === "stderr") {
|
||||||
|
appendOutput(data.line);
|
||||||
|
} else if (data.type === "done") {
|
||||||
|
appendOutput(`---`);
|
||||||
|
appendOutput(`Command finished with exit code: ${data.exitCode}`);
|
||||||
|
} else if (data.type === "error") {
|
||||||
|
appendOutput(`Error: ${data.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
appendOutput("---");
|
||||||
|
appendOutput("Command cancelled");
|
||||||
|
} else {
|
||||||
|
appendOutput(`Error: ${parseError(err)?.message}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsRunning(false);
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setOutput([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (isRunning) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={handleClose}>
|
||||||
|
<SheetContent side="right" className="w-full sm:max-w-xl flex flex-col px-4">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="flex items-center gap-2">
|
||||||
|
<Terminal className="h-5 w-5" />
|
||||||
|
Dev Panel
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription>Execute restic commands against a repository</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 flex-1 min-h-0 mt-4">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="repository">Repository</Label>
|
||||||
|
<Select value={selectedRepoId} onValueChange={setSelectedRepoId}>
|
||||||
|
<SelectTrigger id="repository">
|
||||||
|
<SelectValue placeholder="Select a repository" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{repositories.map((repo) => (
|
||||||
|
<SelectItem key={repo.id} value={repo.id}>
|
||||||
|
{repo.name} ({repo.type})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="command">Command</Label>
|
||||||
|
<Textarea
|
||||||
|
id="command"
|
||||||
|
placeholder="e.g., snapshots, check --dry-run, forget --keep-last 10"
|
||||||
|
value={commandLine}
|
||||||
|
onChange={(e) => setCommandLine(e.target.value)}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleRun} disabled={isRunning || !selectedRepoId || !commandLine.trim()}>
|
||||||
|
<Loader2 className={cn("h-4 w-4 animate-spin mr-2", { hidden: !isRunning })} />
|
||||||
|
<Play className={cn("h-4 w-4 mr-2", { hidden: isRunning })} />
|
||||||
|
<span className={cn({ hidden: !isRunning })}>Running...</span>
|
||||||
|
<span className={cn({ hidden: isRunning })}>Run</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleCancel} className={cn({ hidden: !isRunning })}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleClear} disabled={isRunning}>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 border rounded-md bg-muted/30">
|
||||||
|
<div ref={outputRef} className="h-full overflow-auto p-3 font-mono text-xs">
|
||||||
|
<div className={cn("text-muted-foreground", { hidden: output.length > 0 })}>
|
||||||
|
Output will appear here...
|
||||||
|
</div>
|
||||||
|
<div className={cn("space-y-0.5", { hidden: output.length === 0 })}>
|
||||||
|
{output.map((line, i) => {
|
||||||
|
let displayLine = line;
|
||||||
|
let isJson = false;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
displayLine = JSON.stringify(parsed, null, 2);
|
||||||
|
isJson = true;
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON, display as-is
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
key={`${i}-${line.slice(0, 20)}`}
|
||||||
|
className={cn("whitespace-pre-wrap break-all text-xs", {
|
||||||
|
"wrap-break-word text-[10px] leading-tight": isJson,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{displayLine}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { Button } from "./ui/button";
|
||||||
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
|
||||||
import { AppSidebar } from "./app-sidebar";
|
import { AppSidebar } from "./app-sidebar";
|
||||||
import { authClient } from "../lib/auth-client";
|
import { authClient } from "../lib/auth-client";
|
||||||
|
import { DevPanelListener } from "./dev-panel-listener";
|
||||||
|
|
||||||
export const clientMiddleware = [authMiddleware];
|
export const clientMiddleware = [authMiddleware];
|
||||||
|
|
||||||
|
|
@ -83,6 +84,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
||||||
</GridBackground>
|
</GridBackground>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<DevPanelListener />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ const envSchema = type({
|
||||||
DISABLE_RATE_LIMITING: 'string = "false"',
|
DISABLE_RATE_LIMITING: 'string = "false"',
|
||||||
APP_SECRET: "32 <= string <= 256",
|
APP_SECRET: "32 <= string <= 256",
|
||||||
BASE_URL: "string",
|
BASE_URL: "string",
|
||||||
|
ENABLE_DEV_PANEL: 'string = "false"',
|
||||||
}).pipe((s) => ({
|
}).pipe((s) => ({
|
||||||
__prod__: s.NODE_ENV === "production",
|
__prod__: s.NODE_ENV === "production",
|
||||||
environment: s.NODE_ENV,
|
environment: s.NODE_ENV,
|
||||||
|
|
@ -50,6 +51,7 @@ const envSchema = type({
|
||||||
appSecret: s.APP_SECRET,
|
appSecret: s.APP_SECRET,
|
||||||
baseUrl: s.BASE_URL,
|
baseUrl: s.BASE_URL,
|
||||||
isSecure: s.BASE_URL?.startsWith("https://") ?? false,
|
isSecure: s.BASE_URL?.startsWith("https://") ?? false,
|
||||||
|
enableDevPanel: s.ENABLE_DEV_PANEL === "true",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const parseConfig = (env: unknown) => {
|
const parseConfig = (env: unknown) => {
|
||||||
|
|
|
||||||
9
app/server/modules/auth/dev-panel.middleware.ts
Normal file
9
app/server/modules/auth/dev-panel.middleware.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { Context, Next } from "hono";
|
||||||
|
import { systemService } from "../system/system.service";
|
||||||
|
|
||||||
|
export const requireDevPanel = async (c: Context, next: Next) => {
|
||||||
|
if (!systemService.isDevPanelEnabled()) {
|
||||||
|
return c.json({ message: "Dev panel not enabled" }, 403);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { validator } from "hono-openapi";
|
import { validator } from "hono-openapi";
|
||||||
|
import { streamSSE } from "hono/streaming";
|
||||||
import {
|
import {
|
||||||
createRepositoryBody,
|
createRepositoryBody,
|
||||||
createRepositoryDto,
|
createRepositoryDto,
|
||||||
|
|
@ -24,6 +25,8 @@ import {
|
||||||
tagSnapshotsDto,
|
tagSnapshotsDto,
|
||||||
updateRepositoryBody,
|
updateRepositoryBody,
|
||||||
updateRepositoryDto,
|
updateRepositoryDto,
|
||||||
|
devPanelExecBody,
|
||||||
|
devPanelExecDto,
|
||||||
type DeleteRepositoryDto,
|
type DeleteRepositoryDto,
|
||||||
type DeleteSnapshotDto,
|
type DeleteSnapshotDto,
|
||||||
type DeleteSnapshotsResponseDto,
|
type DeleteSnapshotsResponseDto,
|
||||||
|
|
@ -42,10 +45,11 @@ import {
|
||||||
import { repositoriesService } from "./repositories.service";
|
import { repositoriesService } from "./repositories.service";
|
||||||
import { backupsService } from "../backups/backups.service";
|
import { backupsService } from "../backups/backups.service";
|
||||||
import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone";
|
import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone";
|
||||||
import { requireAuth } from "../auth/auth.middleware";
|
import { requireAuth, requireOrgAdmin } from "../auth/auth.middleware";
|
||||||
import { computeRetentionCategories } from "../../utils/retention-categories";
|
import { computeRetentionCategories } from "../../utils/retention-categories";
|
||||||
import { logger } from "~/server/utils/logger";
|
import { logger } from "~/server/utils/logger";
|
||||||
import { toMessage } from "~/server/utils/errors";
|
import { toMessage } from "~/server/utils/errors";
|
||||||
|
import { requireDevPanel } from "../auth/dev-panel.middleware";
|
||||||
|
|
||||||
export const repositoriesController = new Hono()
|
export const repositoriesController = new Hono()
|
||||||
.use(requireAuth)
|
.use(requireAuth)
|
||||||
|
|
@ -224,4 +228,39 @@ export const repositoriesController = new Hono()
|
||||||
const res = await repositoriesService.updateRepository(id, body);
|
const res = await repositoriesService.updateRepository(id, body);
|
||||||
|
|
||||||
return c.json<UpdateRepositoryDto>(res.repository, 200);
|
return c.json<UpdateRepositoryDto>(res.repository, 200);
|
||||||
});
|
})
|
||||||
|
.post(
|
||||||
|
"/:id/exec",
|
||||||
|
requireDevPanel,
|
||||||
|
requireOrgAdmin,
|
||||||
|
devPanelExecDto,
|
||||||
|
validator("json", devPanelExecBody),
|
||||||
|
async (c) => {
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
return streamSSE(c, async (stream) => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
stream.onAbort(() => abortController.abort());
|
||||||
|
|
||||||
|
const sendSSE = async (event: string, data: unknown) => {
|
||||||
|
await stream.writeSSE({ data: JSON.stringify(data), event });
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await repositoriesService.execResticCommand(
|
||||||
|
id,
|
||||||
|
body.command,
|
||||||
|
body.args,
|
||||||
|
async (line) => sendSSE("output", { type: "stdout", line }),
|
||||||
|
async (line) => sendSSE("output", { type: "stderr", line }),
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendSSE("done", { type: "done", exitCode: result.exitCode });
|
||||||
|
} catch (error) {
|
||||||
|
await sendSSE("error", { type: "error", message: toMessage(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -520,3 +520,29 @@ export const refreshSnapshotsDto = describeRoute({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const devPanelExecBody = type({
|
||||||
|
command: "string",
|
||||||
|
args: "string[]?",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DevPanelExecBody = typeof devPanelExecBody.infer;
|
||||||
|
|
||||||
|
export const devPanelExecDto = describeRoute({
|
||||||
|
description: "Execute a restic command against a repository (dev panel only)",
|
||||||
|
tags: ["Repositories"],
|
||||||
|
operationId: "devPanelExec",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Command output stream (SSE)",
|
||||||
|
content: {
|
||||||
|
"text/event-stream": {
|
||||||
|
schema: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
description: "Dev panel not enabled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import { db } from "../../db/db";
|
||||||
import { repositoriesTable } from "../../db/schema";
|
import { repositoriesTable } from "../../db/schema";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { generateShortId } from "../../utils/id";
|
import { generateShortId } from "../../utils/id";
|
||||||
import { restic } from "../../utils/restic";
|
import { restic, buildEnv, buildRepoUrl, addCommonArgs, cleanupTemporaryKeys } from "../../utils/restic";
|
||||||
|
import { safeSpawn } from "../../utils/spawn";
|
||||||
import { cryptoUtils } from "../../utils/crypto";
|
import { cryptoUtils } from "../../utils/crypto";
|
||||||
import { cache } from "../../utils/cache";
|
import { cache } from "../../utils/cache";
|
||||||
import { repoMutex } from "../../core/repository-mutex";
|
import { repoMutex } from "../../core/repository-mutex";
|
||||||
|
|
@ -560,6 +561,37 @@ const updateRepository = async (id: string, updates: { name?: string; compressio
|
||||||
return { repository: updated };
|
return { repository: updated };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const execResticCommand = async (
|
||||||
|
id: string,
|
||||||
|
command: string,
|
||||||
|
args: string[] | undefined,
|
||||||
|
onStdout: (line: string) => void,
|
||||||
|
onStderr: (line: string) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => {
|
||||||
|
const organizationId = getOrganizationId();
|
||||||
|
const repository = await findRepository(id);
|
||||||
|
|
||||||
|
if (!repository) {
|
||||||
|
throw new NotFoundError("Repository not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoUrl = buildRepoUrl(repository.config);
|
||||||
|
const env = await buildEnv(repository.config, organizationId);
|
||||||
|
|
||||||
|
const resticArgs: string[] = ["--repo", repoUrl, command];
|
||||||
|
if (args && args.length > 0) {
|
||||||
|
resticArgs.push(...args);
|
||||||
|
}
|
||||||
|
addCommonArgs(resticArgs, env, repository.config);
|
||||||
|
|
||||||
|
const result = await safeSpawn({ command: "restic", args: resticArgs, env, signal, onStdout, onStderr });
|
||||||
|
|
||||||
|
await cleanupTemporaryKeys(env);
|
||||||
|
|
||||||
|
return { exitCode: result.exitCode };
|
||||||
|
};
|
||||||
|
|
||||||
export const repositoriesService = {
|
export const repositoriesService = {
|
||||||
listRepositories,
|
listRepositories,
|
||||||
createRepository,
|
createRepository,
|
||||||
|
|
@ -577,4 +609,5 @@ export const repositoriesService = {
|
||||||
deleteSnapshots,
|
deleteSnapshots,
|
||||||
tagSnapshots,
|
tagSnapshots,
|
||||||
refreshSnapshots,
|
refreshSnapshots,
|
||||||
|
execResticCommand,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import {
|
||||||
getRegistrationStatusDto,
|
getRegistrationStatusDto,
|
||||||
registrationStatusBody,
|
registrationStatusBody,
|
||||||
type RegistrationStatusDto,
|
type RegistrationStatusDto,
|
||||||
|
getDevPanelDto,
|
||||||
|
type DevPanelDto,
|
||||||
} from "./system.dto";
|
} from "./system.dto";
|
||||||
import { systemService } from "./system.service";
|
import { systemService } from "./system.service";
|
||||||
import { requireAuth, requireOrgAdmin } from "../auth/auth.middleware";
|
import { requireAuth, requireOrgAdmin } from "../auth/auth.middleware";
|
||||||
|
|
@ -96,4 +98,9 @@ export const systemController = new Hono()
|
||||||
return c.json({ message: "Failed to retrieve Restic password" }, 500);
|
return c.json({ message: "Failed to retrieve Restic password" }, 500);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
.get("/dev-panel", getDevPanelDto, async (c) => {
|
||||||
|
const enabled = systemService.isDevPanelEnabled();
|
||||||
|
|
||||||
|
return c.json<DevPanelDto>({ enabled }, 200);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -122,3 +122,25 @@ export const setRegistrationStatusDto = describeRoute({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const devPanelResponse = type({
|
||||||
|
enabled: "boolean",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DevPanelDto = typeof devPanelResponse.infer;
|
||||||
|
|
||||||
|
export const getDevPanelDto = describeRoute({
|
||||||
|
description: "Get the dev panel status",
|
||||||
|
tags: ["System"],
|
||||||
|
operationId: "getDevPanel",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Dev panel status",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(devPanelResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -107,9 +107,12 @@ const setRegistrationEnabled = async (enabled: boolean) => {
|
||||||
logger.info(`Registration enabled set to: ${enabled}`);
|
logger.info(`Registration enabled set to: ${enabled}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isDevPanelEnabled = () => config.enableDevPanel;
|
||||||
|
|
||||||
export const systemService = {
|
export const systemService = {
|
||||||
getSystemInfo,
|
getSystemInfo,
|
||||||
getUpdates,
|
getUpdates,
|
||||||
isRegistrationEnabled,
|
isRegistrationEnabled,
|
||||||
setRegistrationEnabled,
|
setRegistrationEnabled,
|
||||||
|
isDevPanelEnabled,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ export default defineConfig({
|
||||||
input: `http://${config.serverIp}:3000/api/v1/openapi.json`,
|
input: `http://${config.serverIp}:3000/api/v1/openapi.json`,
|
||||||
output: {
|
output: {
|
||||||
path: "./app/client/api-client",
|
path: "./app/client/api-client",
|
||||||
|
header: [
|
||||||
|
"// @ts-nocheck",
|
||||||
|
"// This file is auto-generated by @hey-api/openapi-ts",
|
||||||
|
],
|
||||||
postProcess: ["oxfmt"],
|
postProcess: ["oxfmt"],
|
||||||
},
|
},
|
||||||
plugins: [...defaultPlugins, "@tanstack/react-query", "@hey-api/client-fetch"],
|
plugins: [...defaultPlugins, "@tanstack/react-query", "@hey-api/client-fetch"],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue