From e6e04698a27c690d55cd5dd5d84ae56ec4848de0 Mon Sep 17 00:00:00 2001 From: majakomel Date: Thu, 20 Nov 2025 16:21:17 +0100 Subject: [PATCH 01/12] Initial chart implementation --- components/alerts/list.tsx | 154 +++++++++++++++ components/chart/AlertChart.tsx | 327 ++++++++++++++++++++++++++++++++ pages/alerts.js | 41 ++++ pages/chart/alert.js | 263 +++++++++++++++++++++++++ 4 files changed, 785 insertions(+) create mode 100644 components/alerts/list.tsx create mode 100644 components/chart/AlertChart.tsx create mode 100644 pages/alerts.js create mode 100644 pages/chart/alert.js diff --git a/components/alerts/list.tsx b/components/alerts/list.tsx new file mode 100644 index 000000000..0cea4cfc6 --- /dev/null +++ b/components/alerts/list.tsx @@ -0,0 +1,154 @@ +import { + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table' +import { addWeeks, format, parseISO, subWeeks } from 'date-fns' +import Link from 'next/link' +import { useMemo } from 'react' +import { FaArrowAltCircleDown, FaArrowAltCircleUp } from 'react-icons/fa' + +export interface Changepoint { + id: string + name: string + description: string + probe_cc: string + probe_asn: string + start_time: string + end_time: string + domain: string + change_dir: 'up' | 'down' +} + +const AlertList: React.FC<{ changepoints: Changepoint[] }> = ({ + changepoints, +}) => { + const columns = useMemo( + () => [ + { + header: 'Direction', + accessorKey: 'change_dir', + cell: ({ getValue }: { getValue: () => 'up' | 'down' }) => { + const direction = getValue() + return ( + + {direction === 'up' ? ( + + ) : ( + + )} + + ) + }, + }, + { + header: 'Time', + accessorKey: 'start_time', + cell: ({ getValue }: { getValue: () => string }) => { + const time = getValue() + return time + }, + }, + { + header: 'Country', + accessorKey: 'probe_cc', + }, + { + header: 'Network', + accessorKey: 'probe_asn', + cell: ({ getValue }: { getValue: () => string }) => { + const asn = getValue() + return `AS${asn}` + }, + }, + { + header: 'Domain', + accessorKey: 'domain', + }, + { + header: 'See more', + id: 'see_more', + cell: ({ row }: { row: { original: Changepoint } }) => { + const { probe_asn, probe_cc, domain, start_time } = row.original + const startDate = parseISO(start_time) + const since = format(subWeeks(startDate, 2), 'yyyy-MM-dd') + const until = format(addWeeks(startDate, 2), 'yyyy-MM-dd') + + const params = new URLSearchParams({ + probe_asn, + probe_cc, + domain, + since, + until, + }) + + return ( + + See more + + ) + }, + }, + ], + [], + ) + + const { getHeaderGroups, getRowModel } = useReactTable({ + columns, + data: changepoints, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + + return ( +
+ + + {getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {getRowModel().rows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + ) + })} + + ) + })} + +
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+
+ ) +} + +export default AlertList diff --git a/components/chart/AlertChart.tsx b/components/chart/AlertChart.tsx new file mode 100644 index 000000000..8fd2d337f --- /dev/null +++ b/components/chart/AlertChart.tsx @@ -0,0 +1,327 @@ +import { ResponsiveLine } from '@nivo/line' +import { colors } from 'ooni-components' +import type { Changepoint } from 'components/alerts/list' +import { scaleUtc } from 'd3-scale' + +type AnalysisResult = { + measurement_start_day: string + loni: { + dns_blocked?: number + dns_down?: number + dns_ok?: number + tcp_blocked?: number + tcp_down?: number + tcp_ok?: number + tls_blocked?: number + tls_down?: number + tls_ok?: number + [key: string]: number | string | string[] | undefined + } + [key: string]: unknown +} + +type ChartDataPoint = { + x: string + y: number +} + +type ChartSeries = { + id: string + data: ChartDataPoint[] +} + +/** + * Transforms an array of analysis results into chart data format for nivo line charts + * @param results Array of analysis result objects + * @param metrics Optional array of metric keys to extract from loni object. If not provided, extracts all numeric metrics. + * @returns Array of chart series objects with id and data arrays + */ +export const transformAnalysisToChartData = ( + results: AnalysisResult[], + metrics?: string[], +): ChartSeries[] => { + if (!results || results.length === 0) { + return [] + } + + // If metrics not specified, extract all numeric metrics from loni object + const metricsToExtract = + metrics || + Object.keys(results[0]?.loni || {}).filter( + (key) => + typeof results[0]?.loni?.[key] === 'number' && + !key.includes('outcome') && + key !== 'blocked_max', + ) + console.log(metricsToExtract) + // Group data by metric + const seriesMap = new Map() + + for (const result of results) { + const { measurement_start_day, loni } = result + + if (!loni || !measurement_start_day) { + continue + } + + for (const metric of metricsToExtract) { + const value = loni[metric] + + if (typeof value === 'number') { + if (!seriesMap.has(metric)) { + seriesMap.set(metric, []) + } + + const dataPoints = seriesMap.get(metric) + if (dataPoints) { + dataPoints.push({ + x: measurement_start_day, + y: value, + }) + } + } + } + } + + // Convert map to array format + return Array.from(seriesMap.entries()).map(([id, data]) => ({ + id, + data: data.sort((a, b) => a.x.localeCompare(b.x)), // Sort by date + })) +} + +const ChangepointLines = ({ + changepoints, + innerHeight, + marginTop, + innerWidth, + series, +}: { + changepoints: Changepoint[] | undefined + innerHeight: number + marginTop: number + innerWidth: number + series: ReadonlyArray<{ data: ReadonlyArray<{ data: { x: string | Date } }> }> +}) => { + if ( + !changepoints || + changepoints.length === 0 || + !series || + series.length === 0 + ) { + return null + } + + // Get all x values from the series to determine the domain + const allXValues = series.flatMap((s) => + s.data.map((point: { data: { x: string | Date } }) => point.data.x), + ) + if (allXValues.length === 0) { + return null + } + + // Parse dates and find min/max for the scale domain + const dates = allXValues.map((x) => { + // Handle both date strings and Date objects + if (typeof x === 'string') { + return new Date(x.endsWith('Z') ? x : `${x}T00:00:00Z`) + } + return x instanceof Date ? x : new Date(x) + }) + + const minDate = new Date(Math.min(...dates.map((d) => d.getTime()))) + const maxDate = new Date(Math.max(...dates.map((d) => d.getTime()))) + + // Create a UTC time scale matching the chart's configuration + const xScale = scaleUtc().domain([minDate, maxDate]).range([0, innerWidth]) + + return ( + + {changepoints.map((changepoint) => { + // Convert start_time to Date object + const startDate = new Date(changepoint.start_time) + // Extract just the date part (set to midnight UTC) to match measurement_start_day format + const dateOnly = new Date( + Date.UTC( + startDate.getUTCFullYear(), + startDate.getUTCMonth(), + startDate.getUTCDate(), + ), + ) + + const x = xScale(dateOnly) + + if (Number.isNaN(x) || x < 0 || x > innerWidth) { + return null + } + + return ( + + ) + })} + + ) +} + +const Chart = ({ + data, + changepoints, +}: { data: ChartSeries[]; changepoints: Changepoint[] | undefined }) => { + const colorMap: Record = { + tls_blocked: colors.fuchsia['600'], + dns_blocked: colors.orange['600'], + tcp_blocked: colors.cyan['500'], + } + + const getColor = (series: ChartSeries) => { + return colorMap[series.id] || '#888' + } + + return ( +
+ ( + + ), + 'slices', + 'legends', + 'crosshair', + ]} + sliceTooltip={(props) => { + if (props?.slice?.points?.length) { + const points = props.slice.points + const date = new Date(points[0].data.x) + return ( +
+
+ {date.toLocaleString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZone: 'UTC', + timeZoneName: 'short', + })} +
+ {points.map((point) => ( +
+
+
+ {point.seriesId} [ + {Number(point.data.yFormatted).toFixed(4)}] +
+
+ ))} +
+ ) + } + return null + }} + legends={[ + { + anchor: 'bottom-right', + direction: 'column', + justify: false, + translateX: 100, + translateY: 0, + itemsSpacing: 0, + itemDirection: 'left-to-right', + itemWidth: 80, + itemHeight: 20, + symbolSize: 12, + symbolShape: 'circle', + itemTextColor: '#777', + }, + ]} + /> +
+ ) +} + +const AlertChart = ({ + analysis, + changepoints, +}: { + analysis: AnalysisResult | AnalysisResult[] + changepoints: [] +}) => { + // console.log(changepoints) + const resultsArray = Array.isArray(analysis) ? analysis : [analysis] + const chartData = transformAnalysisToChartData(resultsArray, [ + 'dns_blocked', + 'tcp_blocked', + 'tls_blocked', + ]) + + return ( +
+ + {/*
+        {JSON.stringify(chartData, null, 2)}
+      
*/} +
+ ) +} + +export default AlertChart diff --git a/pages/alerts.js b/pages/alerts.js new file mode 100644 index 000000000..a1fb16f1a --- /dev/null +++ b/pages/alerts.js @@ -0,0 +1,41 @@ +import useSWR from 'swr' +import AlertList from 'components/alerts/list' + +const ALERTS_ENDPOINT = + 'https://oonimeasurements.dev.ooni.io/api/v1/detector/changepoints' + +const fetcher = async (url) => { + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`) + } + const json = await response.json() + return json?.results +} + +const Alerts = () => { + const SINCE = '2012-01-01' + const until = new Date().toISOString().slice(0, 10) + const params = new URLSearchParams({ since: SINCE, until }).toString() + const requestUrl = `${ALERTS_ENDPOINT}?${params}` + + const { + data: changepoints, + error, + isLoading, + } = useSWR(requestUrl, fetcher, { + revalidateOnFocus: false, + }) + // console.log(changepoints) + return ( +
+

Alerts

+ {isLoading &&

Loading alerts…

} + {error &&

{error.message}

} + {changepoints && } +
+ ) +} + +export default Alerts diff --git a/pages/chart/alert.js b/pages/chart/alert.js new file mode 100644 index 000000000..7f45bbcb7 --- /dev/null +++ b/pages/chart/alert.js @@ -0,0 +1,263 @@ +import { useEffect, useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' +import useSWR from 'swr' +import { useRouter } from 'next/router' +import AlertList from 'components/alerts/list' +import AlertCharts from 'components/chart/AlertChart' + +const ALERTS_ENDPOINT = + 'https://oonimeasurements.dev.ooni.io/api/v1/detector/changepoints' +const ANALYSIS_ENDPOINT = 'https://api.ooni.org/api/v1/aggregation/analysis' + +const fetcher = async (url) => { + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`) + } + + return response.json() +} + +const Alert = () => { + const router = useRouter() + const defaultUntil = new Date().toISOString().slice(0, 10) + + const baseDefaults = useMemo( + () => ({ + probe_asn: '', + probe_cc: '', + domain: '', + since: '2012-01-01', + until: defaultUntil, + }), + [defaultUntil], + ) + + const [query, setQuery] = useState(null) + + const { + register, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + defaultValues: baseDefaults, + }) + + useEffect(() => { + if (!router.isReady) { + return + } + + const queryFields = ['probe_asn', 'probe_cc', 'domain', 'since', 'until'] + + const queryFromRouter = queryFields.reduce((acc, field) => { + const value = router.query[field] + if (Array.isArray(value) && value.length > 0) { + acc[field] = value[0] + } else if (typeof value === 'string' && value.trim().length > 0) { + acc[field] = value + } + return acc + }, {}) + + const hasQueryParams = Object.keys(queryFromRouter).length > 0 + + if (hasQueryParams) { + const nextValues = { + ...baseDefaults, + ...queryFromRouter, + } + reset(nextValues) + const nextValuesString = JSON.stringify(nextValues) + setQuery((prev) => { + const prevString = prev ? JSON.stringify(prev) : null + if (prevString === nextValuesString) { + return prev + } + return nextValues + }) + } + }, [baseDefaults, reset, router.isReady, router.query]) + + const searchParams = useMemo(() => { + if (!query) { + return null + } + + const params = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + params.append(key, value) + } + + return params.toString() + }, [query]) + + const changepointsUrl = useMemo(() => { + if (!searchParams) { + return null + } + + return `${ALERTS_ENDPOINT}?${searchParams}` + }, [searchParams]) + + const analysisUrl = useMemo(() => { + if (!query) { + return null + } + + const params = new URLSearchParams({ + ...query, + axis_x: 'measurement_start_day', + // test_name: 'web_connectivity', + time_grain: 'hour', + }) + + return `${ANALYSIS_ENDPOINT}?${params.toString()}` + }, [query]) + + const { + data: changepoints, + error, + isLoading, + } = useSWR(changepointsUrl, fetcher, { + revalidateOnFocus: false, + }) + + const { + data: analysis, + error: analysisError, + isLoading: isAnalysisLoading, + } = useSWR(analysisUrl, fetcher, { + revalidateOnFocus: false, + }) + + const onSubmit = (values) => { + setQuery(values) + router.replace( + { + pathname: router.pathname, + query: values, + }, + undefined, + { shallow: true }, + ) + } + + const changepointResults = changepoints?.results ?? [] + + return ( +
+

Alerts

+
+ + + + + + + + + + +
+ +
+
+ + {query && ( +
+ {(isLoading || isAnalysisLoading) &&

Loading data…

} + {error &&

{error.message}

} + {analysisError && ( +

{analysisError.message}

+ )} + {changepointResults.length > 0 && ( + + )} + {analysis && ( + <> + {/* {analysis.results.map((analysisResult) => ( */} + + {/* ))} */} + {/*
+                {JSON.stringify(analysis, null, 2)}
+              
*/} + + )} +
+ )} +
+ ) +} + +export default Alert From 733836f9b863bbec9f3528615035561b85259570 Mon Sep 17 00:00:00 2001 From: majakomel Date: Wed, 3 Dec 2025 21:28:33 +0100 Subject: [PATCH 02/12] Fix changepoint positioning --- components/chart/AlertChart.tsx | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/components/chart/AlertChart.tsx b/components/chart/AlertChart.tsx index 8fd2d337f..f20837cbb 100644 --- a/components/chart/AlertChart.tsx +++ b/components/chart/AlertChart.tsx @@ -93,13 +93,13 @@ export const transformAnalysisToChartData = ( const ChangepointLines = ({ changepoints, innerHeight, - marginTop, + // marginTop, innerWidth, series, }: { changepoints: Changepoint[] | undefined innerHeight: number - marginTop: number + // marginTop: number innerWidth: number series: ReadonlyArray<{ data: ReadonlyArray<{ data: { x: string | Date } }> }> }) => { @@ -138,18 +138,9 @@ const ChangepointLines = ({ return ( {changepoints.map((changepoint) => { - // Convert start_time to Date object const startDate = new Date(changepoint.start_time) - // Extract just the date part (set to midnight UTC) to match measurement_start_day format - const dateOnly = new Date( - Date.UTC( - startDate.getUTCFullYear(), - startDate.getUTCMonth(), - startDate.getUTCDate(), - ), - ) - const x = xScale(dateOnly) + const x = xScale(startDate) if (Number.isNaN(x) || x < 0 || x > innerWidth) { return null @@ -202,7 +193,7 @@ const Chart = ({ xScale={{ type: 'time', format: '%Y-%m-%dT%H:%M:%SZ', - precision: 'minute', + precision: 'hour', useUTC: true, }} yScale={{ @@ -219,11 +210,11 @@ const Chart = ({ 'grid', 'axes', 'lines', - ({ innerHeight, margin, innerWidth, series }) => ( + ({ innerHeight, innerWidth, series }) => ( From 680f137b1ef5db8f2c562b088b5b32676d4b1a43 Mon Sep 17 00:00:00 2001 From: majakomel Date: Wed, 3 Dec 2025 21:37:33 +0100 Subject: [PATCH 03/12] Enable alert table sorting --- components/alerts/list.tsx | 57 ++++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/components/alerts/list.tsx b/components/alerts/list.tsx index 0cea4cfc6..7ab23aee9 100644 --- a/components/alerts/list.tsx +++ b/components/alerts/list.tsx @@ -3,11 +3,18 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, + type SortingState, } from '@tanstack/react-table' import { addWeeks, format, parseISO, subWeeks } from 'date-fns' import Link from 'next/link' -import { useMemo } from 'react' -import { FaArrowAltCircleDown, FaArrowAltCircleUp } from 'react-icons/fa' +import { useMemo, useState } from 'react' +import { + FaArrowAltCircleDown, + FaArrowAltCircleUp, + FaSort, + FaSortDown, + FaSortUp, +} from 'react-icons/fa' export interface Changepoint { id: string @@ -24,11 +31,14 @@ export interface Changepoint { const AlertList: React.FC<{ changepoints: Changepoint[] }> = ({ changepoints, }) => { + const [sorting, setSorting] = useState([]) + const columns = useMemo( () => [ { header: 'Direction', accessorKey: 'change_dir', + enableSorting: true, cell: ({ getValue }: { getValue: () => 'up' | 'down' }) => { const direction = getValue() return ( @@ -45,6 +55,7 @@ const AlertList: React.FC<{ changepoints: Changepoint[] }> = ({ { header: 'Time', accessorKey: 'start_time', + enableSorting: true, cell: ({ getValue }: { getValue: () => string }) => { const time = getValue() return time @@ -53,10 +64,12 @@ const AlertList: React.FC<{ changepoints: Changepoint[] }> = ({ { header: 'Country', accessorKey: 'probe_cc', + enableSorting: true, }, { header: 'Network', accessorKey: 'probe_asn', + enableSorting: true, cell: ({ getValue }: { getValue: () => string }) => { const asn = getValue() return `AS${asn}` @@ -65,10 +78,12 @@ const AlertList: React.FC<{ changepoints: Changepoint[] }> = ({ { header: 'Domain', accessorKey: 'domain', + enableSorting: true, }, { header: 'See more', id: 'see_more', + enableSorting: false, cell: ({ row }: { row: { original: Changepoint } }) => { const { probe_asn, probe_cc, domain, start_time } = row.original const startDate = parseISO(start_time) @@ -97,9 +112,13 @@ const AlertList: React.FC<{ changepoints: Changepoint[] }> = ({ [], ) - const { getHeaderGroups, getRowModel } = useReactTable({ + const table = useReactTable({ columns, data: changepoints, + state: { + sorting, + }, + onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), }) @@ -108,7 +127,7 @@ const AlertList: React.FC<{ changepoints: Changepoint[] }> = ({
- {getHeaderGroups().map((headerGroup) => ( + {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( ))} @@ -126,7 +167,7 @@ const AlertList: React.FC<{ changepoints: Changepoint[] }> = ({ ))} - {getRowModel().rows.map((row) => { + {table.getRowModel().rows.map((row) => { return ( {row.getVisibleCells().map((cell) => { From 6f4cf9a4114f917853196df66302b5a25b8f89f0 Mon Sep 17 00:00:00 2001 From: majakomel Date: Thu, 18 Dec 2025 14:20:53 +0100 Subject: [PATCH 04/12] Add failure type column to alert list and update links --- components/alerts/list.tsx | 8 +++++++- pages/alerts.js | 15 ++++++++++++--- pages/chart/{alert.js => alerts.js} | 0 3 files changed, 19 insertions(+), 4 deletions(-) rename pages/chart/{alert.js => alerts.js} (100%) diff --git a/components/alerts/list.tsx b/components/alerts/list.tsx index 7ab23aee9..b81e4cfe3 100644 --- a/components/alerts/list.tsx +++ b/components/alerts/list.tsx @@ -80,6 +80,11 @@ const AlertList: React.FC<{ changepoints: Changepoint[] }> = ({ accessorKey: 'domain', enableSorting: true, }, + { + header: 'Failure type', + accessorKey: 'block_type', + enableSorting: true, + }, { header: 'See more', id: 'see_more', @@ -100,8 +105,9 @@ const AlertList: React.FC<{ changepoints: Changepoint[] }> = ({ return ( See more diff --git a/pages/alerts.js b/pages/alerts.js index a1fb16f1a..8201c5294 100644 --- a/pages/alerts.js +++ b/pages/alerts.js @@ -1,5 +1,6 @@ import useSWR from 'swr' import AlertList from 'components/alerts/list' +import SpinLoader from 'components/vendor/SpinLoader' const ALERTS_ENDPOINT = 'https://oonimeasurements.dev.ooni.io/api/v1/detector/changepoints' @@ -11,7 +12,11 @@ const fetcher = async (url) => { throw new Error(`Request failed with status ${response.status}`) } const json = await response.json() - return json?.results + const sortedResults = + json?.results?.sort((a, b) => { + return new Date(b.start_time) - new Date(a.start_time) + }) || [] + return sortedResults } const Alerts = () => { @@ -27,11 +32,15 @@ const Alerts = () => { } = useSWR(requestUrl, fetcher, { revalidateOnFocus: false, }) - // console.log(changepoints) + return (

Alerts

- {isLoading &&

Loading alerts…

} + {isLoading && ( +
+ +
+ )} {error &&

{error.message}

} {changepoints && }
diff --git a/pages/chart/alert.js b/pages/chart/alerts.js similarity index 100% rename from pages/chart/alert.js rename to pages/chart/alerts.js From 29eb0bbd3e07529354754d81874ebb4c10bd0480 Mon Sep 17 00:00:00 2001 From: majakomel Date: Thu, 18 Dec 2025 14:46:10 +0100 Subject: [PATCH 05/12] Add loading states --- pages/chart/alerts.js | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/pages/chart/alerts.js b/pages/chart/alerts.js index 7f45bbcb7..1f5e2ed3d 100644 --- a/pages/chart/alerts.js +++ b/pages/chart/alerts.js @@ -4,6 +4,8 @@ import useSWR from 'swr' import { useRouter } from 'next/router' import AlertList from 'components/alerts/list' import AlertCharts from 'components/chart/AlertChart' +import { ChartSpinLoader } from 'components/Chart' +import SpinLoader from 'components/vendor/SpinLoader' const ALERTS_ENDPOINT = 'https://oonimeasurements.dev.ooni.io/api/v1/detector/changepoints' @@ -232,7 +234,11 @@ const Alert = () => { {query && (
- {(isLoading || isAnalysisLoading) &&

Loading data…

} + {isLoading && ( +
+ +
+ )} {error &&

{error.message}

} {analysisError && (

{analysisError.message}

@@ -240,19 +246,20 @@ const Alert = () => { {changepointResults.length > 0 && ( )} - {analysis && ( - <> - {/* {analysis.results.map((analysisResult) => ( */} - - {/* ))} */} - {/*
-                {JSON.stringify(analysis, null, 2)}
-              
*/} - + {isAnalysisLoading ? ( + + ) : ( + analysis && ( + <> + + {/*
+                  {JSON.stringify(analysis, null, 2)}
+                
*/} + + ) )}
)} From 94cf420dffc4717cf7b7d015afe0bce87a448bef Mon Sep 17 00:00:00 2001 From: majakomel Date: Thu, 18 Dec 2025 15:19:09 +0100 Subject: [PATCH 06/12] Use ooni-components for alerts form --- pages/alerts.js | 2 +- pages/chart/alerts.js | 148 +++++++++++++++++++++++------------------- 2 files changed, 81 insertions(+), 69 deletions(-) diff --git a/pages/alerts.js b/pages/alerts.js index 8201c5294..301e5e741 100644 --- a/pages/alerts.js +++ b/pages/alerts.js @@ -35,7 +35,7 @@ const Alerts = () => { return (
-

Alerts

+

Alerts

{isLoading && (
diff --git a/pages/chart/alerts.js b/pages/chart/alerts.js index 1f5e2ed3d..91bbdcf45 100644 --- a/pages/chart/alerts.js +++ b/pages/chart/alerts.js @@ -1,11 +1,15 @@ import { useEffect, useMemo, useState } from 'react' -import { useForm } from 'react-hook-form' +import { Controller, useForm } from 'react-hook-form' import useSWR from 'swr' import { useRouter } from 'next/router' +import { useIntl } from 'react-intl' +import { Input, Select } from 'ooni-components' import AlertList from 'components/alerts/list' import AlertCharts from 'components/chart/AlertChart' import { ChartSpinLoader } from 'components/Chart' import SpinLoader from 'components/vendor/SpinLoader' +import countries from 'data/countries.json' +import { getLocalisedRegionName } from 'utils/i18nCountries' const ALERTS_ENDPOINT = 'https://oonimeasurements.dev.ooni.io/api/v1/detector/changepoints' @@ -24,7 +28,7 @@ const fetcher = async (url) => { const Alert = () => { const router = useRouter() const defaultUntil = new Date().toISOString().slice(0, 10) - + const intl = useIntl() const baseDefaults = useMemo( () => ({ probe_asn: '', @@ -39,7 +43,7 @@ const Alert = () => { const [query, setQuery] = useState(null) const { - register, + control, handleSubmit, formState: { errors }, reset, @@ -47,6 +51,20 @@ const Alert = () => { defaultValues: baseDefaults, }) + const countryOptions = useMemo(() => { + const locale = intl.locale + const options = [ + ...countries.map((c) => ({ + ...c, + name: getLocalisedRegionName(c.alpha_2, locale), + })), + ] + + options.sort((a, b) => a.name.localeCompare(b.name)) + + return options + }, [intl]) + useEffect(() => { if (!router.isReady) { return @@ -151,82 +169,76 @@ const Alert = () => { return (
-

Alerts

+

Alerts

- - - - - - - - - + />
-
From 170c137d3ab51daad55435ea717f2af9fc167d7e Mon Sep 17 00:00:00 2001 From: majakomel Date: Thu, 18 Dec 2025 20:30:14 +0100 Subject: [PATCH 07/12] Add MAT charts --- components/alerts/list.tsx | 2 +- components/chart/AlertChart.tsx | 6 ++++-- pages/chart/alerts.js | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/components/alerts/list.tsx b/components/alerts/list.tsx index b81e4cfe3..ec302e01c 100644 --- a/components/alerts/list.tsx +++ b/components/alerts/list.tsx @@ -96,7 +96,7 @@ const AlertList: React.FC<{ changepoints: Changepoint[] }> = ({ const until = format(addWeeks(startDate, 2), 'yyyy-MM-dd') const params = new URLSearchParams({ - probe_asn, + probe_asn: `AS${probe_asn}`, probe_cc, domain, since, diff --git a/components/chart/AlertChart.tsx b/components/chart/AlertChart.tsx index f20837cbb..70f37ca0c 100644 --- a/components/chart/AlertChart.tsx +++ b/components/chart/AlertChart.tsx @@ -187,8 +187,9 @@ const Chart = ({ ( { ) )} + +
)}
From 25ad6f1e6ea5073400d8f6e0b7b412a55b8aa631 Mon Sep 17 00:00:00 2001 From: majakomel Date: Thu, 18 Dec 2025 21:08:25 +0100 Subject: [PATCH 08/12] Chart fixes --- components/aggregation/mat/CustomTooltip.js | 2 +- components/chart/AlertChart.tsx | 1 + pages/chart/alerts.js | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/components/aggregation/mat/CustomTooltip.js b/components/aggregation/mat/CustomTooltip.js index e0fefea04..4d0201ffe 100644 --- a/components/aggregation/mat/CustomTooltip.js +++ b/components/aggregation/mat/CustomTooltip.js @@ -148,7 +148,7 @@ const CustomToolTip = memo(({ data, onClose, title, link = true }) => { : data.blocked_max_outcome}
- {!!data?.likely_blocked_protocols.length && ( + {!!data?.likely_blocked_protocols?.length && (
Likely blocked protocols:{' '}
= ({ className="px-3 py-3.5 text-sm text-start font-semibold text-gray-900" scope="col" > - {flexRender( - header.column.columnDef.header, - header.getContext(), + {header.isPlaceholder ? null : ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {header.column.getCanSort() && ( + + {{ + asc: , + desc: , + }[header.column.getIsSorted() as string] ?? ( + + )} + + )} +
)}