feat(web): confidence tier for network-timeline hotspot badge

An SSID matching the typical iPhone hotspot name (".. iPhone") is strong
evidence on its own — such networks now get a plain "Hotspot" badge, while
subnet-only evidence keeps the hedged "Hotspot?". Tooltip and footnote
explain the distinction; detection also fires on iPhone-named SSIDs
outside the Apple hotspot subnet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-21 19:00:56 +02:00
parent a9f6ccd86d
commit 10eedded13
3 changed files with 36 additions and 11 deletions
@@ -12,6 +12,7 @@ import { useCallback, useRef, useState } from 'react';
import {
fmtDuration,
fmtTime,
HotspotHint,
NetworkModel,
OFFLINE_COLOR,
segmentTitle,
@@ -60,12 +61,14 @@ export default function NetworkBand({ model }: { model: NetworkModel }) {
}
});
// Legend entries in first-seen order
const legend: { identity: string; label: string; color: string; kind: SegmentKind; hotspot?: boolean }[] = [];
// Legend entries in first-seen order; the strongest hotspot hint wins.
const legend: { identity: string; label: string; color: string; kind: SegmentKind; hotspot?: HotspotHint }[] = [];
for (const seg of segments) {
const existing = legend.find((l) => l.identity === seg.identity);
if (existing) {
existing.hotspot = existing.hotspot || !!seg.hotspot;
if (seg.hotspot && (!existing.hotspot || (seg.hotspot.confident && !existing.hotspot.confident))) {
existing.hotspot = seg.hotspot;
}
continue;
}
legend.push({
@@ -73,7 +76,7 @@ export default function NetworkBand({ model }: { model: NetworkModel }) {
label: segmentTitle(seg),
color: colors.get(seg.identity) ?? UNKNOWN_COLOR,
kind: seg.kind,
hotspot: !!seg.hotspot,
hotspot: seg.hotspot,
});
}
@@ -93,7 +96,9 @@ export default function NetworkBand({ model }: { model: NetworkModel }) {
)}
{l.label}
{l.hotspot && (
<span className="text-[10px] px-1 py-px rounded bg-amber-100 text-amber-800 font-medium">Hotspot?</span>
<span className="text-[10px] px-1 py-px rounded bg-amber-100 text-amber-800 font-medium">
{l.hotspot.confident ? 'Hotspot' : 'Hotspot?'}
</span>
)}
</span>
))}
@@ -191,7 +196,7 @@ export default function NetworkBand({ model }: { model: NetworkModel }) {
{seg.adapterDescription && <div>{seg.adapterDescription}</div>}
{seg.hotspot && (
<div className="text-amber-300">
Probable smartphone hotspot ({seg.hotspot.vendor}) — {seg.hotspot.reason}
{`${seg.hotspot.confident ? 'Smartphone hotspot' : 'Probable smartphone hotspot'} (${seg.hotspot.vendor}) — ${seg.hotspot.reason}`}
</div>
)}
{seg.ssidInferred && <div className="text-amber-300">SSID inferred from matching gateway</div>}
@@ -23,6 +23,12 @@ export type SegmentKind = 'ethernet' | 'wifi' | 'offline' | 'unknown';
export interface HotspotHint {
vendor: 'Apple' | 'Windows' | 'Android';
reason: string;
/**
* true when the SSID itself matches a typical phone-hotspot name
* (e.g. "xyz's iPhone") — strong enough to drop the "?" from the badge.
* false = subnet-only evidence, shown as "Hotspot?".
*/
confident: boolean;
}
export interface NetSegment {
@@ -141,14 +147,28 @@ export function detectHotspot(seg: NetSegment): HotspotHint | undefined {
if (seg.kind !== 'wifi') return undefined;
const gw = seg.gateway ?? '';
const ip = seg.ip ?? '';
// iOS default hotspot SSID is "<name>'s iPhone" (localized variants keep
// "iPhone"). A user COULD name any AP like this, but that is atypical —
// treat an iPhone-named SSID as confident, not just probable.
const iphoneSsid = /iphone/i.test(seg.ssid ?? '');
if (gw.includes('172.20.10.1') || ip.startsWith('172.20.10.')) {
return { vendor: 'Apple', reason: 'subnet 172.20.10.x — hard-wired by iOS for the personal hotspot' };
return {
vendor: 'Apple',
confident: iphoneSsid,
reason: iphoneSsid
? 'subnet 172.20.10.x (hard-wired by iOS) and the SSID matches the typical iPhone hotspot name'
: 'subnet 172.20.10.x — hard-wired by iOS for the personal hotspot',
};
}
if (gw.includes('192.168.137.1') || ip.startsWith('192.168.137.')) {
return { vendor: 'Windows', reason: 'subnet 192.168.137.x — Windows mobile hotspot default' };
return { vendor: 'Windows', confident: false, reason: 'subnet 192.168.137.x — Windows mobile hotspot default' };
}
if (gw.includes('192.168.43.1') || ip.startsWith('192.168.43.')) {
return { vendor: 'Android', reason: 'subnet 192.168.43.x — classic Android hotspot default' };
return { vendor: 'Android', confident: false, reason: 'subnet 192.168.43.x — classic Android hotspot default' };
}
if (iphoneSsid) {
return { vendor: 'Apple', confident: true, reason: `SSID "${seg.ssid}" matches the typical iPhone hotspot name` };
}
return undefined;
}
@@ -136,8 +136,8 @@ function Content() {
<NetworkBand model={model} />
{model.hotspotDetected && (
<p className="mt-1 text-[11px] text-amber-700">
Hotspot classification is a heuristic based on vendor-default subnets (e.g. 172.20.10.x on iOS) —
not a proven fact.
Hotspot classification is heuristic: &quot;Hotspot?&quot; marks subnet-only evidence (e.g. 172.20.10.x
on iOS); without the &quot;?&quot; the SSID itself matches a typical phone hotspot name.
</p>
)}
</div>