refactor: react-doctor

This commit is contained in:
Nicolas Meienberger 2026-02-19 19:05:17 +01:00
parent a0a813ed09
commit ebefe410f4
3 changed files with 90 additions and 25 deletions

View file

@ -33,6 +33,32 @@ type SseErrorEvent = {
type SseEvent = SseOutputEvent | SseDoneEvent | SseErrorEvent;
async function processStream(
stream: AsyncIterable<unknown>,
appendOutput: (line: string) => void,
abortSignal: AbortSignal,
): Promise<void> {
for await (const event of stream) {
if (abortSignal.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}`);
}
}
}
export function DevPanel({ open, onOpenChange }: DevPanelProps) {
const { data: repositories = [] } = useQuery({
...listRepositoriesOptions(),
@ -41,10 +67,11 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
const [selectedRepoId, setSelectedRepoId] = useState<string>("");
const [commandLine, setCommandLine] = useState("snapshots");
const [output, setOutput] = useState<string[]>([]);
const [output, setOutput] = useState<Array<{ id: number; line: string }>>([]);
const [isRunning, setIsRunning] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const outputRef = useRef<HTMLDivElement>(null);
const outputIdRef = useRef(0);
const scrollToBottom = useCallback(() => {
if (outputRef.current) {
@ -55,7 +82,7 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
const appendOutput = useCallback(
(line: string) => {
setOutput((prev) => {
const newOutput = [...prev, line];
const newOutput = [...prev, { id: outputIdRef.current++, line }];
setTimeout(scrollToBottom, 0);
return newOutput;
});
@ -69,6 +96,7 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
}
setOutput([]);
outputIdRef.current = 0;
setIsRunning(true);
appendOutput(`$ restic ${commandLine}`.trim());
appendOutput("---");
@ -80,6 +108,7 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
const command = parts[0];
const argsArray = parts.slice(1);
let success = false;
try {
const result = await devPanelExec({
path: { shortId: selectedRepoId },
@ -87,25 +116,8 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
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}`);
}
}
await processStream(result.stream, appendOutput, abortControllerRef.current.signal);
success = true;
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
appendOutput("---");
@ -113,7 +125,9 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
} else {
appendOutput(`Error: ${parseError(err)?.message}`);
}
} finally {
}
if (success || !abortControllerRef.current?.signal.aborted) {
setIsRunning(false);
abortControllerRef.current = null;
}
@ -125,6 +139,7 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
const handleClear = () => {
setOutput([]);
outputIdRef.current = 0;
};
const handleClose = () => {
@ -198,7 +213,7 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
Output will appear here...
</div>
<div className={cn("space-y-0.5", { hidden: output.length === 0 })}>
{output.map((line, i) => {
{output.map(({ id, line }) => {
let displayLine = line;
let isJson = false;
try {
@ -210,7 +225,7 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
}
return (
<pre
key={`${i}-${line.slice(0, 20)}`}
key={id}
className={cn("whitespace-pre-wrap break-all text-xs", {
"wrap-break-word text-[10px] leading-tight": isJson,
})}

View file

@ -67,7 +67,9 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
useEffect(() => {
if (!hasChanges) {
setAssignments(buildAssignments(currentMirrors));
queueMicrotask(() => {
setAssignments(buildAssignments(currentMirrors));
});
}
}, [currentMirrors, hasChanges]);

View file

@ -0,0 +1,48 @@
"use client";
import { Label, Pie, PieChart } from "recharts";
import { ByteSize } from "~/client/components/bytes-size";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "~/client/components/ui/chart";
import type { ChartConfig } from "~/client/components/ui/chart";
type CompressionChartProps = {
chartData: Array<{ name: string; value: number; fill: string }>;
chartConfig: ChartConfig;
compressionRatio: number;
};
export default function CompressionChart({ chartData, chartConfig, compressionRatio }: CompressionChartProps) {
return (
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]">
<PieChart>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value, name) => [<ByteSize key={name} bytes={value as number} />, name]}
/>
}
/>
<Pie data={chartData} dataKey="value" nameKey="name" innerRadius={65} strokeWidth={5}>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-3xl font-bold">
{compressionRatio > 0 ? `${compressionRatio.toFixed(1)}x` : "—"}
</tspan>
<tspan x={viewBox.cx} y={(viewBox.cy || 0) + 24} className="fill-muted-foreground">
Compression
</tspan>
</text>
);
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
);
}