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
- **Styling**: Tailwind CSS v4 + Radix UI components
- **Architecture**: Unified application structure (not a monorepo)
- **Code Quality**: Biome (formatter & linter)
- **Code Quality**: Oxfmt for formatting, Oxlint for linting
## Repository Structure
@ -75,14 +75,11 @@ bun run gen:api-client
### Code Quality
```bash
# Format and lint (Biome)
bunx biome check --write .
# Format
bunx oxfmt format --write <path>
# Format only
bunx biome format --write .
# Lint only
bunx biome lint .
# Lint
bun run lint
```
## Architecture
@ -240,8 +237,6 @@ On startup, the server detects available capabilities (see `core/capabilities.ts
## 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
- **Validation**: Prefer ArkType over Zod - it's used throughout the codebase
- **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, "_")
.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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
import { formatDateTime } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils";
import { cn, safeJsonParse } from "~/client/lib/utils";
type DoctorStep = {
step: string;
@ -15,13 +15,13 @@ type DoctorResult = {
steps: DoctorStep[];
completedAt: number;
};
export const DoctorReport = ({
result,
repositoryStatus,
}: {
type Props = {
result?: DoctorResult | null;
repositoryStatus: string | null;
}) => {
};
export const DoctorReport = ({ result, repositoryStatus }: Props) => {
return (
<div>
<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>
<div className="space-y-2 mt-2">
{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">
<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 ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
@ -45,7 +45,7 @@ export const DoctorReport = ({
<div className="p-2 space-y-3">
{step.output && (
<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>
)}
{step.error && (
@ -68,7 +68,7 @@ export const DoctorReport = ({
)}
<div
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>

View file

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

View file

@ -128,6 +128,7 @@ export const REPOSITORY_STATUS = {
error: "error",
unknown: "unknown",
doctor: "doctor",
cancelled: "cancelled",
} as const;
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 { serverEvents } from "../../core/events";
import { logger } from "../../utils/logger";
import { safeJsonParse } from "../../utils/json";
class AbortError extends Error {
name = "AbortError";
}
const runUnlockStep = async (config: RepositoryConfig, signal?: AbortSignal) => {
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 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) {
logger.error(`Invalid check output format: ${parsed.summary}`);
@ -65,7 +76,7 @@ const parseCheckOutput = (checkOutput: string | null) => {
const checkAbortSignal = (signal: AbortSignal | undefined): void => {
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(),
});
} catch (error) {
await db
.update(repositoriesTable)
.set({ status: "error", lastError: toMessage(error) })
.where(eq(repositoriesTable.id, repositoryId));
if (error instanceof AbortError) {
const doctorResult: DoctorResult = {
success: false,
steps,
completedAt: Date.now(),
};
steps.push({ step: "doctor", success: false, output: null, error: toMessage(error) });
serverEvents.emit("doctor:completed", {
repositoryId,
repositoryName,
success: false,
steps,
completedAt: Date.now(),
});
await db
.update(repositoriesTable)
.set({
status: "cancelled",
lastChecked: Date.now(),
lastError: toMessage(error),
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();
runningDoctors.set(repository.id, abortController);
await db
.update(repositoriesTable)
.set({ status: "doctor", doctorResult: null })
.where(eq(repositoriesTable.id, repository.id));
try {
await db.update(repositoriesTable).set({ status: "doctor" }).where(eq(repositoriesTable.id, repository.id));
serverEvents.emit("doctor:started", {
repositoryId: repository.id,
repositoryName: repository.name,
});
serverEvents.emit("doctor:started", {
repositoryId: repository.id,
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)
.catch((error) => {
@ -368,8 +371,6 @@ const cancelDoctor = async (id: string) => {
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);
if (!abortController) {
throw new ConflictError("No doctor operation is currently running");
@ -378,6 +379,8 @@ const cancelDoctor = async (id: string) => {
abortController.abort();
runningDoctors.delete(repository.id);
await db.update(repositoriesTable).set({ status: "unknown" }).where(eq(repositoriesTable.id, repository.id));
serverEvents.emit("doctor:cancelled", {
repositoryId: repository.id,
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;
}
}