From 12d0eda6ef4d12d0fe56643ccd24f1926ffbe828 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:04:21 +0100 Subject: [PATCH] feat: dev panel (#489) * feat: dev panel * chore: fix typing issue --- .../api-client/@tanstack/react-query.gen.ts | 45 ++++ app/client/api-client/client.gen.ts | 3 +- app/client/api-client/index.ts | 10 + app/client/api-client/sdk.gen.ts | 28 +++ app/client/api-client/types.gen.ts | 49 +++- app/client/components/dev-panel-listener.tsx | 53 ++++ app/client/components/dev-panel.tsx | 229 ++++++++++++++++++ app/client/components/layout.tsx | 2 + app/server/core/config.ts | 2 + .../modules/auth/dev-panel.middleware.ts | 9 + .../repositories/repositories.controller.ts | 43 +++- .../modules/repositories/repositories.dto.ts | 26 ++ .../repositories/repositories.service.ts | 35 ++- .../modules/system/system.controller.ts | 9 +- app/server/modules/system/system.dto.ts | 22 ++ app/server/modules/system/system.service.ts | 3 + openapi-ts.config.ts | 4 + 17 files changed, 566 insertions(+), 6 deletions(-) create mode 100644 app/client/components/dev-panel-listener.tsx create mode 100644 app/client/components/dev-panel.tsx create mode 100644 app/server/modules/auth/dev-panel.middleware.ts diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index f81bfbcb..fb8f2c43 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // This file is auto-generated by @hey-api/openapi-ts import { @@ -22,9 +23,11 @@ import { deleteSnapshot, deleteSnapshots, deleteVolume, + devPanelExec, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, + getDevPanel, getMirrorCompatibility, getNotificationDestination, getRegistrationStatus, @@ -92,12 +95,16 @@ import type { DeleteSnapshotsResponse, DeleteVolumeData, DeleteVolumeResponse, + DevPanelExecData, + DevPanelExecResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, + GetDevPanelData, + GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, @@ -917,6 +924,25 @@ export const tagSnapshotsMutation = ( return mutationOptions; }; +/** + * Execute a restic command against a repository (dev panel only) + */ +export const devPanelExecMutation = ( + options?: Partial>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await devPanelExec({ + ...options, + ...fnOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + export const listBackupSchedulesQueryKey = (options?: Options) => createQueryKey("listBackupSchedules", options); @@ -1524,3 +1550,22 @@ export const downloadResticPasswordMutation = ( }; return mutationOptions; }; + +export const getDevPanelQueryKey = (options?: Options) => createQueryKey("getDevPanel", options); + +/** + * Get the dev panel status + */ +export const getDevPanelOptions = (options?: Options) => + queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getDevPanel({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getDevPanelQueryKey(options), + }); diff --git a/app/client/api-client/client.gen.ts b/app/client/api-client/client.gen.ts index 200ef153..457f3c3d 100644 --- a/app/client/api-client/client.gen.ts +++ b/app/client/api-client/client.gen.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // This file is auto-generated by @hey-api/openapi-ts import { type ClientOptions, type Config, createClient, createConfig } from "./client"; @@ -15,4 +16,4 @@ export type CreateClientConfig = ( override?: Config, ) => Config & T>; -export const client = createClient(createConfig({ baseUrl: "http://192.168.2.42:4096" })); +export const client = createClient(createConfig({ baseUrl: "http://localhost:4096" })); diff --git a/app/client/api-client/index.ts b/app/client/api-client/index.ts index fedb701c..6c1c462b 100644 --- a/app/client/api-client/index.ts +++ b/app/client/api-client/index.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // This file is auto-generated by @hey-api/openapi-ts export { @@ -13,9 +14,11 @@ export { deleteSnapshot, deleteSnapshots, deleteVolume, + devPanelExec, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, + getDevPanel, getMirrorCompatibility, getNotificationDestination, getRegistrationStatus, @@ -98,6 +101,10 @@ export type { DeleteVolumeData, DeleteVolumeResponse, DeleteVolumeResponses, + DevPanelExecData, + DevPanelExecErrors, + DevPanelExecResponse, + DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponse, DownloadResticPasswordResponses, @@ -107,6 +114,9 @@ export type { GetBackupScheduleForVolumeResponses, GetBackupScheduleResponse, GetBackupScheduleResponses, + GetDevPanelData, + GetDevPanelResponse, + GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetMirrorCompatibilityResponses, diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 98a1f117..761f3885 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // This file is auto-generated by @hey-api/openapi-ts import type { Client, Options as Options2, TDataShape } from "./client"; @@ -29,12 +30,17 @@ import type { DeleteSnapshotsResponses, DeleteVolumeData, DeleteVolumeResponses, + DevPanelExecData, + DevPanelExecErrors, + DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, + GetDevPanelData, + GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, @@ -469,6 +475,19 @@ export const tagSnapshots = (options: Opti }, }); +/** + * Execute a restic command against a repository (dev panel only) + */ +export const devPanelExec = (options: Options) => + (options.client ?? client).sse.post({ + url: "/api/v1/repositories/{id}/exec", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + /** * List all backup schedules */ @@ -788,3 +807,12 @@ export const downloadResticPassword = ( ...options?.headers, }, }); + +/** + * Get the dev panel status + */ +export const getDevPanel = (options?: Options) => + (options?.client ?? client).get({ + url: "/api/v1/system/dev-panel", + ...options, + }); diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index e36f73c1..aae207ad 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -1,7 +1,8 @@ +// @ts-nocheck // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { - baseUrl: "http://192.168.2.42:4096" | (string & {}); + baseUrl: "http://localhost:4096" | (string & {}); }; export type GetStatusData = { @@ -1880,6 +1881,34 @@ export type TagSnapshotsResponses = { export type TagSnapshotsResponse = TagSnapshotsResponses[keyof TagSnapshotsResponses]; +export type DevPanelExecData = { + body?: { + command: string; + args?: Array; + }; + 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 = { body?: never; path?: never; @@ -4385,3 +4414,21 @@ export type 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]; diff --git a/app/client/components/dev-panel-listener.tsx b/app/client/components/dev-panel-listener.tsx new file mode 100644 index 00000000..9acad0de --- /dev/null +++ b/app/client/components/dev-panel-listener.tsx @@ -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>(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 ; +} diff --git a/app/client/components/dev-panel.tsx b/app/client/components/dev-panel.tsx new file mode 100644 index 00000000..ebd7bfeb --- /dev/null +++ b/app/client/components/dev-panel.tsx @@ -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(""); + const [commandLine, setCommandLine] = useState("snapshots"); + const [output, setOutput] = useState([]); + const [isRunning, setIsRunning] = useState(false); + const abortControllerRef = useRef(null); + const outputRef = useRef(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 ( + + + + + + Dev Panel + + Execute restic commands against a repository + + +
+
+
+ + +
+ +
+ +