# Files changed · PR \#110 · flambo/atlantis

[View on GitCafe](https://git.cafe/flambo/atlantis/pull/110/diffs)

Repository: [flambo/atlantis](https://git.cafe/flambo/atlantis)

Visibility: public

[feat: compute and chart IPv6 MAAD](https://git.cafe/flambo/atlantis/pull/110.md)

Version: 0

Head commit: ca9b0274e7cae2c271b7495b949768eca9a303b0

Base commit: b590ac31ce70a3734ec835b846055a8aea5799b3

Comparison base: b590ac31ce70a3734ec835b846055a8aea5799b3

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/src/lib/components/charts/BreakdownChart.svelte

Status: modified; +47; −12

```diff
@@ -9,14 +9,18 @@
 	import ChartCard from './ChartCard.svelte';
 	import { Checkbox } from '$lib/components/ui/checkbox';
 	import { Skeleton } from '$lib/components/ui/skeleton';
+	import SegmentedControl from '$lib/components/common/SegmentedControl.svelte';
 	import { navigateToNetflowFile } from '$lib/utils/netflow-file-navigation';
+	import {
+		DEFAULT_MAAD_IP_VERSION,
+		MAAD_IP_VERSION_OPTIONS,
+		type FlowDirection,
+		type IpGranularity,
+		type IpMetricKey,
+		type MaadIpVersion,
-	import type {
-		FlowDirection,
-		IpGranularity,
-		IpMetricKey,
-		ProtocolMetricKey,
-		SpectrumPoint,
-		TimeBucket
+		type ProtocolMetricKey,
+		type SpectrumPoint,
+		type TimeBucket
 	} from '$lib/types/types';
 	import type { SpectrumStatsPayload } from '$lib/types/spectrum-stats';
 	import {
@@ -94,6 +98,7 @@
 		granularity?: IpGranularity;
 		router?: string;
 		addressType?: 'sa' | 'da';
+		ipVersion?: MaadIpVersion;
 		availableRouters?: string[];
 		routers?: RouterConfig;
 		activeMetrics?: MetricsForKind<Kind>;
@@ -102,6 +107,7 @@
 		onGroupByChange?: (payload: { groupBy: GroupByOption }) => void;
 		onRouterChange?: (payload: { router: string }) => void;
 		onAddressTypeChange?: (payload: { addressType: 'sa' | 'da' }) => void;
+		onIpVersionChange?: (payload: { ipVersion: MaadIpVersion }) => void;
 		onMetricsChange?: (payload: { metrics: MetricsForKind<Kind> }) => void;
 	}>();
 	function getConfig(kind: BreakdownChartKind): BreakdownChartConfig {
@@ -136,6 +142,7 @@
 	let loading = $state(false);
 	let error = $state<string | null>(null);
 	let addressType = $state<'sa' | 'da'>(getInitialAddressType());
+	const ipVersion = $derived<MaadIpVersion>(props.ipVersion ?? DEFAULT_MAAD_IP_VERSION);
 	let bucketStarts: number[] = [];
 
 	let chartCanvas = $state<HTMLCanvasElement | null>(null);
@@ -490,6 +497,13 @@
 		props.onAddressTypeChange?.({ addressType: nextAddressType });
 	}
 
+	function handleIpVersionChange(nextIpVersion: MaadIpVersion) {
+		if (nextIpVersion === ipVersion) {
+			return;
+		}
+		props.onIpVersionChange?.({ ipVersion: nextIpVersion });
+	}
+
 	function publishRangeSelection(startIndex: number, endIndex: number) {
 		const labels = getSelectionLabels(chart, startIndex, endIndex);
 		if (!labels) return;
@@ -645,7 +659,13 @@
 			const labelForSlug = activeLabel ?? label;
 			const slug = generateSlugFromLabel(labelForSlug, '5min');
 			if (slug) {
+				void navigateToNetflowFile(
+					goto,
-				void navigateToNetflowFile(goto, slug, props.dataset, props.direction ?? 'all');
+					slug,
+					props.dataset,
+					props.direction ?? 'all',
+					props.ipVersion
+				);
 			}
 			return;
 		}
@@ -1178,6 +1198,7 @@
 		granularity: IpGranularity;
 		routers: string[];
 		direction: FlowDirection;
+		ipVersion?: MaadIpVersion;
 	};
 
 	let lastFiltersKey = '';
@@ -1198,7 +1219,8 @@
 			dataset: props.dataset ?? '',
 			granularity: filters.granularity,
 			routers: filters.routers,
+			direction: filters.direction,
-			direction: filters.direction
+			ipVersion: filters.ipVersion ?? null
 		});
 	}
 
@@ -1218,7 +1240,8 @@
 			dataset: props.dataset ?? '',
 			granularity: filters.granularity,
 			routers: filters.routers.join(','),
+			direction: filters.direction,
+			...(filters.ipVersion !== undefined ? { ipVersion: String(filters.ipVersion) } : {})
-			direction: filters.direction
 		});
 
 		try {
@@ -1369,6 +1392,7 @@
 		const endDateProp = props.endDate;
 		const granularityProp = props.granularity;
 		const nextAddressType = props.addressType ?? 'sa';
+		const nextIpVersion = props.ipVersion ?? DEFAULT_MAAD_IP_VERSION;
 		const direction = props.direction ?? 'all';
 
 		if (nextAddressType !== addressType) {
@@ -1391,7 +1415,8 @@
 			// The spectrum card displays one source at a time. Fetching every available source
 			// multiplied its SQL work and response size while the client discarded all but this one.
 			routers: nextRouter ? [nextRouter] : [],
+			direction,
-			direction
+			ipVersion: nextIpVersion
 		};
 
 		currentGranularity = filters.granularity;
@@ -1469,7 +1494,7 @@
 					{/if}
 				</div>
 				<div class="flex flex-wrap items-center gap-4">
-					{#each [['sa', 'Source IPv4'], ['da', 'Destination IPv4']] as const as addressOption (addressOption[0])}
+					{#each [['sa', 'Source'], ['da', 'Destination']] as const as addressOption (addressOption[0])}
 						<label class="text-foreground flex cursor-pointer items-center gap-2 text-sm">
 							<input
 								type="radio"
@@ -1482,6 +1507,16 @@
 						</label>
 					{/each}
 				</div>
+				<SegmentedControl
+					options={MAAD_IP_VERSION_OPTIONS.map((option) => ({
+						value: String(option.value),
+						label: option.label
+					}))}
+					value={String(ipVersion)}
+					onValueChange={(value) => handleIpVersionChange(Number(value) as MaadIpVersion)}
+					ariaLabel="Select MAAD IP address family"
+					buttonClass="sm:min-w-32"
+				/>
 			</div>
 		{:else}
 			<div class="flex flex-wrap items-center gap-4">
```

## apps/web/src/lib/components/netflow/DatasetDashboardPage.svelte

Status: modified; +18; −2

```diff
@@ -16,10 +16,12 @@
 	import type { Attachment } from 'svelte/attachments';
 	import { clampGroupByToDateRange } from '$lib/components/charts/chart-utils';
 	import {
+		DEFAULT_MAAD_IP_VERSION,
 		type FlowDirection,
 		IP_METRIC_OPTIONS,
 		type IpGranularity,
 		type IpMetricKey,
+		type MaadIpVersion,
 		type ProtocolMetricKey
 	} from '$lib/types/types';
 	import { watch } from 'runed';
@@ -142,6 +144,7 @@
 
 	const ipGranularity = $derived(GROUP_BY_TO_IP[selectedGroupBy]);
 	const hasLocality = $derived(props.hasLocality ?? true);
+	const ipVersion = $derived<MaadIpVersion>(params.ipVersion);
 	const direction = $derived<FlowDirection>(
 		hasLocality ? (params.direction as FlowDirection) : 'all'
 	);
@@ -398,7 +401,7 @@
 	}
 
 	function handleMetricNavigateToFile(slug: string) {
-		void navigateToNetflowFile(goto, slug, props.dataset, direction);
+		void navigateToNetflowFile(goto, slug, props.dataset, direction, ipVersion);
 	}
 
 	function handleRoutersChange(payload: { routers: RouterConfig }) {
@@ -425,6 +428,13 @@
 		params.direction = payload.direction;
 	}
 
+	function handleIpVersionChange(payload: { ipVersion: MaadIpVersion }) {
+		if (payload.ipVersion === params.ipVersion) {
+			return;
+		}
+		params.ipVersion = payload.ipVersion;
+	}
+
 	function handleResetView() {
 		const today = new Date().toJSON().slice(0, 10);
 		selectedGroupBy = 'date';
@@ -434,7 +444,8 @@
 			groupBy: selectedGroupBy,
 			startDate,
 			endDate,
+			direction: 'all',
-			direction: 'all'
+			ipVersion: DEFAULT_MAAD_IP_VERSION
 		});
 	}
 </script>
@@ -530,6 +541,7 @@
 						{routersLoaded}
 						{dataOptions}
 						{direction}
+						{ipVersion}
 						onDateChange={handleDateChange}
 						onGroupByChange={handleGroupByChange}
 						onDataOptionsChange={handleDataOptionsChange}
@@ -562,6 +574,7 @@
 						routers={selectedRouters}
 						activeMetrics={ipMetrics}
 						{direction}
+						{ipVersion}
 						onDateChange={handleDateChange}
 						onGroupByChange={handleGroupByChange}
 						onMetricsChange={handleIpMetricsChange}
@@ -576,6 +589,7 @@
 						routers={selectedRouters}
 						activeMetrics={protocolMetrics}
 						{direction}
+						{ipVersion}
 						onDateChange={handleDateChange}
 						onGroupByChange={handleGroupByChange}
 						onMetricsChange={(payload) => {
@@ -591,6 +605,7 @@
 						granularity={ipGranularity}
 						router={selectedSpectrumRouter}
 						addressType={selectedSpectrumAddressType}
+						{ipVersion}
 						availableRouters={availableSpectrumRouters}
 						{direction}
 						onDateChange={handleDateChange}
@@ -601,6 +616,7 @@
 						onAddressTypeChange={(payload) => {
 							selectedSpectrumAddressType = payload.addressType;
 						}}
+						onIpVersionChange={handleIpVersionChange}
 					/>
 				{:else}
 					<CoverageStrip
```

## apps/web/src/lib/components/netflow/NetflowDashboard.svelte

Status: modified; +3; −1

```diff
@@ -22,6 +22,7 @@
 	} from './types.ts';
 	import type {
 		FlowDirection,
+		MaadIpVersion,
 		NetflowIpFamily,
 		NetflowMetricTotals,
 		NetflowStatsResponse,
@@ -38,6 +39,7 @@
 		routersLoaded: boolean;
 		dataOptions: DataOption[];
 		direction: FlowDirection;
+		ipVersion?: MaadIpVersion;
 		onDateChange?: (payload: { startDate: string; endDate: string }) => void;
 		onGroupByChange?: (payload: { groupBy: GroupByOption }) => void;
 		onDataOptionsChange?: (payload: { options: DataOption[] }) => void;
@@ -237,7 +239,7 @@
 	}
 
 	function handleNavigateToFile(slug: string) {
-		void navigateToNetflowFile(goto, slug, props.dataset, props.direction);
+		void navigateToNetflowFile(goto, slug, props.dataset, props.direction, props.ipVersion);
 	}
 
 	function handleChartTypeChange(newChartType: ChartTypeOption) {
```

## apps/web/src/lib/components/netflow/NetflowFileDetailPage.svelte

Status: modified; +36; −3

```diff
@@ -1,11 +1,17 @@
 <script lang="ts">
-	import { afterNavigate } from '$app/navigation';
+	import { afterNavigate, goto } from '$app/navigation';
 	import { getNetflowFileDetailLoader } from '$lib/components/netflow/file-detail-loader.svelte';
 	import NetflowFileHeader from '$lib/components/netflow/NetflowFileHeader.svelte';
 	import NetflowFileLoadingSkeleton from '$lib/components/netflow/NetflowFileLoadingSkeleton.svelte';
 	import NetflowFileMessageCard from '$lib/components/netflow/NetflowFileMessageCard.svelte';
 	import NetflowFileRouterCard from '$lib/components/netflow/NetflowFileRouterCard.svelte';
+	import SegmentedControl from '$lib/components/common/SegmentedControl.svelte';
-	import type { FlowDirection } from '$lib/types/types';
+	import { navigateToNetflowFile } from '$lib/utils/netflow-file-navigation';
+	import {
+		MAAD_IP_VERSION_OPTIONS,
+		type FlowDirection,
+		type MaadIpVersion
+	} from '$lib/types/types';
 	import {
 		createDateFromPSTComponents,
 		epochToPSTComponents,
@@ -17,6 +23,7 @@
 		dataset: string;
 		slug: string;
 		direction: FlowDirection;
+		ipVersion: MaadIpVersion;
 		fileInfo: {
 			year: string;
 			month: string;
@@ -52,7 +59,7 @@
 	const nextSlug = $derived(getNextSlug(data.slug));
 
 	function syncLoader() {
+		loader = getNetflowFileDetailLoader(data.dataset, data.slug, data.direction, data.ipVersion);
-		loader = getNetflowFileDetailLoader(data.dataset, data.slug, data.direction);
 		loader.refresh();
 	}
 
@@ -60,6 +67,17 @@
 		loader?.refresh();
 	}
 
+	function handleMaadIpVersionChange(nextIpVersion: MaadIpVersion) {
+		if (nextIpVersion === data.ipVersion) {
+			return;
+		}
+		void navigateToNetflowFile(goto, data.slug, data.dataset, data.direction, nextIpVersion, {
+			replaceState: true,
+			noScroll: true,
+			keepFocus: true
+		});
+	}
+
 	onMount(() => {
 		syncLoader();
 	});
@@ -74,6 +92,7 @@
 		dataset={data.dataset}
 		{nextSlug}
 		direction={data.direction}
+		ipVersion={data.ipVersion}
 		filename={data.fileInfo.filename}
 		year={data.fileInfo.year}
 		month={data.fileInfo.month}
@@ -87,6 +106,20 @@
 				: 'N/A'}
 	/>
 
+	<div class="mb-2 flex items-center gap-2">
+		<span class="text-foreground text-sm font-medium">MAAD address family:</span>
+		<SegmentedControl
+			options={MAAD_IP_VERSION_OPTIONS.map((option) => ({
+				value: String(option.value),
+				label: option.label
+			}))}
+			value={String(data.ipVersion)}
+			onValueChange={(value) => handleMaadIpVersionChange(Number(value) as MaadIpVersion)}
+			ariaLabel="Select MAAD IP address family"
+			buttonClass="sm:min-w-32"
+		/>
+	</div>
+
 	{#if loader?.error && !loader.hasRows}
 		<NetflowFileMessageCard
 			tone="danger"
```

## apps/web/src/lib/components/netflow/NetflowFileHeader.svelte

Status: modified; +10; −4

```diff
@@ -2,12 +2,13 @@
 	import { buildNetflowFileHref } from '$lib/utils/netflow-file-navigation';
 	import { Button } from '$lib/components/ui/button';
 	import * as Card from '$lib/components/ui/card';
-	import type { FlowDirection } from '$lib/types/types';
+	import type { FlowDirection, MaadIpVersion } from '$lib/types/types';
 
 	let {
 		dataset,
 		nextSlug,
 		direction,
+		ipVersion,
 		filename,
 		year,
 		month,
@@ -19,6 +20,7 @@
 		dataset: string;
 		nextSlug: string;
 		direction: FlowDirection;
+		ipVersion: MaadIpVersion;
 		filename: string;
 		year: string;
 		month: string;
@@ -31,9 +33,13 @@
 
 <div class="text-foreground mb-2 flex items-center justify-between text-2xl">
 	<h1>NetFlow File: {filename}</h1>
-	<form method="GET" action={buildNetflowFileHref(nextSlug, dataset, direction)}>
-		<Button type="submit" size="sm" class="w-24">Next File</Button>
-	</form>
+	<Button
+		href={buildNetflowFileHref(nextSlug, dataset, direction, ipVersion)}
+		size="sm"
+		class="w-24"
+	>
+		Next File
+	</Button>
 </div>
 
 <Card.Root size="sm" class="mb-2">
```

## apps/web/src/lib/components/netflow/file-detail-loader.svelte.ts

Status: modified; +39; −20

```diff
@@ -1,10 +1,12 @@
+import {
+	DEFAULT_MAAD_IP_VERSION,
+	type FileIpCounts,
+	type FlowDirection,
+	type MaadIpVersion,
+	type NetflowFileDetailsResponse,
+	type NetflowFileSummaryRecord,
+	type SpectrumData,
-import type {
-	FileIpCounts,
-	FlowDirection,
-	NetflowFileDetailsResponse,
-	NetflowFileSummaryRecord,
-	SpectrumData,
-	StructureFunctionData
+	type StructureFunctionData
 } from '$lib/types/types';
 import { SvelteMap } from 'svelte/reactivity';
 
@@ -66,16 +68,30 @@
 	};
 };
 
+function buildLoaderKey(
+	dataset: string,
+	slug: string,
+	direction: FlowDirection,
+	ipVersion: MaadIpVersion
+): string {
+	return `${dataset}:${slug}:${direction}:${ipVersion}`;
-function buildLoaderKey(dataset: string, slug: string, direction: FlowDirection): string {
-	return `${dataset}:${slug}:${direction}`;
 }
 
+function buildScopeSearchParams(
+	direction: FlowDirection,
+	ipVersion: MaadIpVersion
+): Record<string, string> {
+	const params: Record<string, string> = {};
-function buildDirectionSearchParams(direction: FlowDirection): Record<string, string> {
+
+	if (direction !== 'all') {
+		params.direction = direction;
+	}
+
+	if (ipVersion !== DEFAULT_MAAD_IP_VERSION) {
+		params.ipVersion = String(ipVersion);
-	if (direction === 'all') {
-		return {};
 	}
 
-	return { direction };
+	return params;
 }
 
 async function readErrorMessage(response: Response, fallback: string): Promise<string> {
@@ -95,6 +111,7 @@
 	readonly dataset: string;
 	readonly slug: string;
 	readonly direction: FlowDirection;
+	readonly ipVersion: MaadIpVersion;
 	readonly key: string;
 
 	private _loading = $state(false);
@@ -107,11 +124,12 @@
 	private _lastAccessedAt = Date.now();
 	private summarySkeletonDelayId: ReturnType<typeof setTimeout> | null = null;
 
+	constructor(dataset: string, slug: string, direction: FlowDirection, ipVersion: MaadIpVersion) {
-	constructor(dataset: string, slug: string, direction: FlowDirection) {
 		this.dataset = dataset;
 		this.slug = slug;
 		this.direction = direction;
-		this.key = buildLoaderKey(dataset, slug, direction);
+		this.ipVersion = ipVersion;
+		this.key = buildLoaderKey(dataset, slug, direction, ipVersion);
 	}
 
 	get loading() {
@@ -281,7 +299,7 @@
 			const response = await fetch(
 				`/api/netflow/files/${this.slug}/details?${new URLSearchParams({
 					dataset: this.dataset,
+					...buildScopeSearchParams(this.direction, this.ipVersion)
-					...buildDirectionSearchParams(this.direction)
 				}).toString()}`
 			);
 
@@ -361,7 +379,7 @@
 		return `/api/netflow/files/${this.slug}/${kind === 'ipCounts' ? 'ip-counts' : kind}?${new URLSearchParams(
 			{
 				dataset: this.dataset,
+				...buildScopeSearchParams(this.direction, this.ipVersion),
-				...buildDirectionSearchParams(this.direction),
 				router,
 				source: String(side === 'source')
 			}
@@ -393,13 +411,14 @@
 export function getNetflowFileDetailLoader(
 	dataset: string,
 	slug: string,
-	direction: FlowDirection
+	direction: FlowDirection,
+	ipVersion: MaadIpVersion = DEFAULT_MAAD_IP_VERSION
 ) {
+	const key = buildLoaderKey(dataset, slug, direction, ipVersion);
-	const key = buildLoaderKey(dataset, slug, direction);
 	let loader = fileDetailLoaders.get(key);
 
 	if (!loader) {
+		loader = new NetflowFileDetailLoader(dataset, slug, direction, ipVersion);
-		loader = new NetflowFileDetailLoader(dataset, slug, direction);
 		fileDetailLoaders.set(key, loader);
 	}
 
```

## apps/web/src/lib/schemas.ts

Status: modified; +3; −2

```diff
@@ -1,5 +1,5 @@
 // schemas.ts
-import { FLOW_DIRECTIONS } from '$lib/types/types';
+import { DEFAULT_MAAD_IP_VERSION, FLOW_DIRECTIONS, MAAD_IP_VERSIONS } from '$lib/types/types';
 import { z } from 'zod';
 // Requires Zod 4+
 
@@ -8,6 +8,7 @@
 		startDate: z.iso.date().default(defaultStartDate),
 		endDate: z.iso.date().default(new Date().toJSON().slice(0, 10)),
 		groupBy: z.enum(['date', 'hour', '30min', '10min', '5min']).default('date'),
+		direction: z.enum(FLOW_DIRECTIONS).default('all'),
-		direction: z.enum(FLOW_DIRECTIONS).default('all')
+		ipVersion: z.literal(MAAD_IP_VERSIONS).default(DEFAULT_MAAD_IP_VERSION)
 	});
 }
```

## apps/web/src/lib/server/netflow-v3.ts

Status: modified; +21; −1

```diff
@@ -1,11 +1,14 @@
 import {
+	DEFAULT_MAAD_IP_VERSION,
 	FLOW_DIRECTIONS,
 	flowDirectionLocalities,
 	IP_GRANULARITIES,
+	MAAD_IP_VERSIONS,
 	type FlowDirection,
 	type FlowLocality,
 	type FlowLocalityPair,
-	type IpGranularity
+	type IpGranularity,
+	type MaadIpVersion
 } from '$lib/types/types';
 import type { SourceDefinition } from '$lib/server/datasets';
 import type { StructureFunctionPoint } from '$lib/types/types';
@@ -96,7 +99,24 @@
 		status: 400
 	};
 }
+
+export function parseMaadIpVersion(url: URL): MaadIpVersion | RequestValidationError {
+	const param = url.searchParams.get('ipVersion');
+	if (param === null) {
+		return DEFAULT_MAAD_IP_VERSION;
+	}
 
+	const version = MAAD_IP_VERSIONS.find((candidate) => String(candidate) === param);
+	if (version === undefined) {
+		return {
+			error: `Invalid ipVersion. Expected one of: ${MAAD_IP_VERSIONS.join(', ')}`,
+			status: 400
+		};
+	}
+
+	return version;
+}
+
 export function parseFlowDirectionParams(
 	url: URL
 ): ({ direction: FlowDirection } & FlowLocalityPair) | RequestValidationError {
```

## apps/web/src/lib/types/types.ts

Status: modified; +16; −0

```diff
@@ -362,7 +362,23 @@
 	selectedRouters: string[];
 	activeMetrics: IpMetricKey[];
 }
+
+export const MAAD_IP_VERSIONS = [4, 6] as const;
+
+export type MaadIpVersion = (typeof MAAD_IP_VERSIONS)[number];
 
+export const DEFAULT_MAAD_IP_VERSION: MaadIpVersion = 4;
+
+export interface MaadIpVersionOption {
+	value: MaadIpVersion;
+	label: string;
+}
+
+export const MAAD_IP_VERSION_OPTIONS: MaadIpVersionOption[] = [
+	{ value: 4, label: 'IPv4 (/8–/24)' },
+	{ value: 6, label: 'IPv6 (/23–/64)' }
+];
+
 export interface SpectrumPoint {
 	alpha: number;
 	f: number;
```

## apps/web/src/lib/utils/netflow-file-navigation.ts

Status: modified; +17; −6

```diff
@@ -1,8 +1,12 @@
 import type { goto } from '$app/navigation';
 import { resolve } from '$app/paths';
-import type { FlowDirection } from '$lib/types/types';
+import { DEFAULT_MAAD_IP_VERSION, type FlowDirection, type MaadIpVersion } from '$lib/types/types';
 
-export function buildNetflowFileSearch(dataset?: string, direction?: FlowDirection): string {
+export function buildNetflowFileSearch(
+	dataset?: string,
+	direction?: FlowDirection,
+	ipVersion?: MaadIpVersion
+): string {
 	const normalizedDataset = dataset?.trim();
 	const searchParams: string[] = [];
 
@@ -14,6 +18,10 @@
 		searchParams.push(`direction=${encodeURIComponent(direction)}`);
 	}
 
+	if (ipVersion && ipVersion !== DEFAULT_MAAD_IP_VERSION) {
+		searchParams.push(`ipVersion=${ipVersion}`);
+	}
+
 	const search = searchParams.join('&');
 	if (!search) {
 		return '';
@@ -25,17 +33,20 @@
 export function buildNetflowFileHref(
 	slug: string,
 	dataset?: string,
+	direction?: FlowDirection,
+	ipVersion?: MaadIpVersion
-	direction?: FlowDirection
 ): string {
 	const pathname = resolve('/netflow/files/[slug]', { slug });
-	return `${pathname}${buildNetflowFileSearch(dataset, direction)}`;
+	return `${pathname}${buildNetflowFileSearch(dataset, direction, ipVersion)}`;
 }
 
 export function navigateToNetflowFile(
 	navigate: typeof goto,
 	slug: string,
 	dataset?: string,
+	direction?: FlowDirection,
+	ipVersion?: MaadIpVersion,
+	options?: Parameters<typeof goto>[1]
-	direction?: FlowDirection
 ): Promise<void> {
-	return navigate(buildNetflowFileHref(slug, dataset, direction));
+	return navigate(buildNetflowFileHref(slug, dataset, direction, ipVersion), options);
 }
```

## apps/web/src/routes/api/netflow/files/\[slug\]/details/\+server.ts

Status: modified; +14; −3

```diff
@@ -11,7 +11,11 @@
 	StructureFunctionPoint
 } from '$lib/types/types';
 import { getDatasetFromRequest, slugToBucketStart, withDb } from '../utils';
+import {
+	normalizeStructurePoints,
+	parseFlowDirectionParams,
+	parseMaadIpVersion
+} from '$lib/server/netflow-v3';
-import { normalizeStructurePoints, parseFlowDirectionParams } from '$lib/server/netflow-v3';
 
 const FIVE_MINUTES = '5m';
 
@@ -129,6 +133,11 @@
 		return json({ error: flowDirection.error }, { status: flowDirection.status });
 	}
 
+	const ipVersion = parseMaadIpVersion(url);
+	if (typeof ipVersion !== 'number') {
+		return json({ error: ipVersion.error }, { status: ipVersion.status });
+	}
+
 	if (!slug || slug.length !== 12 || !/^\d{12}$/.test(slug)) {
 		return json({ error: 'Invalid slug format' }, { status: 400 });
 	}
@@ -198,7 +207,7 @@
 				FROM address_structure_stats
 				WHERE granularity = ?
 					AND bucket_start = ?
-					AND ip_version = 4
+					AND ip_version = ?
 					AND src_locality = ?
 					AND dst_locality = ?
 					AND structure_kind = 'structure'
@@ -213,7 +222,7 @@
 				FROM address_structure_stats
 				WHERE granularity = ?
 					AND bucket_start = ?
-					AND ip_version = 4
+					AND ip_version = ?
 					AND src_locality = ?
 					AND dst_locality = ?
 					AND structure_kind = 'spectrum'
@@ -272,10 +281,12 @@
 					flowDirection.dstLocality,
 					FIVE_MINUTES,
 					bucketStart,
+					ipVersion,
 					flowDirection.srcLocality,
 					flowDirection.dstLocality,
 					FIVE_MINUTES,
 					bucketStart,
+					ipVersion,
 					flowDirection.srcLocality,
 					flowDirection.dstLocality,
 					bucketStart
```

## apps/web/src/routes/api/netflow/files/\[slug\]/spectrum/\+server.ts

Status: modified; +8; −2

```diff
@@ -1,7 +1,7 @@
 import { json } from '@sveltejs/kit';
 import type { RequestHandler } from './$types';
 import type { SpectrumData, SpectrumPoint } from '$lib/types/types';
-import { parseFlowDirectionParams } from '$lib/server/netflow-v3';
+import { parseFlowDirectionParams, parseMaadIpVersion } from '$lib/server/netflow-v3';
 import { getDatasetFromRequest, slugToBucketStart, withDb } from '../utils';
 
 const FIVE_MINUTES = '5m';
@@ -21,6 +21,11 @@
 		return json({ error: flowDirection.error }, { status: flowDirection.status });
 	}
 
+	const ipVersion = parseMaadIpVersion(url);
+	if (typeof ipVersion !== 'number') {
+		return json({ error: ipVersion.error }, { status: ipVersion.status });
+	}
+
 	if (!slug || slug.length !== 12 || !/^\d{12}$/.test(slug)) {
 		return json({ error: 'Invalid slug format' }, { status: 400 });
 	}
@@ -52,7 +57,7 @@
 			WHERE source_id = ?
 				AND granularity = ?
 				AND bucket_start = ?
+				AND ip_version = ?
-				AND ip_version = 4
 				AND src_locality = ?
 				AND dst_locality = ?
 				AND address_side = ?
@@ -62,6 +67,7 @@
 					router,
 					FIVE_MINUTES,
 					bucketStart,
+					ipVersion,
 					flowDirection.srcLocality,
 					flowDirection.dstLocality,
 					isSource ? 'source' : 'destination'
```

## apps/web/src/routes/api/netflow/files/\[slug\]/structure/\+server.ts

Status: modified; +12; −2

```diff
@@ -2,7 +2,11 @@
 import type { RequestHandler } from './$types';
 import type { StructureFunctionData, StructureFunctionPoint } from '$lib/types/types';
 import { getDatasetFromRequest, slugToBucketStart, withDb } from '../utils';
+import {
+	normalizeStructurePoints,
+	parseFlowDirectionParams,
+	parseMaadIpVersion
-import { normalizeStructurePoints, parseFlowDirectionParams } from '$lib/server/netflow-v3';
+} from '$lib/server/netflow-v3';
 
 const FIVE_MINUTES = '5m';
 
@@ -21,6 +25,11 @@
 		return json({ error: flowDirection.error }, { status: flowDirection.status });
 	}
 
+	const ipVersion = parseMaadIpVersion(url);
+	if (typeof ipVersion !== 'number') {
+		return json({ error: ipVersion.error }, { status: ipVersion.status });
+	}
+
 	if (!slug || slug.length !== 12 || !/^\d{12}$/.test(slug)) {
 		return json({ error: 'Invalid slug format' }, { status: 400 });
 	}
@@ -52,7 +61,7 @@
 			WHERE source_id = ?
 				AND granularity = ?
 				AND bucket_start = ?
-				AND ip_version = 4
+				AND ip_version = ?
 				AND src_locality = ?
 				AND dst_locality = ?
 				AND address_side = ?
@@ -62,6 +71,7 @@
 					router,
 					FIVE_MINUTES,
 					bucketStart,
+					ipVersion,
 					flowDirection.srcLocality,
 					flowDirection.dstLocality,
 					isSource ? 'source' : 'destination'
```

## apps/web/src/routes/api/netflow/spectrum-stats/\+server.ts

Status: modified; +19; −3

```diff
@@ -4,7 +4,11 @@
 import type { SpectrumStatsPayload, SpectrumStatsResponse } from '$lib/types/spectrum-stats';
 import { buildCoverageTimelines } from '$lib/server/db/coverage';
 import { getRequestedDataset, withDatasetDb } from '$lib/server/datasets';
+import {
+	parseAggregateStatsParams,
-import { parseAggregateStatsParams, placeholders } from '$lib/server/netflow-v3';
+	parseMaadIpVersion,
+	placeholders
+} from '$lib/server/netflow-v3';
 
 type SpectrumStatsRow = SpectrumStatsPayload & {
 	router: string;
@@ -53,6 +57,10 @@
 	if ('error' in params) {
 		return json({ error: params.error }, { status: params.status });
 	}
+	const ipVersion = parseMaadIpVersion(url);
+	if (typeof ipVersion !== 'number') {
+		return json({ error: ipVersion.error }, { status: ipVersion.status });
+	}
 	const { routers, granularity, start, end, srcLocality, dstLocality } = params;
 
 	try {
@@ -60,7 +68,15 @@
 		return await withDatasetDb(dataset, platform, async ({ db }) => {
 			const tableName = 'address_structure_stats';
 			const sourceColumn = 'source_id';
-			const queryParams = [granularity, ...routers, srcLocality, dstLocality, start, end];
+			const queryParams = [
+				granularity,
+				...routers,
+				srcLocality,
+				dstLocality,
+				start,
+				end,
+				ipVersion
+			];
 
 			const query = `
 			SELECT
@@ -76,7 +92,7 @@
 				AND dst_locality = ?
 				AND bucket_start >= ?
 				AND bucket_start < ?
-				AND ip_version = 4
+				AND ip_version = ?
 				AND structure_kind = 'spectrum'
 			GROUP BY ${sourceColumn}, bucket_start
 		`;
```

## apps/web/src/routes/api/netflow/structure-stats/\+server.ts

Status: modified; +15; −2

```diff
@@ -7,6 +7,7 @@
 import {
 	normalizeStructurePoints,
 	parseAggregateStatsParams,
+	parseMaadIpVersion,
 	placeholders
 } from '$lib/server/netflow-v3';
 
@@ -67,6 +68,10 @@
 	if ('error' in params) {
 		return json({ error: params.error }, { status: params.status });
 	}
+	const ipVersion = parseMaadIpVersion(url);
+	if (typeof ipVersion !== 'number') {
+		return json({ error: ipVersion.error }, { status: ipVersion.status });
+	}
 	const { routers, granularity, start, end, srcLocality, dstLocality } = params;
 
 	try {
@@ -74,7 +79,15 @@
 		return await withDatasetDb(dataset, platform, async ({ db }) => {
 			const tableName = 'address_structure_stats';
 			const sourceColumn = 'source_id';
+			const queryParams = [
+				granularity,
+				...routers,
+				srcLocality,
-			const queryParams = [granularity, ...routers, srcLocality, dstLocality, start, end];
+				dstLocality,
+				start,
+				end,
+				ipVersion
+			];
 
 			const query = `
 			SELECT
@@ -90,7 +103,7 @@
 				AND dst_locality = ?
 				AND bucket_start >= ?
 				AND bucket_start < ?
-				AND ip_version = 4
+				AND ip_version = ?
 				AND structure_kind = 'structure'
 			GROUP BY ${sourceColumn}, bucket_start
 		`;
```

## apps/web/src/routes/netflow/files/\[slug\]/\+page.server.ts

Status: modified; +7; −1

```diff
@@ -1,7 +1,7 @@
 import { error } from '@sveltejs/kit';
 import type { PageServerLoad } from './$types';
 import { loadDatasetSummariesFromFetch, resolveDefaultDatasetId } from '$lib/datasets';
-import { parseFlowDirectionParams } from '$lib/server/netflow-v3';
+import { parseFlowDirectionParams, parseMaadIpVersion } from '$lib/server/netflow-v3';
 
 export const load: PageServerLoad = async ({ params, url, fetch }) => {
 	const { slug } = params;
@@ -12,6 +12,11 @@
 		throw error(flowDirection.status, flowDirection.error);
 	}
 
+	const ipVersion = parseMaadIpVersion(url);
+	if (typeof ipVersion !== 'number') {
+		throw error(ipVersion.status, ipVersion.error);
+	}
+
 	if (!dataset) {
 		const datasets = await loadDatasetSummariesFromFetch(fetch);
 		dataset = resolveDefaultDatasetId(datasets);
@@ -34,6 +39,7 @@
 		dataset,
 		slug,
 		direction: flowDirection.direction,
+		ipVersion,
 		fileInfo: {
 			year,
 			month,
```

## apps/web/tests/e2e/maad-ip-version.spec.ts

Status: added; +65; −0

```diff
@@ -1,0 +1,65 @@
+import { expect, test } from '@playwright/test';
+
+const IPV6_LABEL = 'IPv6 (/23–/64)';
+
+test('keeps the IPv6 MAAD selection across drilldown, reload, and next file', async ({ page }) => {
+	await page.goto('/datasets/playwright?startDate=2025-03-01&endDate=2025-03-01&groupBy=5min');
+
+	const spectrum = page.locator('[data-chart-id="spectrum"]');
+	await page.locator('[data-chart-sentinel="spectrum"]').scrollIntoViewIfNeeded();
+	await expect(spectrum).toHaveAttribute('data-chart-activated', 'true');
+
+	const spectrumRequest = page.waitForRequest(
+		(request) =>
+			new URL(request.url()).pathname === '/api/netflow/spectrum-stats' &&
+			new URL(request.url()).searchParams.get('ipVersion') === '6'
+	);
+	await spectrum.getByRole('button', { name: IPV6_LABEL }).click();
+	await expect.poll(() => new URL(page.url()).searchParams.get('ipVersion')).toBe('6');
+	await spectrumRequest;
+
+	await page.reload();
+	await page.locator('[data-chart-sentinel="spectrum"]').scrollIntoViewIfNeeded();
+	await expect(spectrum.getByRole('button', { name: IPV6_LABEL })).toHaveAttribute(
+		'aria-pressed',
+		'true'
+	);
+
+	const dashboard = page.locator('[data-chart-id="dashboard"]');
+	await dashboard.scrollIntoViewIfNeeded();
+	await dashboard.locator('canvas').first().click();
+	await expect(page).toHaveURL(/\/netflow\/files\/\d{12}\?/);
+	const fileUrl = new URL(page.url());
+	expect(fileUrl.searchParams.get('dataset')).toBe('playwright');
+	expect(fileUrl.searchParams.get('ipVersion')).toBe('6');
+
+	const fileIpv6 = page
+		.getByRole('group', { name: 'Select MAAD IP address family' })
+		.getByRole('button', { name: IPV6_LABEL });
+	await expect(fileIpv6).toHaveAttribute('aria-pressed', 'true');
+
+	await page.reload();
+	await expect(fileIpv6).toHaveAttribute('aria-pressed', 'true');
+
+	await page.getByRole('link', { name: 'Next File' }).click();
+	await expect(page).not.toHaveURL(fileUrl.toString());
+	await expect.poll(() => new URL(page.url()).searchParams.get('ipVersion')).toBe('6');
+	expect(new URL(page.url()).searchParams.get('dataset')).toBe('playwright');
+	await expect(fileIpv6).toHaveAttribute('aria-pressed', 'true');
+
+	await page.getByRole('button', { name: 'IPv4 (/8–/24)' }).click();
+	await expect.poll(() => new URL(page.url()).searchParams.get('ipVersion')).toBeNull();
+});
+
+test('falls back to IPv4 MAAD for an unknown ipVersion on the dashboard', async ({ page }) => {
+	await page.goto(
+		'/datasets/playwright?startDate=2025-03-01&endDate=2025-03-01&groupBy=5min&ipVersion=5'
+	);
+
+	const spectrum = page.locator('[data-chart-id="spectrum"]');
+	await page.locator('[data-chart-sentinel="spectrum"]').scrollIntoViewIfNeeded();
+	await expect(spectrum.getByRole('button', { name: 'IPv4 (/8–/24)' })).toHaveAttribute(
+		'aria-pressed',
+		'true'
+	);
+});
```

## apps/web/tests/lib/netflow-file-navigation.test.ts

Status: modified; +25; −2

```diff
@@ -52,18 +52,41 @@
 		expect(buildNetflowFileSearch('   ')).toBe('');
 	});
 
+	it('adds a non-default MAAD IP family to file links', async () => {
+		const { buildNetflowFileHref, buildNetflowFileSearch } =
+			await import('$lib/utils/netflow-file-navigation');
+
+		expect(buildNetflowFileSearch('uoregon', 'all', 4)).toBe('?dataset=uoregon');
+		expect(buildNetflowFileSearch('uoregon', 'ingress', 6)).toBe(
+			'?dataset=uoregon&direction=ingress&ipVersion=6'
+		);
+		expect(buildNetflowFileHref('202506192010', 'uoregon', 'all', 6)).toBe(
+			'/netflow/files/202506192010?dataset=uoregon&ipVersion=6'
+		);
+	});
+
 	it('delegates navigation to the built href', async () => {
 		const { navigateToNetflowFile } = await import('$lib/utils/netflow-file-navigation');
 		const navigate = vi.fn().mockResolvedValue(undefined);
 
 		await navigateToNetflowFile(navigate, '202506192010', 'uoregon');
 
+		expect(navigate).toHaveBeenCalledWith('/netflow/files/202506192010?dataset=uoregon', undefined);
-		expect(navigate).toHaveBeenCalledWith('/netflow/files/202506192010?dataset=uoregon');
 
 		await navigateToNetflowFile(navigate, '202506192010', 'my dataset', 'egress');
 
 		expect(navigate).toHaveBeenLastCalledWith(
+			'/netflow/files/202506192010?dataset=my%20dataset&direction=egress',
+			undefined
+		);
+
+		await navigateToNetflowFile(navigate, '202506192010', 'uoregon', 'all', 6, {
+			replaceState: true
+		});
+
-			'/netflow/files/202506192010?dataset=my%20dataset&direction=egress'
+		expect(navigate).toHaveBeenLastCalledWith(
+			'/netflow/files/202506192010?dataset=uoregon&ipVersion=6',
+			{ replaceState: true }
 		);
 	});
 });
```

## apps/web/tests/lib/schemas.test.ts

Status: modified; +10; −0

```diff
@@ -8,8 +8,18 @@
 		for (const groupBy of ['date', 'hour', '30min', '10min', '5min']) {
 			expect(schema.parse({ groupBy }).groupBy).toBe(groupBy);
 		}
+	});
+
+	it('defaults the MAAD IP family to IPv4 and accepts IPv6', () => {
+		expect(schema.parse({}).ipVersion).toBe(4);
+		expect(schema.parse({ ipVersion: 6 }).ipVersion).toBe(6);
 	});
 
+	it('rejects unknown MAAD IP families', () => {
+		expect(schema.safeParse({ ipVersion: 5 }).success).toBe(false);
+		expect(schema.safeParse({ ipVersion: '6' }).success).toBe(false);
+	});
+
 	it('rejects stored granularity spellings as groupings', () => {
 		expect(schema.safeParse({ groupBy: '10m' }).success).toBe(false);
 	});
```

## apps/web/tests/lib/server/netflow-v3.test.ts

Status: modified; +22; −0

```diff
@@ -8,6 +8,7 @@
 	parseIpGranularityOrDefault,
 	parseFlowDirection,
 	parseFlowDirectionParams,
+	parseMaadIpVersion,
 	parseSourceIds,
 	parseTimestamp,
 	resolveSourceIds
@@ -147,6 +148,27 @@
 		});
 	});
 
+	it('parses the MAAD ip version request param, defaulting to 4', () => {
+		expect(parseMaadIpVersion(new URL('http://localhost/api/test'))).toBe(4);
+		expect(parseMaadIpVersion(new URL('http://localhost/api/test?ipVersion=4'))).toBe(4);
+		expect(parseMaadIpVersion(new URL('http://localhost/api/test?ipVersion=6'))).toBe(6);
+		expect(parseMaadIpVersion(new URL('http://localhost/api/test?ipVersion=5'))).toEqual({
+			error: 'Invalid ipVersion. Expected one of: 4, 6',
+			status: 400
+		});
+		for (const param of ['abc', '0x6', '6.0', '6e0', ' 6', '']) {
+			expect(
+				parseMaadIpVersion(
+					new URL(`http://localhost/api/test?ipVersion=${encodeURIComponent(param)}`)
+				),
+				param
+			).toEqual({
+				error: 'Invalid ipVersion. Expected one of: 4, 6',
+				status: 400
+			});
+		}
+	});
+
 	it('normalizes structure points from MAAD variants', () => {
 		expect(normalizeStructurePoints([{ q: 1, tauTilde: 2, s: 3 }])).toEqual([
 			{ q: 1, tau: 2, sd: 3 }
```

[Next page](https://git.cafe/flambo/atlantis/pull/110/diffs?after=ImFwcHMvd2ViL3Rlc3RzL2xpYi9zZXJ2ZXIvbmV0Zmxvdy12My50ZXN0LnRzIg&expectedVersion=0&format=markdown)
