feat: dev panel (#489)

* feat: dev panel

* chore: fix typing issue
This commit is contained in:
Nico
2026-02-09 22:04:21 +01:00
committed by GitHub
parent 413e86b8b9
commit 12d0eda6ef
17 changed files with 566 additions and 6 deletions

View File

@@ -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<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>) =>
createQueryKey("listBackupSchedules", options);
@@ -1524,3 +1550,22 @@ export const downloadResticPasswordMutation = (
};
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),
});

View File

@@ -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<T extends ClientOptions = ClientOptions2> = (
override?: Config<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" }));

View File

@@ -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,

View File

@@ -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 = <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
*/
@@ -788,3 +807,12 @@ export const downloadResticPassword = <ThrowOnError extends boolean = false>(
...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,
});

View File

@@ -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<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 = {
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];

View 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} />;
}

View 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>
);
}

View File

@@ -10,6 +10,7 @@ import { Button } from "./ui/button";
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
import { AppSidebar } from "./app-sidebar";
import { authClient } from "../lib/auth-client";
import { DevPanelListener } from "./dev-panel-listener";
export const clientMiddleware = [authMiddleware];
@@ -83,6 +84,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
</GridBackground>
</div>
</div>
<DevPanelListener />
</SidebarProvider>
);
}

View File

@@ -36,6 +36,7 @@ const envSchema = type({
DISABLE_RATE_LIMITING: 'string = "false"',
APP_SECRET: "32 <= string <= 256",
BASE_URL: "string",
ENABLE_DEV_PANEL: 'string = "false"',
}).pipe((s) => ({
__prod__: s.NODE_ENV === "production",
environment: s.NODE_ENV,
@@ -50,6 +51,7 @@ const envSchema = type({
appSecret: s.APP_SECRET,
baseUrl: s.BASE_URL,
isSecure: s.BASE_URL?.startsWith("https://") ?? false,
enableDevPanel: s.ENABLE_DEV_PANEL === "true",
}));
const parseConfig = (env: unknown) => {

View 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();
};

View File

@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { validator } from "hono-openapi";
import { streamSSE } from "hono/streaming";
import {
createRepositoryBody,
createRepositoryDto,
@@ -24,6 +25,8 @@ import {
tagSnapshotsDto,
updateRepositoryBody,
updateRepositoryDto,
devPanelExecBody,
devPanelExecDto,
type DeleteRepositoryDto,
type DeleteSnapshotDto,
type DeleteSnapshotsResponseDto,
@@ -42,10 +45,11 @@ import {
import { repositoriesService } from "./repositories.service";
import { backupsService } from "../backups/backups.service";
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 { logger } from "~/server/utils/logger";
import { toMessage } from "~/server/utils/errors";
import { requireDevPanel } from "../auth/dev-panel.middleware";
export const repositoriesController = new Hono()
.use(requireAuth)
@@ -224,4 +228,39 @@ export const repositoriesController = new Hono()
const res = await repositoriesService.updateRepository(id, body);
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) });
}
});
},
);

View File

@@ -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",
},
},
});

View File

@@ -5,7 +5,8 @@ import { db } from "../../db/db";
import { repositoriesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors";
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 { cache } from "../../utils/cache";
import { repoMutex } from "../../core/repository-mutex";
@@ -560,6 +561,37 @@ const updateRepository = async (id: string, updates: { name?: string; compressio
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 = {
listRepositories,
createRepository,
@@ -577,4 +609,5 @@ export const repositoriesService = {
deleteSnapshots,
tagSnapshots,
refreshSnapshots,
execResticCommand,
};

View File

@@ -11,6 +11,8 @@ import {
getRegistrationStatusDto,
registrationStatusBody,
type RegistrationStatusDto,
getDevPanelDto,
type DevPanelDto,
} from "./system.dto";
import { systemService } from "./system.service";
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);
}
},
);
)
.get("/dev-panel", getDevPanelDto, async (c) => {
const enabled = systemService.isDevPanelEnabled();
return c.json<DevPanelDto>({ enabled }, 200);
});

View File

@@ -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),
},
},
},
},
});

View File

@@ -107,9 +107,12 @@ const setRegistrationEnabled = async (enabled: boolean) => {
logger.info(`Registration enabled set to: ${enabled}`);
};
const isDevPanelEnabled = () => config.enableDevPanel;
export const systemService = {
getSystemInfo,
getUpdates,
isRegistrationEnabled,
setRegistrationEnabled,
isDevPanelEnabled,
};

View File

@@ -5,6 +5,10 @@ export default defineConfig({
input: `http://${config.serverIp}:3000/api/v1/openapi.json`,
output: {
path: "./app/client/api-client",
header: [
"// @ts-nocheck",
"// This file is auto-generated by @hey-api/openapi-ts",
],
postProcess: ["oxfmt"],
},
plugins: [...defaultPlugins, "@tanstack/react-query", "@hey-api/client-fetch"],