Skip to content

Commit 19c79b2

Browse files
committed
feat(clients): node health/quality rating in the location picker
Answers "is this node healthy, fast, and not crowded?" — a shared quality rating shown per country in both pickers. - core: gatewayQuality(latencyMs, load) → { tone, label, loadPct, score }. Load-weighted (bandwidth on a shared exit matters most), latency as a tiebreaker; a near-full node is always "Busy". 6 unit tests. - mobile picker: each row now shows a coloured quality dot + label (Excellent/Good/Fair/Busy) and "<latency> ms · <load>% load". (Search was already present.) - desktop picker: each row shows the quality label + coloured dot + load %. Verified via a headless render of the desktop picker (all 12 countries with quality + load). tsc / eslint / prettier / 54 core tests green.
1 parent 2db7b8a commit 19c79b2

6 files changed

Lines changed: 193 additions & 53 deletions

File tree

clients/core-ts/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export { discoverGateways, directoryVerify } from './discovery.js';
1515
export { enroll, status } from './enroll.js';
1616
export { buildWgConfig } from './wgconfig.js';
1717
export { selectHops, buildMultihopConfig } from './multihop.js';
18+
export { gatewayQuality } from './quality.js';
19+
export type { GatewayQuality, QualityTone } from './quality.js';
1820
export type {
1921
RouteStyle,
2022
Hop,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { gatewayQuality } from './quality.js';
3+
4+
describe('gatewayQuality', () => {
5+
it('rates a fast, empty node Excellent', () => {
6+
const q = gatewayQuality(40, 0.1);
7+
expect(q.tone).toBe('excellent');
8+
expect(q.loadPct).toBe(10);
9+
expect(q.score).toBeGreaterThanOrEqual(78);
10+
});
11+
12+
it('rates a near-full node Busy regardless of latency', () => {
13+
expect(gatewayQuality(30, 0.9).tone).toBe('busy');
14+
expect(gatewayQuality(500, 0.95).tone).toBe('busy');
15+
});
16+
17+
it('clamps load to a 0..100 percentage', () => {
18+
expect(gatewayQuality(100, 1.5).loadPct).toBe(100);
19+
expect(gatewayQuality(100, -0.2).loadPct).toBe(0);
20+
});
21+
22+
it('scores an unmeasured latency neutrally rather than failing', () => {
23+
const q = gatewayQuality(null, 0.3);
24+
expect(q.score).toBeGreaterThan(0);
25+
expect(['excellent', 'good', 'fair']).toContain(q.tone);
26+
});
27+
28+
it('ranks a lighter node above a heavier one at equal latency', () => {
29+
expect(gatewayQuality(120, 0.2).score).toBeGreaterThan(gatewayQuality(120, 0.7).score);
30+
});
31+
32+
it('degrades toward Fair as latency climbs', () => {
33+
expect(gatewayQuality(600, 0.3).tone).toBe('fair');
34+
});
35+
});

clients/core-ts/src/quality.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Node quality rating — turns a gateway's live signals (measured round-trip
3+
* latency + reported load) into a single human-facing quality so the clients can
4+
* show "which node is healthy, fast, and not crowded" at a glance.
5+
*
6+
* Load is weighted a little more than latency: on a shared exit node the load is
7+
* what determines the bandwidth you actually get, while latency mostly affects
8+
* responsiveness. A node reporting near-full load is always surfaced as "Busy"
9+
* regardless of latency.
10+
*/
11+
12+
/** Coarse quality bucket, for colour + label. */
13+
export type QualityTone = 'excellent' | 'good' | 'fair' | 'busy';
14+
15+
/** A gateway's connection quality, derived from latency + load. */
16+
export interface GatewayQuality {
17+
/** Coarse tone for colour + label. */
18+
readonly tone: QualityTone;
19+
/** Human label, e.g. `"Excellent"`. */
20+
readonly label: string;
21+
/** Load as a 0..100 percentage (how full the node is). */
22+
readonly loadPct: number;
23+
/** 0..100 composite score (higher is better); good for ranking ties. */
24+
readonly score: number;
25+
}
26+
27+
const LABELS: Record<QualityTone, string> = {
28+
excellent: 'Excellent',
29+
good: 'Good',
30+
fair: 'Fair',
31+
busy: 'Busy',
32+
};
33+
34+
/**
35+
* Rate a gateway from its measured round-trip latency (ms, or `null` if not yet
36+
* measured) and its reported `load` (0..1 utilisation from `/v1/info`).
37+
*
38+
* @param latencyMs - Measured RTT in ms, or `null` when unknown (scored neutral).
39+
* @param load - Reported utilisation, 0 (idle) .. 1 (full). Clamped.
40+
* @returns A {@link GatewayQuality} with tone, label, load %, and a 0..100 score.
41+
*/
42+
export function gatewayQuality(latencyMs: number | null, load: number): GatewayQuality {
43+
const l = Math.min(1, Math.max(0, load));
44+
const loadPct = Math.round(l * 100);
45+
46+
// Sub-scores in 0..1 (1 = best). Latency: <=60ms is ideal, >=400ms is poor.
47+
const loadScore = 1 - l;
48+
const latScore =
49+
latencyMs == null
50+
? 0.5
51+
: latencyMs <= 60
52+
? 1
53+
: latencyMs >= 400
54+
? 0
55+
: 1 - (latencyMs - 60) / 340;
56+
57+
const score = Math.round((0.6 * loadScore + 0.4 * latScore) * 100);
58+
59+
let tone: QualityTone;
60+
if (l >= 0.85) {
61+
tone = 'busy';
62+
} else if (score >= 78) {
63+
tone = 'excellent';
64+
} else if (score >= 55) {
65+
tone = 'good';
66+
} else {
67+
tone = 'fair';
68+
}
69+
70+
return { tone, label: LABELS[tone], loadPct, score };
71+
}

clients/desktop/src/components/CountryPicker.tsx

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useMemo, useState } from 'react';
22
import type { JSX } from 'react';
3+
import { gatewayQuality } from '@cumulusvpn/core';
4+
import type { QualityTone } from '@cumulusvpn/core';
35
import type { CountryOption } from '../lib/session.js';
46

57
interface Props {
@@ -9,16 +11,13 @@ interface Props {
911
readonly onClose: () => void;
1012
}
1113

12-
/** Map a 0..1 load into a latency-dot severity class. */
13-
function pingClass(load: number): string {
14-
if (load < 0.4) {
15-
return 'ping';
16-
}
17-
if (load < 0.75) {
18-
return 'ping mid';
19-
}
20-
return 'ping far';
21-
}
14+
/** Quality-tone → CSS colour var (green best … red busiest). */
15+
const TONE_VAR: Record<QualityTone, string> = {
16+
excellent: 'var(--green)',
17+
good: 'var(--cyan)',
18+
fair: 'var(--amber)',
19+
busy: 'var(--red)',
20+
};
2221

2322
/** Full-window country sheet with search — map-flavoured picker per the mockup. */
2423
export function CountryPicker({ countries, selectedCode, onPick, onClose }: Props): JSX.Element {
@@ -44,21 +43,27 @@ export function CountryPicker({ countries, selectedCode, onPick, onClose }: Prop
4443
autoFocus
4544
/>
4645
<div className="clist">
47-
{filtered.map((c) => (
48-
<button
49-
key={c.code}
50-
className={`crow ${c.code === selectedCode ? 'sel' : ''}`}
51-
onClick={() => {
52-
onPick(c.code);
53-
onClose();
54-
}}
55-
>
56-
<span className="flag">{c.flag}</span>
57-
<span className="cn">{c.name}</span>
58-
<span className={pingClass(c.load)} />
59-
<span className="lat">{Math.round(c.load * 100)}%</span>
60-
</button>
61-
))}
46+
{filtered.map((c) => {
47+
const q = gatewayQuality(null, c.load);
48+
return (
49+
<button
50+
key={c.code}
51+
className={`crow ${c.code === selectedCode ? 'sel' : ''}`}
52+
onClick={() => {
53+
onPick(c.code);
54+
onClose();
55+
}}
56+
>
57+
<span className="flag">{c.flag}</span>
58+
<span className="cn">{c.name}</span>
59+
<span className="qlabel" style={{ color: TONE_VAR[q.tone] }}>
60+
{q.label}
61+
</span>
62+
<span className="qdot" style={{ background: TONE_VAR[q.tone] }} />
63+
<span className="lat">{q.loadPct}%</span>
64+
</button>
65+
);
66+
})}
6267
{filtered.length === 0 && (
6368
<div className="lat" style={{ padding: '12px 4px' }}>
6469
No matching locations.

clients/desktop/src/styles.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,16 @@ button {
613613
font-size: 11px;
614614
color: var(--ink-3);
615615
}
616+
.crow .qlabel {
617+
font-size: 12px;
618+
font-weight: 600;
619+
}
620+
.crow .qdot {
621+
width: 7px;
622+
height: 7px;
623+
border-radius: 50%;
624+
flex-shrink: 0;
625+
}
616626
.sheet .close {
617627
margin-top: 12px;
618628
text-align: center;

clients/mobile/src/screens/CountryPickerScreen.tsx

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,19 @@
55
*/
66
import { useMemo, useState } from 'react';
77
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
8+
import { gatewayQuality } from '@cumulusvpn/core';
9+
import type { QualityTone } from '@cumulusvpn/core';
810
import type { Country } from '../lib/gateways';
9-
import { latencyBand } from '../lib/gateways';
10-
import { LatencyDot } from '../components/LatencyDot';
1111
import { color, font, radius, space } from '../theme/tokens';
1212

13+
/** Quality-tone → accent colour (green best … red busiest). */
14+
const TONE_COLOR: Record<QualityTone, string> = {
15+
excellent: color.green,
16+
good: color.cyan,
17+
fair: color.amber,
18+
busy: color.red,
19+
};
20+
1321
interface Props {
1422
readonly countries: readonly Country[];
1523
readonly selectedCode: string | null;
@@ -61,30 +69,36 @@ export function CountryPickerScreen({
6169
ListEmptyComponent={
6270
<Text style={styles.empty}>No gateways reachable — pull to refresh.</Text>
6371
}
64-
renderItem={({ item }) => (
65-
<Pressable
66-
style={[styles.row, item.code === selectedCode && styles.rowSelected]}
67-
onPress={() => {
68-
onSelect(item.code);
69-
onClose();
70-
}}
71-
accessibilityRole="button"
72-
>
73-
<Text style={styles.flag}>{item.flag}</Text>
74-
<View style={styles.meta}>
75-
<Text style={styles.name}>{item.name}</Text>
76-
<Text style={styles.sub}>
77-
{item.nodeCount} {item.nodeCount === 1 ? 'node' : 'nodes'} · {item.city}
78-
</Text>
79-
</View>
80-
<View style={styles.ping}>
81-
<LatencyDot band={latencyBand(item.latencyMs)} />
82-
<Text style={styles.pingText}>
83-
{item.latencyMs === null ? '—' : `${item.latencyMs} ms`}
84-
</Text>
85-
</View>
86-
</Pressable>
87-
)}
72+
renderItem={({ item }) => {
73+
const q = gatewayQuality(item.latencyMs, item.best.load);
74+
return (
75+
<Pressable
76+
style={[styles.row, item.code === selectedCode && styles.rowSelected]}
77+
onPress={() => {
78+
onSelect(item.code);
79+
onClose();
80+
}}
81+
accessibilityRole="button"
82+
>
83+
<Text style={styles.flag}>{item.flag}</Text>
84+
<View style={styles.meta}>
85+
<Text style={styles.name}>{item.name}</Text>
86+
<Text style={styles.sub}>
87+
{item.nodeCount} {item.nodeCount === 1 ? 'node' : 'nodes'} · {item.city}
88+
</Text>
89+
</View>
90+
<View style={styles.qual}>
91+
<View style={styles.qualTop}>
92+
<View style={[styles.qualDot, { backgroundColor: TONE_COLOR[q.tone] }]} />
93+
<Text style={[styles.qualLabel, { color: TONE_COLOR[q.tone] }]}>{q.label}</Text>
94+
</View>
95+
<Text style={styles.qualSub}>
96+
{item.latencyMs === null ? '— ms' : `${item.latencyMs} ms`} · {q.loadPct}% load
97+
</Text>
98+
</View>
99+
</Pressable>
100+
);
101+
}}
88102
/>
89103
</View>
90104
);
@@ -129,7 +143,10 @@ const styles = StyleSheet.create({
129143
meta: { flex: 1 },
130144
name: { color: color.ink, fontSize: 15, fontWeight: '600' },
131145
sub: { color: color.inkDim, fontSize: 12, marginTop: 2 },
132-
ping: { flexDirection: 'row', alignItems: 'center' },
133-
pingText: { fontFamily: font.mono, fontSize: 12, color: color.inkMuted },
146+
qual: { alignItems: 'flex-end' },
147+
qualTop: { flexDirection: 'row', alignItems: 'center', gap: 5 },
148+
qualDot: { width: 7, height: 7, borderRadius: 4 },
149+
qualLabel: { fontSize: 12.5, fontWeight: '600' },
150+
qualSub: { fontFamily: font.mono, fontSize: 10.5, color: color.inkFaint, marginTop: 2 },
134151
empty: { color: color.inkDim, textAlign: 'center', marginTop: 40, fontSize: 14 },
135152
});

0 commit comments

Comments
 (0)