diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx index 392ddf44..d20d426f 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx @@ -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) && ( + {/* Deep link to the network timeline page */} + {session && ( +
+ + Network timeline + → + +
+ )} {networkAdapters && networkAdapters.adapters && ( (networkAdapters.adapters as unknown[]).map((adapter, i: number) => { const a = adapter as { [key: string]: unknown; macAddress?: string }; diff --git a/src/Web/autopilot-monitor-web/app/sessions/network-timeline/NetworkBand.tsx b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/NetworkBand.tsx new file mode 100644 index 00000000..24d09046 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/NetworkBand.tsx @@ -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(null); + const [tooltip, setTooltip] = useState(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 ( +
+
+ {legend.map((l) => ( + + {l.kind === 'offline' ? ( + + + + + + ) : ( + + )} + {l.label} + {l.hotspot && ( + Hotspot? + )} + + ))} + + + ✓ + + Connectivity check after switch + +
+ + {/* 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. */} +
+
+
+ + + + + + + + + {/* 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 = ( +
+
{p.name}
+
+ {fmtTime(p.start)} – {fmtTime(p.end)} ({fmtDuration(p.end - p.start)}) +
+
+ ); + return ( + + showTooltip(e, tip)} + onClick={(e) => showTooltip(e, tip)} + onMouseLeave={hideTooltip} + /> + {x1 - x0 > 70 && ( + + {p.name} + + )} + + ); + })} + + {/* 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 (renders broken). + const label = segmentTitle(seg); + const sub = + seg.kind === 'wifi' && seg.signalPercent != null + ? `${seg.signalPercent}%` + : seg.linkSpeedMbps + ? `${seg.linkSpeedMbps} Mbit` + : ''; + const tip = ( +
+
{segmentTitle(seg)}
+
+ {fmtTime(seg.start)} – {fmtTime(seg.end)} · {fmtDuration(seg.end - seg.start)} +
+ {seg.ip &&
IP: {seg.ip}
} + {seg.gateway &&
Gateway: {seg.gateway}
} + {seg.linkSpeedMbps != null &&
Link: {seg.linkSpeedMbps} Mbit/s
} + {seg.signalPercent != null && ( +
+ Signal: {seg.signalPercent}%{seg.radioType ? ` (${seg.radioType})` : ''} +
+ )} + {seg.adapterDescription &&
{seg.adapterDescription}
} + {seg.hotspot && ( +
+ Probable smartphone hotspot ({seg.hotspot.vendor}) — {seg.hotspot.reason} +
+ )} + {seg.ssidInferred &&
SSID inferred from matching gateway
} +
+ ); + return ( + + showTooltip(e, tip)} + onClick={(e) => showTooltip(e, tip)} + onMouseLeave={hideTooltip} + /> + {w > 60 && ( + + {label} + + )} + {w > 60 && sub && seg.kind !== 'offline' && ( + + {sub} + + )} + + ); + })} + + {/* Lifecycle markers (reboot / desktop / terminal) */} + {lifeMarkers.map((m, i) => { + const x = Math.min(Math.max(scale.x(m.t), 6), W - 6); + const tip = ( +
+
{m.label}
+
{fmtTime(m.t)}
+
+ ); + return ( + + + showTooltip(e, tip)} + onClick={(e) => showTooltip(e, tip)} + onMouseLeave={hideTooltip} + /> + + ); + })} + + {/* 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 = ( +
+
+ Connectivity check · {c.reachable}/{c.total} reachable +
+
{fmtTime(c.t)}
+ {c.results.map((r) => ( +
+ {r.reachable ? '✓' : '✗'} + {r.endpoint} + + {r.reachable ? `${r.latencyMs} ms` : (r.error ?? 'unreachable')} + +
+ ))} +
+ ); + return ( + showTooltip(e, tip)} + onClick={(e) => showTooltip(e, tip)} + onMouseLeave={hideTooltip} + > + + + {c.ok ? '✓' : '!'} + + + ); + })} + + {/* 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 ( + + + + {fmtTime(tk.t)} + + + ); + })} + +
+
+ + {tooltip && ( +
+ {tooltip.content} +
+ )} +
+ +

+ 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. +

+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/sessions/network-timeline/networkTimelineModel.ts b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/networkTimelineModel.ts new file mode 100644 index 00000000..c6d2aac2 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/networkTimelineModel.ts @@ -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; + scale: { tB: number[]; xB: number[]; x: (t: number) => number }; + phases: PhaseBand[]; + checks: CheckMarker[]; + lifeMarkers: LifeMarker[]; + networkEvents: EnrollmentEvent[]; + offlineMs: number; + distinctNetworks: Set; + 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; + + 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 })) + .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; + 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 { + const map = new Map(); + 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; + 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, + }; +} diff --git a/src/Web/autopilot-monitor-web/app/sessions/network-timeline/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/page.tsx new file mode 100644 index 00000000..1e4d8bf0 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/page.tsx @@ -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=&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(null); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + + Loading…}> + + + + ); +} + +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 ( +
+

Network Timeline

+

+ Provide the session ID via query parameters:{' '} + /sessions/network-timeline?id=<sessionId>&tenantId=<tenantId> +

+
+ ); + } + + return ( +
+

Network Timeline

+ + {session && ( +

+ {session.deviceName} · {session.model} · SN {session.serialNumber} ·{' '} + {session.status} ·{' '} + + Open session detail + +

+ )} + + {loading &&
Loading session data…
} + {error &&
{error}
} + + {model && session && ( + <> + +
+ + {model.hotspotDetected && ( +

+ Hotspot classification is a heuristic based on vendor-default subnets (e.g. 172.20.10.x on iOS) — + not a proven fact. +

+ )} +
+ + + )} +
+ ); +} + +// ── 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 ( +
+ {items.map((it) => ( +
+
{it.label}
+
+ {it.value} +
+
+ ))} +
+ ); +} + +// ── Event list ─────────────────────────────────────────────────────────────── + +const CHANGE_TYPE_BADGES: Record = { + 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
No network events in this session.
; + } + return ( +
+
+ Network events ({events.length}) +
+
    + {events.map((e) => { + const d = (e.data ?? {}) as Record; + const changeType = typeof d.changeType === 'string' ? d.changeType : null; + const rest = Object.fromEntries(Object.entries(d).filter(([k]) => k !== 'decisionState')); + return ( +
  • +
    + + + {new Date(e.timestamp).toLocaleTimeString(undefined, { hour12: false })} + + {e.eventType} + {changeType && ( + + {changeType} + + )} + {e.message} + +
    +                  {JSON.stringify(rest, null, 2)}
    +                
    +
    +
  • + ); + })} +
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/LegacyPathRedirect.tsx b/src/Web/autopilot-monitor-web/components/LegacyPathRedirect.tsx index 45b82d89..2ad66f3b 100644 --- a/src/Web/autopilot-monitor-web/components/LegacyPathRedirect.tsx +++ b/src/Web/autopilot-monitor-web/components/LegacyPathRedirect.tsx @@ -28,6 +28,11 @@ import { * the target shapes. */ +// Real static pages under /sessions/ — never legacy session ids. Every new +// /sessions/ route MUST be added here, or the legacy rewrite below +// hijacks it into /sessions?id=. +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, diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/legacyPathRedirect.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/legacyPathRedirect.test.ts index b4f68da6..7c4d8316 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/legacyPathRedirect.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/legacyPathRedirect.test.ts @@ -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(); diff --git a/src/Web/autopilot-monitor-web/lib/routes.ts b/src/Web/autopilot-monitor-web/lib/routes.ts index a4a6a104..cb353093 100644 --- a/src/Web/autopilot-monitor-web/lib/routes.ts +++ b/src/Web/autopilot-monitor-web/lib/routes.ts @@ -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 }); } diff --git a/src/Web/autopilot-monitor-web/staticwebapp.config.json b/src/Web/autopilot-monitor-web/staticwebapp.config.json index 72088e7e..a3580fa6 100644 --- a/src/Web/autopilot-monitor-web/staticwebapp.config.json +++ b/src/Web/autopilot-monitor-web/staticwebapp.config.json @@ -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" },