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,62 +33,13 @@ type SseErrorEvent = {
type SseEvent = SseOutputEvent | SseDoneEvent | SseErrorEvent;
export function DevPanel({ open, onOpenChange }: DevPanelProps) {
const { data: repositories = [] } = useQuery({
...listRepositoriesOptions(),
enabled: open,
});
const [selectedRepoId, setSelectedRepoId] = useState<string>("");
const [commandLine, setCommandLine] = useState("snapshots");
const [output, setOutput] = useState<string[]>([]);
const [isRunning, setIsRunning] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const outputRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, []);
const appendOutput = useCallback(
(line: string) => {
setOutput((prev) => {
const newOutput = [...prev, line];
setTimeout(scrollToBottom, 0);
return newOutput;
});
},
[scrollToBottom],
);
const handleRun = async () => {
if (!selectedRepoId || !commandLine.trim()) {
return;
}
setOutput([]);
setIsRunning(true);
appendOutput(`$ restic ${commandLine}`.trim());
appendOutput("---");
abortControllerRef.current = new AbortController();
const trimmedLine = commandLine.trim();
const parts = trimmedLine.split(/\s+/);
const command = parts[0];
const argsArray = parts.slice(1);
try {
const result = await devPanelExec({
path: { shortId: selectedRepoId },
body: { command, args: argsArray.length > 0 ? argsArray : undefined },
signal: abortControllerRef.current.signal,
});
for await (const event of result.stream) {
if (abortControllerRef.current.signal.aborted) {
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;
@ -106,6 +57,67 @@ export function DevPanel({ open, onOpenChange }: DevPanelProps) {
appendOutput(`Error: ${data.message}`);
}
}
}
export function DevPanel({ open, onOpenChange }: DevPanelProps) {
const { data: repositories = [] } = useQuery({
...listRepositoriesOptions(),
enabled: open,
});
const [selectedRepoId, setSelectedRepoId] = useState<string>("");
const [commandLine, setCommandLine] = useState("snapshots");
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) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, []);
const appendOutput = useCallback(
(line: string) => {
setOutput((prev) => {
const newOutput = [...prev, { id: outputIdRef.current++, line }];
setTimeout(scrollToBottom, 0);
return newOutput;
});
},
[scrollToBottom],
);
const handleRun = async () => {
if (!selectedRepoId || !commandLine.trim()) {
return;
}
setOutput([]);
outputIdRef.current = 0;
setIsRunning(true);
appendOutput(`$ restic ${commandLine}`.trim());
appendOutput("---");
abortControllerRef.current = new AbortController();
const trimmedLine = commandLine.trim();
const parts = trimmedLine.split(/\s+/);
const command = parts[0];
const argsArray = parts.slice(1);
let success = false;
try {
const result = await devPanelExec({
path: { shortId: selectedRepoId },
body: { command, args: argsArray.length > 0 ? argsArray : undefined },
signal: abortControllerRef.current.signal,
});
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) {
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>
);
}