Compare commits

...

1 commit

Author SHA1 Message Date
Nicolas Meienberger
ebefe410f4 refactor: react-doctor 2026-02-21 11:16:27 +01:00
3 changed files with 90 additions and 25 deletions

View file

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

View file

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