Skip to content

Commit 53e1301

Browse files
author
Leopold
committed
feat(monitors): Kuma-style min/avg/max ping chart with status bar
The Uptime Kuma web dashboard has a beautiful chart per monitor that shows min/avg/max response time as three lines plus a colored status bar overlay for down/maintenance/pending heartbeats. Match that UI. Detail screen changes (app/monitors/[monitorId].tsx): - Range selector: 24h/7d/30d -> Recent/3h/6h/24h/1w (per Kuma) - New Kuma-style 4-uptime-pill row (7d/24h/30d/1y), always shown, independent of the chart range selector - New min/avg/max ping readout in the header for the current range - Uptime pills get a color tint (green >=99, amber >=95, red <95) - Bug fix: the 7d uptime pill was a lie (showed the 30d value). Kuma 2.3+ does push a real 168h/7d ratio (cached as '168' in the store); the comment-and-fallback in the old code was unnecessary. Now wired to the real value across all 4 windows. - Time range selector only controls the chart, not the pills (matches Kuma's separation of concerns: chart range vs uptime pill) Chart changes (ResponseTimeChart.tsx): - New optional 'series' prop (Series[] with kind: 'min' | 'avg' | 'max') in addition to the legacy 'data' prop (single series) - Optional 'statusOverlay' prop for the Kuma-style colored bar at the bottom of the chart (red=down, blue=maintenance, amber=pending, green=up) - 'kumaPingColors(brand)' helper returns the three greens Kuma uses (min = brand-700 dark, avg = brand, max = brand-400 bright) - The single-series mode (data prop) is preserved for the design-system preview screen i18n (5 locales): added ranges.recent/3h/6h/1w, uptimeWindows.7d, and responseTimeStats.min/avg/max keys. Parity test confirms all 5 locales match. Data flow is unchanged: we still only use Kuma 2.3+ socket events (heartbeatList, heartbeat, uptime, avgPing) and slice the cached 100-row heartbeatList by timestamp for ranges > Recent. Aggregation into min/avg/max buckets mirrors Kuma's server-side UptimeCalculator. All gates green: typecheck 0 errors, lint 0 errors (1 pre-existing warning not in this PR), 283/283 tests passing.
1 parent 73fdb90 commit 53e1301

8 files changed

Lines changed: 625 additions & 119 deletions

File tree

app/monitors/[monitorId].tsx

Lines changed: 335 additions & 45 deletions
Large diffs are not rendered by default.

src/components/chart/ResponseTimeChart.tsx

Lines changed: 218 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,92 @@
11
/**
22
* ResponseTimeChart - SVG line chart with Reanimated path-draw animation.
33
*
4-
* Renders a sparkline-style chart of response time over time.
5-
* Animates the path drawing on mount.
4+
* Two render modes:
65
*
7-
* Inputs: array of (timestamp, value) points.
8-
* Optional: y-axis max (auto if not provided), color, height.
6+
* 1. **Single series** (legacy, used by design-system preview):
7+
* Pass `data: TimePoint[]`. One line is drawn. Average is shown as
8+
* a dashed reference line, latest value as a corner label.
99
*
10-
* Theme: average-line color uses surface.border; chart label uses
11-
* surface.sunken bg + surface.text color so it reads on both themes.
10+
* 2. **Multi-series (Kuma-style ping chart)**: Pass `series: Series[]`
11+
* where each series has a `kind` of 'min' | 'avg' | 'max' and its
12+
* own color. Up to three lines are drawn on the same y-axis,
13+
* themed like Uptime Kuma's web dashboard:
14+
* - min: dark green
15+
* - avg: light green (the same brand color as legacy single mode)
16+
* - max: bright green
17+
* An optional `status` overlay (red/blue/yellow segments along the
18+
* bottom) can be drawn underneath the lines to mirror Kuma's bar
19+
* chart overlay for down/maintenance/pending heartbeats.
20+
*
21+
* Theme: surface.sunken bg, surface.text for labels, surface.border
22+
* for grid/avg reference.
1223
*/
1324

1425
import { useEffect, useMemo } from 'react';
1526
import { View, Text, StyleSheet } from 'react-native';
16-
import Svg, { Path, Line, Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
27+
import Svg, {
28+
Path,
29+
Line,
30+
Circle,
31+
Defs,
32+
LinearGradient,
33+
Stop,
34+
Rect,
35+
} from 'react-native-svg';
1736
import Animated, {
1837
useSharedValue,
1938
useAnimatedProps,
2039
withTiming,
2140
Easing,
2241
} from 'react-native-reanimated';
23-
import { spacing, typography, useAppTheme } from '@/theme';
42+
import { colors, spacing, typography, useAppTheme } from '@/theme';
2443
import type { TimePoint } from '@/domain/models';
2544

2645
const AnimatedPath = Animated.createAnimatedComponent(Path);
2746

28-
interface ResponseTimeChartProps {
47+
export type SeriesKind = 'min' | 'avg' | 'max';
48+
49+
export interface Series {
50+
kind: SeriesKind;
2951
data: TimePoint[];
52+
color: string;
53+
label: string;
54+
}
55+
56+
export interface StatusPoint {
57+
/** x position 0..1 of the chart width. */
58+
x: number;
59+
/** Status color (red/blue/yellow/green). */
60+
color: string;
61+
}
62+
63+
interface ResponseTimeChartProps {
64+
// Single-series mode (legacy)
65+
data?: TimePoint[];
66+
// Multi-series mode (Kuma-style)
67+
series?: Series[];
68+
/** Optional status overlay segments drawn at the bottom of the chart. */
69+
statusOverlay?: StatusPoint[];
3070
width?: number;
3171
height?: number;
72+
/** Single-series color override (only used when `data` is passed, not `series`). */
3273
color?: string;
33-
/** Show a subtle grid line at the average */
74+
/** Show a subtle grid line at the average (single-series only). */
3475
showAverage?: boolean;
35-
/** Show the latest value as a label */
76+
/** Show the latest value as a label (single-series only). */
3677
showLatestLabel?: boolean;
3778
/** Empty state message */
3879
emptyMessage?: string;
3980
}
4081

82+
const padX = 4;
83+
const padY = 8;
84+
const overlayHeight = 6; // height of the status bar overlay at the bottom
85+
4186
export function ResponseTimeChart({
4287
data,
88+
series,
89+
statusOverlay,
4390
width = 320,
4491
height = 120,
4592
color: colorProp,
@@ -48,65 +95,83 @@ export function ResponseTimeChart({
4895
emptyMessage = 'No data',
4996
}: ResponseTimeChartProps) {
5097
const { surface, brand } = useAppTheme();
51-
// Default to the theme-aware brand color (brand-400 in dark mode for
52-
// better contrast on near-black surfaces; brand-500 in light mode).
53-
const color = colorProp ?? brand;
5498
const progress = useSharedValue(0);
5599

100+
// Normalize to series[] internally so both code paths share logic.
101+
const allSeries: Series[] = useMemo(() => {
102+
if (series && series.length > 0) return series;
103+
if (data && data.length > 0) {
104+
return [
105+
{
106+
kind: 'avg',
107+
data,
108+
color: colorProp ?? brand,
109+
label: 'avg',
110+
},
111+
];
112+
}
113+
return [];
114+
}, [series, data, colorProp, brand]);
115+
56116
useEffect(() => {
57117
progress.value = 0;
58118
progress.value = withTiming(1, {
59119
duration: 800,
60120
easing: Easing.out(Easing.cubic),
61121
});
62-
}, [data, progress]);
122+
}, [allSeries, progress]);
63123

64-
const { path, points, avg, max, min, latest } = useMemo(() => {
65-
if (data.length === 0) {
66-
return { path: '', points: [], avg: 0, max: 0, min: 0, latest: 0 };
124+
// Compute y-axis bounds across ALL series so they share a scale.
125+
const layout = useMemo(() => {
126+
const allPoints = allSeries.flatMap((s) => s.data);
127+
if (allPoints.length === 0) {
128+
return {
129+
paths: [] as { kind: SeriesKind; color: string; d: string; latest?: { x: number; y: number } }[],
130+
minVal: 0,
131+
maxVal: 0,
132+
avgVal: 0,
133+
latestVal: 0,
134+
};
67135
}
68-
69-
const values = data.map((d) => d.value);
136+
const values = allPoints.map((p) => p.value);
70137
const minVal = Math.min(...values);
71138
const maxVal = Math.max(...values);
72139
const avgVal = values.reduce((a, b) => a + b, 0) / values.length;
73140
const latestVal = values[values.length - 1];
74141

75142
const range = maxVal - minVal || 1;
76-
const padX = 4;
77-
const padY = 8;
78143
const chartWidth = width - padX * 2;
79-
const chartHeight = height - padY * 2;
144+
const chartHeight = height - padY * 2 - (statusOverlay ? overlayHeight + 2 : 0);
80145

81-
const pts = data.map((d, i) => {
82-
const x = padX + (i / Math.max(1, data.length - 1)) * chartWidth;
83-
const y = padY + chartHeight - ((d.value - minVal) / range) * chartHeight;
84-
return { x, y, value: d.value };
146+
const paths = allSeries.map((s) => {
147+
const pts = s.data.map((d, i) => {
148+
const x = padX + (i / Math.max(1, s.data.length - 1)) * chartWidth;
149+
const y = padY + chartHeight - ((d.value - minVal) / range) * chartHeight;
150+
return { x, y };
151+
});
152+
if (pts.length === 0) {
153+
return { kind: s.kind, color: s.color, d: '' };
154+
}
155+
let d = `M ${pts[0].x} ${pts[0].y}`;
156+
for (let i = 1; i < pts.length; i++) {
157+
d += ` L ${pts[i].x} ${pts[i].y}`;
158+
}
159+
return {
160+
kind: s.kind,
161+
color: s.color,
162+
d,
163+
latest: pts[pts.length - 1],
164+
};
85165
});
86166

87-
// Build smooth path using a simple line (M, L, L, L...)
88-
let d = `M ${pts[0].x} ${pts[0].y}`;
89-
for (let i = 1; i < pts.length; i++) {
90-
d += ` L ${pts[i].x} ${pts[i].y}`;
91-
}
92-
93-
return {
94-
path: d,
95-
points: pts,
96-
avg: avgVal,
97-
max: maxVal,
98-
min: minVal,
99-
latest: latestVal,
100-
};
101-
}, [data, width, height]);
167+
return { paths, minVal, maxVal, avgVal, latestVal };
168+
}, [allSeries, width, height, statusOverlay]);
102169

103170
const animatedPathProps = useAnimatedProps(() => ({
104-
// Use stroke-dashoffset-style animation via opacity since
105-
// SVG stroke-dasharray in react-native-svg doesn't support animation yet.
106171
opacity: progress.value,
107172
}));
108173

109-
if (data.length === 0) {
174+
if (allSeries.length === 0 || allSeries.every((s) => s.data.length === 0)) {
110175
return (
111176
<View style={[styles.empty, { width, height }]}>
112177
<Text style={[typography.caption, { color: surface.textMuted }]}>
@@ -116,23 +181,22 @@ export function ResponseTimeChart({
116181
);
117182
}
118183

119-
const padX = 4;
120-
const padY = 8;
121-
const chartHeight = height - padY * 2;
122-
const avgY = padY + chartHeight - ((avg - min) / (max - min || 1)) * chartHeight;
184+
const chartHeight = height - padY * 2 - (statusOverlay ? overlayHeight + 2 : 0);
185+
const avgY = padY + chartHeight - ((layout.avgVal - layout.minVal) / (layout.maxVal - layout.minVal || 1)) * chartHeight;
186+
const isMulti = allSeries.length > 1;
123187

124188
return (
125189
<View style={{ width, height }}>
126190
<Svg width={width} height={height}>
127191
<Defs>
128192
<LinearGradient id="lineGradient" x1="0" y1="0" x2="0" y2="1">
129-
<Stop offset="0%" stopColor={color} stopOpacity={0.3} />
130-
<Stop offset="100%" stopColor={color} stopOpacity={0} />
193+
<Stop offset="0%" stopColor={brand} stopOpacity={0.3} />
194+
<Stop offset="100%" stopColor={brand} stopOpacity={0} />
131195
</LinearGradient>
132196
</Defs>
133197

134-
{/* Average line */}
135-
{showAverage && (
198+
{/* Average reference line (only in single-series mode) */}
199+
{!isMulti && showAverage && (
136200
<Line
137201
x1={padX}
138202
y1={avgY}
@@ -144,37 +208,123 @@ export function ResponseTimeChart({
144208
/>
145209
)}
146210

147-
{/* Main line */}
148-
<AnimatedPath
149-
d={path}
150-
stroke={color}
151-
strokeWidth={1.5}
152-
fill="none"
153-
animatedProps={animatedPathProps}
154-
/>
211+
{/* Lines for each series */}
212+
{layout.paths.map((p) => (
213+
<AnimatedPath
214+
key={p.kind}
215+
d={p.d}
216+
stroke={p.color}
217+
strokeWidth={isMulti ? 1.25 : 1.5}
218+
fill="none"
219+
animatedProps={animatedPathProps}
220+
/>
221+
))}
155222

156-
{/* Latest point dot */}
157-
{points.length > 0 && (
223+
{/* Latest-point dot for single-series mode only */}
224+
{!isMulti && layout.paths[0]?.latest && (
158225
<Circle
159-
cx={points[points.length - 1].x}
160-
cy={points[points.length - 1].y}
226+
cx={layout.paths[0].latest.x}
227+
cy={layout.paths[0].latest.y}
161228
r={3}
162-
fill={color}
229+
fill={layout.paths[0].color}
230+
/>
231+
)}
232+
233+
{/* Status bar overlay (Kuma-style) */}
234+
{statusOverlay && statusOverlay.length > 0 && (
235+
<StatusOverlay
236+
points={statusOverlay}
237+
x={padX}
238+
y={height - padY - overlayHeight}
239+
width={width - padX * 2}
240+
height={overlayHeight}
163241
/>
164242
)}
165243
</Svg>
166244

167-
{showLatestLabel && (
245+
{!isMulti && showLatestLabel && (
168246
<View style={[styles.label, { backgroundColor: surface.sunken }]}>
169247
<Text style={[typography.caption, styles.labelText, { color: surface.text }]}>
170-
{latest < 1000 ? `${Math.round(latest)}ms` : `${(latest / 1000).toFixed(2)}s`}
248+
{layout.latestVal < 1000
249+
? `${Math.round(layout.latestVal)}ms`
250+
: `${(layout.latestVal / 1000).toFixed(2)}s`}
171251
</Text>
172252
</View>
173253
)}
174254
</View>
175255
);
176256
}
177257

258+
// ---- Status overlay ----------------------------------------------------
259+
260+
/**
261+
* Status bar overlay drawn at the bottom of the chart.
262+
* Each point's x is in 0..1 (relative to width). The overlay paints
263+
* a thin colored segment at that x with the given width.
264+
*/
265+
function StatusOverlay({
266+
points,
267+
x,
268+
y,
269+
width,
270+
height,
271+
}: {
272+
points: StatusPoint[];
273+
x: number;
274+
y: number;
275+
width: number;
276+
height: number;
277+
}) {
278+
// Merge adjacent same-color points into runs for cleaner rendering.
279+
const runs: { fromX: number; toX: number; color: string }[] = [];
280+
for (let i = 0; i < points.length; i++) {
281+
const p = points[i];
282+
const px = x + p.x * width;
283+
const last = runs[runs.length - 1];
284+
if (last && last.color === p.color && Math.abs(last.toX - px) < 0.5) {
285+
last.toX = px + 1;
286+
} else {
287+
runs.push({ fromX: px, toX: px + 1, color: p.color });
288+
}
289+
}
290+
return (
291+
<>
292+
{runs.map((r, i) => (
293+
<Rect
294+
key={i}
295+
x={r.fromX}
296+
y={y}
297+
width={Math.max(1, r.toX - r.fromX)}
298+
height={height}
299+
fill={r.color}
300+
opacity={0.55}
301+
/>
302+
))}
303+
</>
304+
);
305+
}
306+
307+
// ---- Kuma-style palette helpers ----------------------------------------
308+
309+
/**
310+
* Pick the three greens Kuma uses for min/avg/max lines.
311+
* Centralized here so the call site doesn't need to know the palette.
312+
*
313+
* Kuma's source: `min` = #126331, `avg` = #5CDD8B, `max` = #21b55a.
314+
* We map "avg" to the brand color so it always reads on both themes.
315+
*/
316+
export function kumaPingColors(brandColor: string): {
317+
min: string;
318+
avg: string;
319+
max: string;
320+
} {
321+
return {
322+
min: colors.brand?.[700] ?? '#047857',
323+
avg: brandColor,
324+
max: colors.brand?.[400] ?? '#34D399',
325+
};
326+
}
327+
178328
const styles = StyleSheet.create({
179329
empty: {
180330
alignItems: 'center',

src/components/chart/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
* Re-exports for the chart module.
33
*/
44

5-
export { ResponseTimeChart } from './ResponseTimeChart';
5+
export { ResponseTimeChart, kumaPingColors } from './ResponseTimeChart';
6+
export type { Series, SeriesKind, StatusPoint } from './ResponseTimeChart';
67
export { UptimeBar } from './UptimeBar';

0 commit comments

Comments
 (0)