chore: pr feedbacks

This commit is contained in:
Nicolas Meienberger 2026-01-17 09:56:34 +01:00
parent 60af409ba8
commit e1e8d097be
8 changed files with 99 additions and 45 deletions

View file

@ -18,7 +18,7 @@ Zerobyte is a backup automation tool built on top of Restic that provides a web
- **Validation**: ArkType for runtime schema validation - **Validation**: ArkType for runtime schema validation
- **Styling**: Tailwind CSS v4 + Radix UI components - **Styling**: Tailwind CSS v4 + Radix UI components
- **Architecture**: Unified application structure (not a monorepo) - **Architecture**: Unified application structure (not a monorepo)
- **Code Quality**: Biome (formatter & linter) - **Code Quality**: Oxfmt for formatting, Oxlint for linting
## Repository Structure ## Repository Structure
@ -75,14 +75,11 @@ bun run gen:api-client
### Code Quality ### Code Quality
```bash ```bash
# Format and lint (Biome) # Format
bunx biome check --write . bunx oxfmt format --write <path>
# Format only # Lint
bunx biome format --write . bun run lint
# Lint only
bunx biome lint .
``` ```
## Architecture ## Architecture
@ -240,8 +237,6 @@ On startup, the server detects available capabilities (see `core/capabilities.ts
## Important Notes ## Important Notes
- **Code Style**: Uses Biome with tabs (not spaces), 120 char line width, double quotes
- **Imports**: Organize imports is disabled in Biome - do not auto-organize
- **TypeScript**: Uses `"type": "module"` - all imports must include extensions when targeting Node/Bun - **TypeScript**: Uses `"type": "module"` - all imports must include extensions when targeting Node/Bun
- **Validation**: Prefer ArkType over Zod - it's used throughout the codebase - **Validation**: Prefer ArkType over Zod - it's used throughout the codebase
- **Database**: Timestamps are stored as Unix epoch integers, not ISO strings - **Database**: Timestamps are stored as Unix epoch integers, not ISO strings

View file

@ -28,3 +28,11 @@ export function slugify(input: string): string {
.replace(/[_]{2,}/g, "_") .replace(/[_]{2,}/g, "_")
.trim(); .trim();
} }
export function safeJsonParse<T>(input: string): T | null {
try {
return JSON.parse(input) as T;
} catch {
return null;
}
}

View file

@ -1,7 +1,7 @@
import { AlertCircle, CheckCircle2 } from "lucide-react"; import { AlertCircle, CheckCircle2 } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
import { formatDateTime } from "~/client/lib/datetime"; import { formatDateTime } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils"; import { cn, safeJsonParse } from "~/client/lib/utils";
type DoctorStep = { type DoctorStep = {
step: string; step: string;
@ -15,13 +15,13 @@ type DoctorResult = {
steps: DoctorStep[]; steps: DoctorStep[];
completedAt: number; completedAt: number;
}; };
export const DoctorReport = ({
result, type Props = {
repositoryStatus,
}: {
result?: DoctorResult | null; result?: DoctorResult | null;
repositoryStatus: string | null; repositoryStatus: string | null;
}) => { };
export const DoctorReport = ({ result, repositoryStatus }: Props) => {
return ( return (
<div> <div>
<h3 className="text-lg font-semibold">Doctor Report</h3> <h3 className="text-lg font-semibold">Doctor Report</h3>
@ -30,10 +30,10 @@ export const DoctorReport = ({
<span className="text-xs text-muted-foreground">Completed {formatDateTime(result.completedAt)}</span> <span className="text-xs text-muted-foreground">Completed {formatDateTime(result.completedAt)}</span>
<div className="space-y-2 mt-2"> <div className="space-y-2 mt-2">
{result.steps.map((step) => ( {result.steps.map((step) => (
<Collapsible key={step.step} className="border rounde overflow-hidden bg-muted/30 group"> <Collapsible key={step.step} className="border rounded overflow-hidden bg-muted/30 group">
<CollapsibleTrigger className="w-full flex items-center justify-start p-3 hover:bg-muted/50 transition-colors"> <CollapsibleTrigger className="w-full flex items-center justify-start p-3 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm font-medium">{step.step.replace("_", " ")}</span> <span className="text-sm font-medium">{step.step.replaceAll("_", " ")}</span>
{step.success ? ( {step.success ? (
<CheckCircle2 className="h-4 w-4 text-green-500" /> <CheckCircle2 className="h-4 w-4 text-green-500" />
) : ( ) : (
@ -45,7 +45,7 @@ export const DoctorReport = ({
<div className="p-2 space-y-3"> <div className="p-2 space-y-3">
{step.output && ( {step.output && (
<pre className="text-xs font-mono bg-background/50 p-3 border overflow-auto max-h-50 whitespace-pre-wrap"> <pre className="text-xs font-mono bg-background/50 p-3 border overflow-auto max-h-50 whitespace-pre-wrap">
{step.output.startsWith("{") ? JSON.stringify(JSON.parse(step.output), null, 2) : step.output} {safeJsonParse(step.output) ? JSON.stringify(safeJsonParse(step.output), null, 2) : step.output}
</pre> </pre>
)} )}
{step.error && ( {step.error && (
@ -68,7 +68,7 @@ export const DoctorReport = ({
)} )}
<div <div
className={cn("mt-2 bg-muted/30 border p-6 text-center", { className={cn("mt-2 bg-muted/30 border p-6 text-center", {
hidden: result !== null || repositoryStatus === "doctor", hidden: result != null || repositoryStatus === "doctor",
})} })}
> >
<p className="text-sm text-muted-foreground">No doctor report available.</p> <p className="text-sm text-muted-foreground">No doctor report available.</p>

View file

@ -137,6 +137,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
loading={cancelDoctor.isPending}
onClick={() => cancelDoctor.mutate({ path: { id: repository.id } })} onClick={() => cancelDoctor.mutate({ path: { id: repository.id } })}
> >
<Square className="h-4 w-4 mr-2" /> <Square className="h-4 w-4 mr-2" />

View file

@ -128,6 +128,7 @@ export const REPOSITORY_STATUS = {
error: "error", error: "error",
unknown: "unknown", unknown: "unknown",
doctor: "doctor", doctor: "doctor",
cancelled: "cancelled",
} as const; } as const;
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS; export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;

View file

@ -8,6 +8,11 @@ import { type DoctorStep, type DoctorResult, type RepositoryConfig } from "~/sch
import { type } from "arktype"; import { type } from "arktype";
import { serverEvents } from "../../core/events"; import { serverEvents } from "../../core/events";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { safeJsonParse } from "../../utils/json";
class AbortError extends Error {
name = "AbortError";
}
const runUnlockStep = async (config: RepositoryConfig, signal?: AbortSignal) => { const runUnlockStep = async (config: RepositoryConfig, signal?: AbortSignal) => {
const result = await restic.unlock(config, { signal }).then( const result = await restic.unlock(config, { signal }).then(
@ -53,7 +58,13 @@ const runRepairIndexStep = async (config: RepositoryConfig, signal: AbortSignal)
const parseCheckOutput = (checkOutput: string | null) => { const parseCheckOutput = (checkOutput: string | null) => {
const schema = type({ suggest_repair_index: "boolean", suggest_prune: "boolean" }); const schema = type({ suggest_repair_index: "boolean", suggest_prune: "boolean" });
const parsed = schema(JSON.parse(checkOutput ?? "{}")); const parsedJson = safeJsonParse(checkOutput);
if (parsedJson === null) {
return null;
}
const parsed = schema(parsedJson);
if (parsed instanceof type.errors) { if (parsed instanceof type.errors) {
logger.error(`Invalid check output format: ${parsed.summary}`); logger.error(`Invalid check output format: ${parsed.summary}`);
@ -65,7 +76,7 @@ const parseCheckOutput = (checkOutput: string | null) => {
const checkAbortSignal = (signal: AbortSignal | undefined): void => { const checkAbortSignal = (signal: AbortSignal | undefined): void => {
if (signal?.aborted) { if (signal?.aborted) {
throw new Error("Doctor operation cancelled"); throw new AbortError("Doctor operation cancelled");
} }
}; };
@ -143,18 +154,42 @@ export const executeDoctor = async (
completedAt: Date.now(), completedAt: Date.now(),
}); });
} catch (error) { } catch (error) {
await db if (error instanceof AbortError) {
.update(repositoriesTable) const doctorResult: DoctorResult = {
.set({ status: "error", lastError: toMessage(error) }) success: false,
.where(eq(repositoriesTable.id, repositoryId)); steps,
completedAt: Date.now(),
};
steps.push({ step: "doctor", success: false, output: null, error: toMessage(error) }); await db
serverEvents.emit("doctor:completed", { .update(repositoriesTable)
repositoryId, .set({
repositoryName, status: "cancelled",
success: false, lastChecked: Date.now(),
steps, lastError: toMessage(error),
completedAt: Date.now(), doctorResult,
}); })
.where(eq(repositoriesTable.id, repositoryId));
serverEvents.emit("doctor:cancelled", {
repositoryId,
repositoryName,
error: toMessage(error),
});
} else {
await db
.update(repositoriesTable)
.set({ status: "error", lastError: toMessage(error) })
.where(eq(repositoriesTable.id, repositoryId));
steps.push({ step: "doctor", success: false, output: null, error: toMessage(error) });
serverEvents.emit("doctor:completed", {
repositoryId,
repositoryName,
success: false,
steps,
completedAt: Date.now(),
});
}
} }
}; };

View file

@ -338,17 +338,20 @@ const startDoctor = async (id: string) => {
} }
const abortController = new AbortController(); const abortController = new AbortController();
runningDoctors.set(repository.id, abortController);
await db try {
.update(repositoriesTable) await db.update(repositoriesTable).set({ status: "doctor" }).where(eq(repositoriesTable.id, repository.id));
.set({ status: "doctor", doctorResult: null })
.where(eq(repositoriesTable.id, repository.id));
serverEvents.emit("doctor:started", { serverEvents.emit("doctor:started", {
repositoryId: repository.id, repositoryId: repository.id,
repositoryName: repository.name, repositoryName: repository.name,
}); });
runningDoctors.set(repository.id, abortController);
} catch (error) {
runningDoctors.delete(repository.id);
throw error;
}
executeDoctor(repository.id, repository.config, repository.name, abortController.signal) executeDoctor(repository.id, repository.config, repository.name, abortController.signal)
.catch((error) => { .catch((error) => {
@ -368,8 +371,6 @@ const cancelDoctor = async (id: string) => {
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
await db.update(repositoriesTable).set({ status: "unknown" }).where(eq(repositoriesTable.id, repository.id));
const abortController = runningDoctors.get(repository.id); const abortController = runningDoctors.get(repository.id);
if (!abortController) { if (!abortController) {
throw new ConflictError("No doctor operation is currently running"); throw new ConflictError("No doctor operation is currently running");
@ -378,6 +379,8 @@ const cancelDoctor = async (id: string) => {
abortController.abort(); abortController.abort();
runningDoctors.delete(repository.id); runningDoctors.delete(repository.id);
await db.update(repositoriesTable).set({ status: "unknown" }).where(eq(repositoriesTable.id, repository.id));
serverEvents.emit("doctor:cancelled", { serverEvents.emit("doctor:cancelled", {
repositoryId: repository.id, repositoryId: repository.id,
repositoryName: repository.name, repositoryName: repository.name,

11
app/server/utils/json.ts Normal file
View file

@ -0,0 +1,11 @@
export function safeJsonParse<T>(input: string | null | undefined): T | null {
if (!input) {
return null;
}
try {
return JSON.parse(input) as T;
} catch {
return null;
}
}