mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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=<sessionId>&tenantId=<tenantId></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" },
|
||||
|
||||
Reference in New Issue
Block a user