Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 103 additions & 78 deletions ui/src/components/charts/HistoryChart.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,6 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
BarChart,
Bar,
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip } from 'recharts';
import { api } from '../../api/client';
import type { HistoryStatsTimeRange } from '../../types';

Expand All @@ -34,6 +25,8 @@ const COLORS = {
cancelled: '#71717a', // zinc-500
};

const CHART_HEIGHT = 192; // h-48 = 12rem = 192px

function formatTimestamp(timestamp: string, range: HistoryStatsTimeRange): string {
const date = new Date(timestamp);

Expand Down Expand Up @@ -82,9 +75,33 @@ function CustomTooltip({ active, payload, label }: CustomTooltipProps) {
}

export function HistoryChart({ groupId }: HistoryChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [timeRange, setTimeRange] = useState<HistoryStatsTimeRange>('auto');
const [chartType, setChartType] = useState<ChartType>('bar');

// Track container width with ResizeObserver.
useEffect(() => {
const container = containerRef.current;
if (!container) return;

const updateWidth = () => {
const { width } = container.getBoundingClientRect();
if (width > 0) {
setContainerWidth(width);
}
};

// Check immediately.
updateWidth();

// Observe for size changes.
const observer = new ResizeObserver(updateWidth);
observer.observe(container);

return () => observer.disconnect();
}, []);

const { data, isLoading, error } = useQuery({
queryKey: ['historyStats', groupId, timeRange],
queryFn: () => api.getHistoryStats(groupId, timeRange),
Expand Down Expand Up @@ -152,7 +169,7 @@ export function HistoryChart({ groupId }: HistoryChartProps) {
</div>

{/* Chart */}
<div className="h-48">
<div ref={containerRef} className="h-48">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<div className="text-sm text-zinc-500">Loading...</div>
Expand All @@ -165,72 +182,80 @@ export function HistoryChart({ groupId }: HistoryChartProps) {
<div className="flex h-full items-center justify-center">
<div className="text-sm text-zinc-500">No historical data available</div>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
{chartType === 'bar' ? (
<BarChart data={chartData} margin={{ top: 5, right: 5, left: -20, bottom: 5 }}>
<XAxis
dataKey="timestamp"
tick={{ fontSize: 10, fill: '#71717a' }}
tickLine={false}
axisLine={{ stroke: '#3f3f46' }}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: '#71717a' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="completed" stackId="a" fill={COLORS.completed} name="Completed" />
<Bar dataKey="failed" stackId="a" fill={COLORS.failed} name="Failed" />
<Bar dataKey="cancelled" stackId="a" fill={COLORS.cancelled} name="Cancelled" />
</BarChart>
) : (
<LineChart data={chartData} margin={{ top: 5, right: 5, left: -20, bottom: 5 }}>
<XAxis
dataKey="timestamp"
tick={{ fontSize: 10, fill: '#71717a' }}
tickLine={false}
axisLine={{ stroke: '#3f3f46' }}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: '#71717a' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="monotone"
dataKey="completed"
stroke={COLORS.completed}
strokeWidth={2}
dot={false}
name="Completed"
/>
<Line
type="monotone"
dataKey="failed"
stroke={COLORS.failed}
strokeWidth={2}
dot={false}
name="Failed"
/>
<Line
type="monotone"
dataKey="cancelled"
stroke={COLORS.cancelled}
strokeWidth={2}
dot={false}
name="Cancelled"
/>
</LineChart>
)}
</ResponsiveContainer>
)}
) : containerWidth > 0 ? (
chartType === 'bar' ? (
<BarChart
width={containerWidth}
height={CHART_HEIGHT}
data={chartData}
margin={{ top: 5, right: 5, left: -20, bottom: 5 }}
>
<XAxis
dataKey="timestamp"
tick={{ fontSize: 10, fill: '#71717a' }}
tickLine={false}
axisLine={{ stroke: '#3f3f46' }}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: '#71717a' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="completed" stackId="a" fill={COLORS.completed} name="Completed" />
<Bar dataKey="failed" stackId="a" fill={COLORS.failed} name="Failed" />
<Bar dataKey="cancelled" stackId="a" fill={COLORS.cancelled} name="Cancelled" />
</BarChart>
) : (
<LineChart
width={containerWidth}
height={CHART_HEIGHT}
data={chartData}
margin={{ top: 5, right: 5, left: -20, bottom: 5 }}
>
<XAxis
dataKey="timestamp"
tick={{ fontSize: 10, fill: '#71717a' }}
tickLine={false}
axisLine={{ stroke: '#3f3f46' }}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: '#71717a' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="monotone"
dataKey="completed"
stroke={COLORS.completed}
strokeWidth={2}
dot={false}
name="Completed"
/>
<Line
type="monotone"
dataKey="failed"
stroke={COLORS.failed}
strokeWidth={2}
dot={false}
name="Failed"
/>
<Line
type="monotone"
dataKey="cancelled"
stroke={COLORS.cancelled}
strokeWidth={2}
dot={false}
name="Cancelled"
/>
</LineChart>
)
) : null}
</div>

{/* Legend with totals */}
Expand Down
74 changes: 46 additions & 28 deletions ui/src/components/charts/MiniHistoryChart.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { BarChart, Bar, XAxis, YAxis, Tooltip } from 'recharts';
import { api } from '../../api/client';

interface MiniHistoryChartProps {
Expand Down Expand Up @@ -51,13 +45,40 @@ function CustomTooltip({ active, payload, label }: CustomTooltipProps) {
);
}

const CHART_HEIGHT = 80; // h-20 = 5rem = 80px

export function MiniHistoryChart({ groupId }: MiniHistoryChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);

const { data, isLoading } = useQuery({
queryKey: ['historyStats', groupId, '24h'],
queryFn: () => api.getHistoryStats(groupId, '24h'),
refetchInterval: 60000,
});

// Track container width with ResizeObserver.
useEffect(() => {
const container = containerRef.current;
if (!container) return;

const updateWidth = () => {
const { width } = container.getBoundingClientRect();
if (width > 0) {
setContainerWidth(width);
}
};

// Check immediately.
updateWidth();

// Observe for size changes.
const observer = new ResizeObserver(updateWidth);
observer.observe(container);

return () => observer.disconnect();
}, []);

const chartData = data?.buckets.map((bucket) => ({
timestamp: formatTimestamp(bucket.timestamp),
completed: bucket.completed,
Expand All @@ -67,26 +88,23 @@ export function MiniHistoryChart({ groupId }: MiniHistoryChartProps) {

const hasData = data && data.totals.completed + data.totals.failed + data.totals.cancelled > 0;

if (isLoading) {
return (
<div className="h-20 flex items-center justify-center">
<div className="text-xs text-zinc-600">Loading...</div>
</div>
);
}

if (!hasData) {
return (
<div className="h-20 flex items-center justify-center">
<div className="text-xs text-zinc-600">No history data</div>
</div>
);
}

return (
<div className="h-20">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 5, right: 5, left: -30, bottom: 0 }}>
<div ref={containerRef} className="h-20">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<div className="text-xs text-zinc-600">Loading...</div>
</div>
) : !hasData ? (
<div className="flex h-full items-center justify-center">
<div className="text-xs text-zinc-600">No history data</div>
</div>
) : containerWidth > 0 ? (
<BarChart
width={containerWidth}
height={CHART_HEIGHT}
data={chartData}
margin={{ top: 5, right: 5, left: -30, bottom: 0 }}
>
<XAxis
dataKey="timestamp"
tick={{ fontSize: 8, fill: '#52525b' }}
Expand All @@ -106,7 +124,7 @@ export function MiniHistoryChart({ groupId }: MiniHistoryChartProps) {
<Bar dataKey="failed" stackId="a" fill={COLORS.failed} />
<Bar dataKey="cancelled" stackId="a" fill={COLORS.cancelled} />
</BarChart>
</ResponsiveContainer>
) : null}
</div>
);
}
28 changes: 24 additions & 4 deletions ui/src/hooks/useWebSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export function useWebSocket(options: UseWebSocketOptions = {}) {
const subscribedGroupsRef = useRef<Set<string>>(new Set());
const queryClient = useQueryClient();
const [isConnected, setIsConnected] = useState(false);
// Flag to prevent reconnect and close pending connections when intentionally disconnecting
const isDisconnectingRef = useRef(false);

// Store options and queryClient in refs to avoid stale closures
const optionsRef = useRef(options);
Expand All @@ -47,6 +49,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}) {
}, [options, queryClient]);

const connect = useCallback(() => {
// Reset the disconnecting flag when connecting
isDisconnectingRef.current = false;

const token = api.getToken();
if (!token) {
return;
Expand Down Expand Up @@ -109,6 +114,11 @@ export function useWebSocket(options: UseWebSocketOptions = {}) {
wsRef.current = ws;

ws.onopen = () => {
// If disconnect was called while we were connecting, close immediately
if (isDisconnectingRef.current) {
ws.close();
return;
}
setIsConnected(true);
// Re-subscribe to all groups
subscribedGroupsRef.current.forEach((groupId) => {
Expand All @@ -119,8 +129,10 @@ export function useWebSocket(options: UseWebSocketOptions = {}) {
ws.onclose = () => {
setIsConnected(false);
wsRef.current = null;
// Attempt to reconnect after 3 seconds using ref
reconnectTimeoutRef.current = setTimeout(() => connectRef.current(), 3000);
// Only attempt to reconnect if we're not intentionally disconnecting
if (!isDisconnectingRef.current) {
reconnectTimeoutRef.current = setTimeout(() => connectRef.current(), 3000);
}
};

ws.onerror = () => {
Expand All @@ -137,7 +149,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}) {
};
} catch {
// Connection failed, will retry using ref
reconnectTimeoutRef.current = setTimeout(() => connectRef.current(), 3000);
if (!isDisconnectingRef.current) {
reconnectTimeoutRef.current = setTimeout(() => connectRef.current(), 3000);
}
}
}, []);

Expand All @@ -161,11 +175,17 @@ export function useWebSocket(options: UseWebSocketOptions = {}) {
}, []);

const disconnect = useCallback(() => {
isDisconnectingRef.current = true;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = undefined;
}
if (wsRef.current) {
wsRef.current.close();
// Only close if already open - if still connecting, the onopen handler
// will check isDisconnectingRef and close it then
if (wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.close();
}
wsRef.current = null;
}
setIsConnected(false);
Expand Down