feat(web): per-session network timeline page + device-details deep link

New /sessions/network-timeline?id=&tenantId= page visualizing every network
the device was on during enrollment: one colored segment per network
(Ethernet / WiFi SSIDs, fixed-order validated palette), hatched offline
gaps, ESP phase context lane, lifecycle + connectivity-check markers with
hover/tap tooltips, stats row and the raw network event list.

Segment model derives from network_interface_info + network_state_change
(before/after), enriches SSIDs from wifi_signal_info, infers missing
post-reconnect SSIDs via matching gateway, and flags probable smartphone
hotspots from vendor-default subnets (Apple 172.20.10.x, Windows
192.168.137.x, legacy Android 192.168.43.x) - always as a heuristic badge.
Axis is honest: linear per segment, minimum width for short segments,
boundary ticks show real times. Mobile scrolls horizontally.

Entry point: "Network timeline" deep link in DeviceDetailsCard's Network
section via new networkTimelineUrl() builder. Route registered in the SWA
config (exact routes before the /sessions/* wildcard, incl. the index.txt
RSC route) and excluded from LegacyPathRedirect's /sessions/{id} rewrite
via the new static-siblings set (test pinned).

Planned follow-up once the agent emits them: clock-change events (rebase
axis into clock eras) and standby/Modern-Standby events (render sleep as
its own segment kind).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-21 18:26:49 +02:00
parent c9701301ea
commit f8d0ccd2f5
8 changed files with 1036 additions and 2 deletions
@@ -1,6 +1,8 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { networkTimelineUrl } from "@/lib/routes";
import { EnrollmentEvent, Session } from "@/types";
import OobeConfigModal from "./OobeConfigModal";
import { compareVersions, stripGitHashSuffix } from "@/utils/bootstrapVersion";
@@ -245,6 +247,18 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
{/* Network */}
{(networkAdapters || dnsConfig || proxyConfig || networkInterfaceInfo || wifiSignalInfo) && (
<DetailSection title="Network">
{/* Deep link to the network timeline page */}
{session && (
<div className="-mt-1 mb-2">
<Link
href={networkTimelineUrl(session.sessionId, { tenantId: session.tenantId })}
className="text-xs text-green-700 hover:text-green-800 hover:underline inline-flex items-center gap-1"
>
<span>Network timeline</span>
<span aria-hidden>→</span>
</Link>
</div>
)}
{networkAdapters && networkAdapters.adapters && (
(networkAdapters.adapters as unknown[]).map((adapter, i: number) => {
const a = adapter as { [key: string]: unknown; macAddress?: string };
@@ -0,0 +1,380 @@
'use client';
// ─────────────────────────────────────────────────────────────────────────────
// Network timeline — the band chart (SVG).
//
// Phase context lane, one colored segment per network, offline gaps hatched,
// lifecycle + connectivity-check markers, hover/tap tooltips. Mobile keeps a
// minimum width and scrolls horizontally.
// ─────────────────────────────────────────────────────────────────────────────
import { useCallback, useRef, useState } from 'react';
import {
fmtDuration,
fmtTime,
NetworkModel,
OFFLINE_COLOR,
segmentTitle,
SegmentKind,
UNKNOWN_COLOR,
W,
} from './networkTimelineModel';
interface TooltipState {
x: number;
y: number;
maxLeft: number;
content: React.ReactNode;
}
export default function NetworkBand({ model }: { model: NetworkModel }) {
const wrapRef = useRef<HTMLDivElement>(null);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const showTooltip = useCallback((e: React.MouseEvent, content: React.ReactNode) => {
const rect = wrapRef.current?.getBoundingClientRect();
if (!rect) return;
setTooltip({ x: e.clientX - rect.left, y: e.clientY - rect.top, maxLeft: rect.width - 260, content });
}, []);
const hideTooltip = useCallback(() => setTooltip(null), []);
const { segments, colors, scale, phases, checks, lifeMarkers } = model;
const PHASE_Y = 8;
const PHASE_H = 22;
const BAND_Y = 40;
const BAND_H = 58;
const TICK_Y = BAND_Y + BAND_H;
const H = 172;
// Boundary ticks: real times at every segment boundary, staggered rows,
// labels suppressed when they would collide.
const ticks: { x: number; t: number; row: number }[] = [];
const lastX = [-100, -100];
scale.tB.forEach((t, i) => {
const x = scale.xB[i];
const row = ticks.length % 2;
if (x - lastX[row] > 60) {
ticks.push({ x, t, row });
lastX[row] = x;
}
});
// Legend entries in first-seen order
const legend: { identity: string; label: string; color: string; kind: SegmentKind; hotspot?: boolean }[] = [];
for (const seg of segments) {
const existing = legend.find((l) => l.identity === seg.identity);
if (existing) {
existing.hotspot = existing.hotspot || !!seg.hotspot;
continue;
}
legend.push({
identity: seg.identity,
label: segmentTitle(seg),
color: colors.get(seg.identity) ?? UNKNOWN_COLOR,
kind: seg.kind,
hotspot: !!seg.hotspot,
});
}
return (
<div className="mt-3">
<div className="flex flex-wrap gap-x-4 gap-y-1 mb-2 text-xs text-gray-700">
{legend.map((l) => (
<span key={l.identity} className="inline-flex items-center gap-1.5">
{l.kind === 'offline' ? (
<svg width="14" height="10" className="rounded-sm">
<rect width="14" height="10" fill="none" stroke={OFFLINE_COLOR} strokeWidth="1" />
<line x1="0" y1="10" x2="10" y2="0" stroke={OFFLINE_COLOR} strokeWidth="1.5" />
<line x1="6" y1="12" x2="16" y2="2" stroke={OFFLINE_COLOR} strokeWidth="1.5" />
</svg>
) : (
<span className="w-3.5 h-2.5 rounded-sm inline-block" style={{ backgroundColor: l.color }} />
)}
{l.label}
{l.hotspot && (
<span className="text-[10px] px-1 py-px rounded bg-amber-100 text-amber-800 font-medium">Hotspot?</span>
)}
</span>
))}
<span className="inline-flex items-center gap-1.5 ml-auto text-gray-500">
<span className="inline-flex w-3.5 h-3.5 rounded-full bg-green-600 text-white items-center justify-center text-[9px] leading-none">
✓
</span>
Connectivity check after switch
</span>
</div>
{/* Mobile: horizontal scroll for the band. The tooltip lives OUTSIDE the
scroll container (on the relative wrapper) so it can neither be
clipped nor spawn scrollbars when it extends past the band. */}
<div ref={wrapRef} className="relative">
<div className="overflow-x-auto">
<div className="min-w-[700px]">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full select-none" role="img" aria-label="Netzwerk-Timeline">
<defs>
<pattern id="offlineHatch" width="8" height="8" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<rect width="8" height="8" fill="transparent" />
<line x1="0" y1="0" x2="0" y2="8" stroke={OFFLINE_COLOR} strokeWidth="2.5" />
</pattern>
</defs>
{/* Phase context lane */}
{phases.map((p, i) => {
const x0 = scale.x(p.start);
const x1 = scale.x(p.end);
if (x1 - x0 < 1) return null;
const tip = (
<div>
<div className="font-semibold">{p.name}</div>
<div>
{fmtTime(p.start)} – {fmtTime(p.end)} ({fmtDuration(p.end - p.start)})
</div>
</div>
);
return (
<g key={`ph-${i}`}>
<rect
x={x0}
y={PHASE_Y}
width={x1 - x0}
height={PHASE_H}
className={i % 2 === 0 ? 'fill-gray-100' : 'fill-gray-200'}
onMouseMove={(e) => showTooltip(e, tip)}
onClick={(e) => showTooltip(e, tip)}
onMouseLeave={hideTooltip}
/>
{x1 - x0 > 70 && (
<text
x={(x0 + x1) / 2}
y={PHASE_Y + PHASE_H / 2 + 4}
textAnchor="middle"
fontSize="11"
className="fill-gray-600 pointer-events-none"
>
{p.name}
</text>
)}
</g>
);
})}
{/* Network band */}
{segments.map((seg, i) => {
const x0 = scale.xB[i];
const x1 = scale.xB[i + 1];
const color = colors.get(seg.identity) ?? UNKNOWN_COLOR;
const w = x1 - x0 - 2; // 2px spacer between fills
// Hotspot classification lives ONLY in the legend badge (and tooltip) —
// no marker on the segment itself, and no emoji in SVG <text> (renders broken).
const label = segmentTitle(seg);
const sub =
seg.kind === 'wifi' && seg.signalPercent != null
? `${seg.signalPercent}%`
: seg.linkSpeedMbps
? `${seg.linkSpeedMbps} Mbit`
: '';
const tip = (
<div className="space-y-0.5">
<div className="font-semibold">{segmentTitle(seg)}</div>
<div>
{fmtTime(seg.start)} – {fmtTime(seg.end)} · {fmtDuration(seg.end - seg.start)}
</div>
{seg.ip && <div>IP: {seg.ip}</div>}
{seg.gateway && <div>Gateway: {seg.gateway}</div>}
{seg.linkSpeedMbps != null && <div>Link: {seg.linkSpeedMbps} Mbit/s</div>}
{seg.signalPercent != null && (
<div>
Signal: {seg.signalPercent}%{seg.radioType ? ` (${seg.radioType})` : ''}
</div>
)}
{seg.adapterDescription && <div>{seg.adapterDescription}</div>}
{seg.hotspot && (
<div className="text-amber-300">
Probable smartphone hotspot ({seg.hotspot.vendor}) — {seg.hotspot.reason}
</div>
)}
{seg.ssidInferred && <div className="text-amber-300">SSID inferred from matching gateway</div>}
</div>
);
return (
<g key={`seg-${i}`}>
<rect
x={x0 + 1}
y={BAND_Y}
width={Math.max(w, 4)}
height={BAND_H}
rx="4"
fill={seg.kind === 'offline' ? 'url(#offlineHatch)' : color}
stroke={seg.kind === 'offline' ? OFFLINE_COLOR : 'none'}
strokeWidth={seg.kind === 'offline' ? 1.5 : 0}
fillOpacity={seg.kind === 'offline' ? 1 : 0.92}
onMouseMove={(e) => showTooltip(e, tip)}
onClick={(e) => showTooltip(e, tip)}
onMouseLeave={hideTooltip}
/>
{w > 60 && (
<text
x={x0 + (x1 - x0) / 2}
y={BAND_Y + BAND_H / 2 - 2}
textAnchor="middle"
fontSize="12"
fontWeight="600"
fill="#ffffff"
className="pointer-events-none"
style={seg.kind === 'offline' ? { fill: OFFLINE_COLOR } : undefined}
>
{label}
</text>
)}
{w > 60 && sub && seg.kind !== 'offline' && (
<text
x={x0 + (x1 - x0) / 2}
y={BAND_Y + BAND_H / 2 + 16}
textAnchor="middle"
fontSize="10"
fill="#ffffff"
fillOpacity="0.85"
className="pointer-events-none"
>
{sub}
</text>
)}
</g>
);
})}
{/* Lifecycle markers (reboot / desktop / terminal) */}
{lifeMarkers.map((m, i) => {
const x = Math.min(Math.max(scale.x(m.t), 6), W - 6);
const tip = (
<div>
<div className="font-semibold">{m.label}</div>
<div>{fmtTime(m.t)}</div>
</div>
);
return (
<g key={`life-${i}`}>
<line
x1={x}
y1={PHASE_Y}
x2={x}
y2={TICK_Y}
stroke="currentColor"
strokeDasharray="3 3"
strokeWidth="1"
className="text-gray-400"
/>
<circle
cx={x}
cy={PHASE_Y - 1}
r="4.5"
className="fill-gray-500"
onMouseMove={(e) => showTooltip(e, tip)}
onClick={(e) => showTooltip(e, tip)}
onMouseLeave={hideTooltip}
/>
</g>
);
})}
{/* Connectivity check markers */}
{checks.map((c, i) => {
// Clamp so the circle (r=8 + 2 stroke) never clips at the viewBox edges
const x = Math.min(Math.max(scale.x(c.t), 11), W - 11);
const tip = (
<div>
<div className="font-semibold mb-1">
Connectivity check · {c.reachable}/{c.total} reachable
</div>
<div>{fmtTime(c.t)}</div>
{c.results.map((r) => (
<div key={r.endpoint} className="flex gap-2">
<span>{r.reachable ? '✓' : '✗'}</span>
<span>{r.endpoint}</span>
<span className="opacity-70">
{r.reachable ? `${r.latencyMs} ms` : (r.error ?? 'unreachable')}
</span>
</div>
))}
</div>
);
return (
<g
key={`chk-${i}`}
onMouseMove={(e) => showTooltip(e, tip)}
onClick={(e) => showTooltip(e, tip)}
onMouseLeave={hideTooltip}
>
<circle
cx={x}
cy={BAND_Y + BAND_H - 1}
r="8"
fill={c.ok ? '#16a34a' : '#d97706'}
stroke="#ffffff"
strokeWidth="2"
/>
<text
x={x}
y={BAND_Y + BAND_H + 3}
textAnchor="middle"
fontSize="10"
fontWeight="700"
fill="#ffffff"
className="pointer-events-none"
>
{c.ok ? '✓' : '!'}
</text>
</g>
);
})}
{/* Boundary ticks with real times */}
{ticks.map((tk, i) => {
// Edge ticks anchor their label flush to the tick line instead of
// clamping a centered label sideways (which reads as a stray dash).
const anchor = tk.x < 30 ? 'start' : tk.x > W - 30 ? 'end' : 'middle';
return (
<g key={`tick-${i}`}>
<line
x1={tk.x}
y1={TICK_Y}
x2={tk.x}
y2={TICK_Y + 6}
stroke="currentColor"
strokeWidth="1"
className="text-gray-400"
/>
<text
x={tk.x}
y={TICK_Y + 20 + tk.row * 14}
textAnchor={anchor}
fontSize="10"
className="fill-gray-500"
>
{fmtTime(tk.t)}
</text>
</g>
);
})}
</svg>
</div>
</div>
{tooltip && (
<div
className="absolute z-10 pointer-events-none bg-gray-900/95 text-gray-100 text-xs rounded-md px-3 py-2 shadow-lg max-w-xs"
style={{ left: Math.max(0, Math.min(tooltip.x + 12, tooltip.maxLeft)), top: tooltip.y + 14 }}
>
{tooltip.content}
</div>
)}
</div>
<p className="mt-2 text-[11px] text-gray-400">
Time axis: linear within each segment; very short segments are stretched to a minimum width — the ticks at the
segment boundaries show the real times. * = inferred SSID.
</p>
</div>
);
}
@@ -0,0 +1,378 @@
// ─────────────────────────────────────────────────────────────────────────────
// Network timeline — domain model.
//
// Derives network segments (Ethernet / WiFi SSIDs / offline gaps) from the
// session's event stream, assigns fixed-order categorical colors, builds the
// piecewise time→x scale and classifies probable smartphone hotspots from
// well-known vendor subnets.
//
// Planned enrichment once the agent emits the corresponding events:
// - clock-change events → split the axis into clock eras and rebase each era
// by the cumulative delta (true elapsed-time axis, change point marked)
// - standby/Modern-Standby events → render sleep periods as their own
// segment kind instead of attributing them to the active network
// ─────────────────────────────────────────────────────────────────────────────
import { V1_PHASE_NAMES, V2_PHASE_NAMES } from '@/app/sessions/utils/phaseConstants';
import type { EnrollmentEvent, Session } from '@/types';
// ── Types ────────────────────────────────────────────────────────────────────
export type SegmentKind = 'ethernet' | 'wifi' | 'offline' | 'unknown';
export interface HotspotHint {
vendor: 'Apple' | 'Windows' | 'Android';
reason: string;
}
export interface NetSegment {
start: number;
end: number;
kind: SegmentKind;
ssid?: string;
ssidInferred?: boolean;
ip?: string;
gateway?: string;
linkSpeedMbps?: number;
adapterDescription?: string;
signalPercent?: number;
radioType?: string;
hotspot?: HotspotHint;
identity: string;
}
export interface PhaseBand {
start: number;
end: number;
name: string;
}
export interface CheckMarker {
t: number;
ok: boolean;
reachable: number;
total: number;
results: { endpoint: string; reachable: boolean; latencyMs?: number; httpStatus?: number; error?: string }[];
}
export interface LifeMarker {
t: number;
label: string;
}
export interface NetworkModel {
t0: number;
t1: number;
segments: NetSegment[];
colors: Map<string, string>;
scale: { tB: number[]; xB: number[]; x: (t: number) => number };
phases: PhaseBand[];
checks: CheckMarker[];
lifeMarkers: LifeMarker[];
networkEvents: EnrollmentEvent[];
offlineMs: number;
distinctNetworks: Set<string>;
switchCount: number;
hotspotDetected: boolean;
}
// ── Constants ────────────────────────────────────────────────────────────────
// Fixed-order categorical palette (validated light+dark, dataviz skill):
// Ethernet always blue; each distinct WiFi network takes the next slot.
export const ETHERNET_COLOR = '#3b82f6';
export const WIFI_COLORS = ['#16a34a', '#8b5cf6', '#d97706', '#0891b2'];
export const WIFI_OVERFLOW_COLOR = '#64748b';
export const OFFLINE_COLOR = '#dc2626';
export const UNKNOWN_COLOR = '#9ca3af';
export const NETWORK_EVENT_TYPES = new Set([
'network_interface_info',
'network_state_change',
'network_connectivity_check',
'wifi_signal_info',
'network_adapters',
'network_bandwidth_estimate',
'dns_configuration',
'proxy_configuration',
'outbound_ip',
]);
// ── Formatting helpers ───────────────────────────────────────────────────────
export function fmtTime(ms: number): string {
return new Date(ms).toLocaleTimeString(undefined, { hour12: false });
}
export function fmtDuration(ms: number): string {
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
export function segmentTitle(seg: NetSegment): string {
if (seg.kind === 'offline') return 'Offline';
if (seg.kind === 'unknown') return 'Unknown';
if (seg.kind === 'ethernet') return 'Ethernet';
return seg.ssid ? `WiFi "${seg.ssid}"${seg.ssidInferred ? ' *' : ''}` : 'WiFi';
}
function str(v: unknown): string | undefined {
if (typeof v !== 'string') return undefined;
if (v === '' || v === 'n/a' || v === 'None') return undefined;
return v;
}
function num(v: unknown): number | undefined {
return typeof v === 'number' && isFinite(v) && v > 0 ? v : undefined;
}
// ── Hotspot heuristic ────────────────────────────────────────────────────────
// Vendor-default hotspot subnets. Apple hard-codes 172.20.10.0/28 for the
// personal hotspot — near-fingerprint precision. Windows Mobile Hotspot
// defaults to 192.168.137.1. Older Android uses 192.168.43.1 (newer versions
// randomize, so Android detection has false NEGATIVES). Always presented as
// a heuristic ("vermutlich"), never as fact.
export function detectHotspot(seg: NetSegment): HotspotHint | undefined {
if (seg.kind !== 'wifi') return undefined;
const gw = seg.gateway ?? '';
const ip = seg.ip ?? '';
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' };
}
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' };
}
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 undefined;
}
// ── Segment derivation ───────────────────────────────────────────────────────
export function buildSegments(events: EnrollmentEvent[], t0: number, t1: number): NetSegment[] {
const segs: NetSegment[] = [];
const netInfo = events.find((e) => e.eventType === 'network_interface_info');
const d0 = (netInfo?.data ?? {}) as Record<string, unknown>;
let cur: NetSegment;
if (netInfo && d0.status !== 'no_active_interface') {
const kind: SegmentKind = d0.connectionType === 'WiFi' ? 'wifi' : 'ethernet';
cur = {
start: t0,
end: t1,
kind,
gateway: str(d0.gateways),
linkSpeedMbps: num(d0.linkSpeedMbps),
adapterDescription: str(d0.adapterDescription),
identity: '',
};
} else {
cur = { start: t0, end: t1, kind: netInfo ? 'offline' : 'unknown', identity: '' };
}
const changes = events
.filter((e) => e.eventType === 'network_state_change')
.map((e) => ({ t: Date.parse(e.timestamp), data: (e.data ?? {}) as Record<string, unknown> }))
.filter((c) => !isNaN(c.t))
.sort((a, b) => a.t - b.t);
for (const { t, data } of changes) {
const tc = Math.min(Math.max(t, cur.start), t1);
cur.end = tc;
segs.push(cur);
if (data.hasNetwork === false) {
cur = { start: tc, end: t1, kind: 'offline', identity: '' };
} else {
cur = {
start: tc,
end: t1,
kind: data.after_connectionType === 'WiFi' ? 'wifi' : 'ethernet',
ssid: str(data.after_wifiSsid),
ip: str(data.after_ipAddress),
gateway: str(data.after_gateway),
linkSpeedMbps: num(data.after_linkSpeedMbps),
identity: '',
};
}
}
segs.push(cur);
// Enrich WiFi segments with signal samples (SSID often only known from
// wifi_signal_info — the NIC info event has no SSID).
for (const e of events.filter((x) => x.eventType === 'wifi_signal_info')) {
const t = Date.parse(e.timestamp);
const data = (e.data ?? {}) as Record<string, unknown>;
const seg = segs.find((s) => s.kind === 'wifi' && t >= s.start && t < s.end);
if (!seg) continue;
if (!seg.ssid && str(data.wifiSsid)) seg.ssid = str(data.wifiSsid);
if (typeof data.wifiSignalPercent === 'number') seg.signalPercent = data.wifiSignalPercent;
if (str(data.wifiRadioType)) seg.radioType = str(data.wifiRadioType);
}
// Infer missing SSIDs from a sibling WiFi segment on the same gateway
// (right after reconnect the WLAN API frequently has no SSID yet).
for (const seg of segs) {
if (seg.kind === 'wifi' && !seg.ssid && seg.gateway) {
const twin = segs.find((s) => s !== seg && s.kind === 'wifi' && s.ssid && s.gateway === seg.gateway);
if (twin) {
seg.ssid = twin.ssid;
seg.ssidInferred = true;
}
}
}
// Identity = color key, assigned in first-seen order (fixed, never cycled).
for (const seg of segs) {
seg.hotspot = detectHotspot(seg);
if (seg.kind === 'ethernet') seg.identity = 'ethernet';
else if (seg.kind === 'wifi') seg.identity = seg.ssid ? `wifi:${seg.ssid}` : `wifi:@${seg.gateway ?? seg.ip ?? '?'}`;
else seg.identity = seg.kind;
}
return segs.filter((s) => s.end > s.start);
}
export function buildColorMap(segs: NetSegment[]): Map<string, string> {
const map = new Map<string, string>();
let wifiIdx = 0;
for (const seg of segs) {
if (map.has(seg.identity)) continue;
if (seg.kind === 'ethernet') map.set(seg.identity, ETHERNET_COLOR);
else if (seg.kind === 'wifi') {
map.set(seg.identity, wifiIdx < WIFI_COLORS.length ? WIFI_COLORS[wifiIdx] : WIFI_OVERFLOW_COLOR);
wifiIdx++;
} else if (seg.kind === 'offline') map.set(seg.identity, OFFLINE_COLOR);
else map.set(seg.identity, UNKNOWN_COLOR);
}
return map;
}
// ── Piecewise time→x mapping ─────────────────────────────────────────────────
// Linear between segment boundaries; tiny segments get a minimum width so
// short offline gaps stay visible. Boundary ticks always show REAL times, so
// the axis stays honest despite the local stretch.
export const W = 1200;
const MIN_SEG_W = 28;
export function buildScale(segs: NetSegment[]): NetworkModel['scale'] {
const widths = segs.map((s) => s.end - s.start);
const total = widths.reduce((a, b) => a + b, 0) || 1;
let px = widths.map((w) => Math.max((w / total) * W, MIN_SEG_W));
const sum = px.reduce((a, b) => a + b, 0);
px = px.map((w) => (w / sum) * W);
const tB: number[] = [segs[0]?.start ?? 0];
const xB: number[] = [0];
segs.forEach((s, i) => {
tB.push(s.end);
xB.push(xB[i] + px[i]);
});
const x = (t: number): number => {
if (t <= tB[0]) return 0;
for (let i = 1; i < tB.length; i++) {
if (t <= tB[i]) {
const f = (t - tB[i - 1]) / Math.max(tB[i] - tB[i - 1], 1);
return xB[i - 1] + f * (xB[i] - xB[i - 1]);
}
}
return W;
};
return { tB, xB, x };
}
// ── Full model ───────────────────────────────────────────────────────────────
export function buildNetworkModel(session: Session, events: EnrollmentEvent[]): NetworkModel | null {
if (events.length === 0) return null;
const evTimes = events.map((e) => Date.parse(e.timestamp)).filter((t) => !isNaN(t));
if (evTimes.length === 0) return null;
const t0 = Math.min(Date.parse(session.startedAt), ...evTimes);
const t1 = Math.max(session.completedAt ? Date.parse(session.completedAt) : 0, ...evTimes);
if (!(t1 > t0)) return null;
const segments = buildSegments(events, t0, t1);
if (segments.length === 0) return null;
const colors = buildColorMap(segments);
const scale = buildScale(segments);
const phaseNames = session.enrollmentType === 'v2' ? V2_PHASE_NAMES : V1_PHASE_NAMES;
const transitions = events
.filter((e) => e.eventType === 'phase_transition')
.map((e) => ({ t: Date.parse(e.timestamp), name: phaseNames[e.phase] ?? e.phaseName ?? `Phase ${e.phase}` }))
.filter((p) => !isNaN(p.t))
.sort((a, b) => a.t - b.t);
const phases: PhaseBand[] = [];
let prev = { t: t0, name: 'Start' };
for (const tr of transitions) {
if (tr.t > prev.t) phases.push({ start: prev.t, end: tr.t, name: prev.name });
prev = tr;
}
phases.push({ start: prev.t, end: t1, name: prev.name });
const checks: CheckMarker[] = events
.filter((e) => e.eventType === 'network_connectivity_check')
.map((e) => {
const d = (e.data ?? {}) as Record<string, unknown>;
const results = Array.isArray(d.results) ? (d.results as CheckMarker['results']) : [];
return {
t: Date.parse(e.timestamp),
ok: d.allReachable === true,
reachable: typeof d.reachableCount === 'number' ? d.reachableCount : results.filter((r) => r.reachable).length,
total: typeof d.totalCount === 'number' ? d.totalCount : results.length,
results,
};
})
.filter((c) => !isNaN(c.t));
const lifeMarkers: LifeMarker[] = events
.filter((e) =>
['system_reboot_detected', 'desktop_arrived', 'enrollment_complete', 'enrollment_failed'].includes(e.eventType),
)
.map((e) => ({
t: Date.parse(e.timestamp),
label:
e.eventType === 'system_reboot_detected'
? 'Reboot'
: e.eventType === 'desktop_arrived'
? 'Desktop'
: e.eventType === 'enrollment_complete'
? 'Completed'
: 'Failed',
}))
.filter((m) => !isNaN(m.t));
const networkEvents = events.filter((e) => NETWORK_EVENT_TYPES.has(e.eventType));
const offlineMs = segments.filter((s) => s.kind === 'offline').reduce((a, s) => a + (s.end - s.start), 0);
const distinctNetworks = new Set(
segments.filter((s) => s.kind === 'wifi' || s.kind === 'ethernet').map((s) => s.identity),
);
const switchCount = events.filter((e) => e.eventType === 'network_state_change').length;
const hotspotDetected = segments.some((s) => s.hotspot);
return {
t0,
t1,
segments,
colors,
scale,
phases,
checks,
lifeMarkers,
networkEvents,
offlineMs,
distinctNetworks,
switchCount,
hotspotDetected,
};
}
@@ -0,0 +1,247 @@
'use client';
// ─────────────────────────────────────────────────────────────────────────────
// Network timeline.
//
// Standalone full-analysis view of the network timeline for one session:
// stats row, the NetworkBand chart, and the raw network event list. Reached
// via the "Network timeline" deep link in the session detail page's
// DeviceDetailsCard (Network section).
//
// Open at: /sessions/network-timeline?id=<sessionId>&tenantId=<tenantId>
// ─────────────────────────────────────────────────────────────────────────────
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { ProtectedRoute } from '@/components/ProtectedRoute';
import { useAuth } from '@/contexts/AuthContext';
import { api } from '@/lib/api';
import { authenticatedFetch } from '@/lib/authenticatedFetch';
import { extractContinuation, MAX_EAGER_PAGES } from '@/lib/paginationLink';
import { sessionUrl } from '@/lib/routes';
import NetworkBand from './NetworkBand';
import { buildNetworkModel, fmtDuration, NetworkModel } from './networkTimelineModel';
import type { EnrollmentEvent, Session } from '@/types';
// ── Data loading ─────────────────────────────────────────────────────────────
function useSessionData(sessionId: string | null, tenantIdOverride: string | undefined) {
const { getAccessToken } = useAuth();
const [session, setSession] = useState<Session | null>(null);
const [events, setEvents] = useState<EnrollmentEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!sessionId) return;
setLoading(true);
setError(null);
try {
const sResp = await authenticatedFetch(api.sessions.get(sessionId, tenantIdOverride), getAccessToken);
if (!sResp.ok) throw new Error(`Failed to load session (${sResp.status})`);
const sData = await sResp.json();
const found: Session | undefined =
sData.session ?? sData.sessions?.find((s: Session) => s.sessionId === sessionId);
if (!found) throw new Error('Session not found');
setSession(found);
const tenantId = tenantIdOverride ?? found.tenantId;
const all: EnrollmentEvent[] = [];
let url = api.sessions.events(sessionId, tenantId, { pageSize: 200 });
for (let page = 0; page < MAX_EAGER_PAGES; page++) {
const resp = await authenticatedFetch(url, getAccessToken);
if (!resp.ok) throw new Error(`Failed to load events (${resp.status})`);
const data = await resp.json();
if (Array.isArray(data.events)) all.push(...data.events);
const cont = extractContinuation(data.nextLink);
if (!cont) break;
url = api.sessions.events(sessionId, tenantId, { pageSize: 200, continuation: cont });
}
all.sort((a, b) => a.sequence - b.sequence);
setEvents(all);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [sessionId, tenantIdOverride, getAccessToken]);
useEffect(() => {
const run = async () => {
await load();
};
void run();
}, [load]);
return { session, events, loading, error };
}
// ── Page ─────────────────────────────────────────────────────────────────────
export default function NetworkTimelinePage() {
return (
<ProtectedRoute>
<Suspense fallback={<div className="p-8 text-gray-500">Loading…</div>}>
<Content />
</Suspense>
</ProtectedRoute>
);
}
function Content() {
const searchParams = useSearchParams();
const sessionId = searchParams?.get('id') ?? null;
const tenantIdOverride = searchParams?.get('tenantId') ?? undefined;
const { session, events, loading, error } = useSessionData(sessionId, tenantIdOverride);
const model = useMemo(() => (session ? buildNetworkModel(session, events) : null), [session, events]);
if (!sessionId) {
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-2xl font-bold mb-2 text-gray-900">Network Timeline</h1>
<p className="text-gray-600 mb-4">
Provide the session ID via query parameters:{' '}
<code className="bg-gray-100 px-1 rounded">/sessions/network-timeline?id=&lt;sessionId&gt;&amp;tenantId=&lt;tenantId&gt;</code>
</p>
</div>
);
}
return (
<div className="max-w-7xl mx-auto p-4 sm:p-8">
<h1 className="text-2xl font-bold text-gray-900 mb-1">Network Timeline</h1>
{session && (
<p className="text-sm text-gray-600 mb-6">
{session.deviceName} · {session.model} · SN {session.serialNumber} ·{' '}
<span className="font-medium">{session.status}</span> ·{' '}
<Link
href={sessionUrl(session.sessionId, { tenantId: session.tenantId })}
className="text-blue-600 hover:underline"
>
Open session detail
</Link>
</p>
)}
{loading && <div className="text-gray-500 py-12 text-center">Loading session data…</div>}
{error && <div className="text-red-600 py-6">{error}</div>}
{model && session && (
<>
<StatsRow model={model} />
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-6">
<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.
</p>
)}
</div>
<EventList events={model.networkEvents} />
</>
)}
</div>
);
}
// ── Stats row ────────────────────────────────────────────────────────────────
function StatsRow({ model }: { model: NetworkModel }) {
const wifiSegs = model.segments.filter((s) => s.kind === 'wifi' && s.signalPercent != null);
const avgSignal = wifiSegs.length
? Math.round(wifiSegs.reduce((a, s) => a + (s.signalPercent ?? 0), 0) / wifiSegs.length)
: null;
const lastCheck = model.checks[model.checks.length - 1];
const items: { label: string; value: string; tone?: 'bad' | 'good' }[] = [
{ label: 'Duration', value: fmtDuration(model.t1 - model.t0) },
{ label: 'Networks', value: String(model.distinctNetworks.size) },
{ label: 'Network change events', value: String(model.switchCount) },
{
label: 'Offline total',
value: model.offlineMs > 0 ? fmtDuration(model.offlineMs) : '—',
tone: model.offlineMs > 0 ? 'bad' : undefined,
},
];
if (avgSignal != null) items.push({ label: 'Avg WiFi signal', value: `${avgSignal}%` });
if (lastCheck)
items.push({
label: 'Last connectivity check',
value: `${lastCheck.reachable}/${lastCheck.total} reachable`,
tone: lastCheck.ok ? 'good' : 'bad',
});
return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
{items.map((it) => (
<div key={it.label} className="bg-white border border-gray-200 rounded-lg px-3 py-2">
<div className="text-[11px] uppercase tracking-wide text-gray-500">{it.label}</div>
<div
className={`text-lg font-semibold ${
it.tone === 'bad' ? 'text-red-600' : it.tone === 'good' ? 'text-green-600' : 'text-gray-900'
}`}
>
{it.value}
</div>
</div>
))}
</div>
);
}
// ── Event list ───────────────────────────────────────────────────────────────
const CHANGE_TYPE_BADGES: Record<string, string> = {
network_lost: 'bg-red-100 text-red-800',
network_restored: 'bg-green-100 text-green-800',
type_change: 'bg-amber-100 text-amber-800',
ssid_change: 'bg-violet-100 text-violet-800',
adapter_change: 'bg-cyan-100 text-cyan-800',
ip_change: 'bg-gray-100 text-gray-800',
};
function EventList({ events }: { events: EnrollmentEvent[] }) {
if (events.length === 0) {
return <div className="text-gray-500 py-6">No network events in this session.</div>;
}
return (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200 font-semibold text-gray-900">
Network events ({events.length})
</div>
<ul className="divide-y divide-gray-100">
{events.map((e) => {
const d = (e.data ?? {}) as Record<string, unknown>;
const changeType = typeof d.changeType === 'string' ? d.changeType : null;
const rest = Object.fromEntries(Object.entries(d).filter(([k]) => k !== 'decisionState'));
return (
<li key={e.eventId || `${e.sessionId}-${e.sequence}`} className="px-4 py-2.5">
<details>
<summary className="cursor-pointer flex flex-wrap items-center gap-2 text-sm">
<span className="font-mono text-xs text-gray-500 w-20 shrink-0">
{new Date(e.timestamp).toLocaleTimeString(undefined, { hour12: false })}
</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700 font-mono">{e.eventType}</span>
{changeType && (
<span
className={`text-xs px-1.5 py-0.5 rounded font-medium ${CHANGE_TYPE_BADGES[changeType] ?? 'bg-gray-100 text-gray-700'}`}
>
{changeType}
</span>
)}
<span className="text-gray-800">{e.message}</span>
</summary>
<pre className="mt-2 text-xs bg-gray-50 rounded p-3 overflow-x-auto text-gray-700">
{JSON.stringify(rest, null, 2)}
</pre>
</details>
</li>
);
})}
</ul>
</div>
);
}
@@ -28,6 +28,11 @@ import {
* the target shapes.
*/
// Real static pages under /sessions/ — never legacy session ids. Every new
// /sessions/<subpage> route MUST be added here, or the legacy rewrite below
// hijacks it into /sessions?id=<subpage>.
const SESSIONS_STATIC_SIBLINGS = new Set(["inspector", "network-timeline"]);
function legacyTarget(pathname: string, search: URLSearchParams, hash: string): Route | null {
const seg = pathname.replace(/\/+$/, "").split("/").filter(Boolean).map(decodeURIComponent);
@@ -35,8 +40,8 @@ function legacyTarget(pathname: string, search: URLSearchParams, hash: string):
if (seg.length === 3 && seg[0] === "sessions" && seg[2] === "inspector") {
return inspectorUrl(seg[1], { tab: search.get("tab") ?? undefined });
}
// /sessions/{id} — "inspector" as an id cannot occur (the static sibling wins)
if (seg.length === 2 && seg[0] === "sessions" && seg[1] !== "inspector") {
// /sessions/{id} — a static sibling as an id cannot occur (the static page wins)
if (seg.length === 2 && seg[0] === "sessions" && !SESSIONS_STATIC_SIBLINGS.has(seg[1])) {
return sessionUrl(seg[1], {
tenantId: search.get("tenantId") ?? undefined,
hash: hash || undefined,
@@ -53,6 +53,7 @@ describe("legacyTarget", () => {
it("leaves canonical and unrelated paths alone", () => {
expect(legacyTarget("/sessions", q("id=abc"), "")).toBeNull();
expect(legacyTarget("/sessions/inspector", q("id=abc"), "")).toBeNull();
expect(legacyTarget("/sessions/network-timeline", q("id=abc&tenantId=t"), "")).toBeNull();
expect(legacyTarget("/apps/detail", q("name=x"), "")).toBeNull();
expect(legacyTarget("/admin/backups/detail", q("id=x"), "")).toBeNull();
expect(legacyTarget("/admin/customs-archive/detail", q(""), "")).toBeNull();
@@ -39,6 +39,13 @@ export function inspectorUrl(sessionId: string, opts?: { tab?: string }): Route
return withQuery("/sessions/inspector", { id: sessionId, tab: opts?.tab });
}
export function networkTimelineUrl(
sessionId: string,
opts?: { tenantId?: string },
): Route {
return withQuery("/sessions/network-timeline", { id: sessionId, tenantId: opts?.tenantId });
}
export function diagnosisUrl(sessionId: string): Route {
return withQuery("/diagnosis", { id: sessionId });
}
@@ -26,6 +26,7 @@
{ "route": "/sessions/index.txt", "rewrite": "/sessions/index.txt" },
{ "route": "/sessions/inspector/index.txt", "rewrite": "/sessions/inspector/index.txt" },
{ "route": "/sessions/network-timeline/index.txt", "rewrite": "/sessions/network-timeline/index.txt" },
{ "route": "/diagnosis/index.txt", "rewrite": "/diagnosis/index.txt" },
{ "route": "/apps/index.txt", "rewrite": "/apps/index.txt" },
{ "route": "/apps/detail/index.txt", "rewrite": "/apps/detail/index.txt" },
@@ -36,6 +37,7 @@
{ "route": "/sessions", "rewrite": "/sessions/index.html" },
{ "route": "/sessions/inspector", "rewrite": "/sessions/inspector/index.html" },
{ "route": "/sessions/network-timeline", "rewrite": "/sessions/network-timeline/index.html" },
{ "route": "/sessions/*", "rewrite": "/sessions/index.html" },
{ "route": "/diagnosis", "rewrite": "/diagnosis/index.html" },
{ "route": "/diagnosis/*", "rewrite": "/diagnosis/index.html" },