chore: pr feedbacks
This commit is contained in:
parent
60af409ba8
commit
e1e8d097be
8 changed files with 99 additions and 45 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
11
app/server/utils/json.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue