mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-05-19 06:03:01 -04:00
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
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 { 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) });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 { 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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
const isDevPanelEnabled = () => config.enableDevPanel;
|
||||
|
||||
export const systemService = {
|
||||
getSystemInfo,
|
||||
getUpdates,
|
||||
isRegistrationEnabled,
|
||||
setRegistrationEnabled,
|
||||
isDevPanelEnabled,
|
||||
};
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user