From 462aea31cfd162274daa3a1a61daec9e09fa5be7 Mon Sep 17 00:00:00 2001 From: Rinjani Analytics <209478350+rinjanianalytics@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:36:06 +0700 Subject: [PATCH 1/5] console: say what happened on the not found page, and give ways out The page for an unknown address only said "Not found!". It now says the address may be mistyped or the page may have moved, and links to the home page and to the documentation. The page also sets its own title: the tab said "FlowScope" alone. --- docs/99-changelog.md | 5 +++++ web/src/app/not-found.tsx | 28 +++++++++++++++++++++++++++- web/src/components/error-page.tsx | 28 +++++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/99-changelog.md b/docs/99-changelog.md index e5aa9c201..9f3ccf694 100644 --- a/docs/99-changelog.md +++ b/docs/99-changelog.md @@ -14,6 +14,11 @@ identified with a specific icon: - 🩹: bug fix - 🌱: miscellaneous change +## FlowScope (unreleased) + +- 🩹 *console*: the page for an unknown address says what happened and links + to the home page and to the documentation + ## FlowScope 2.0.0-rs.0 - 2026-10-10 The first pre-release of *FlowScope*. It is Akvorado, as of its 2026.10.0 version diff --git a/web/src/app/not-found.tsx b/web/src/app/not-found.tsx index bdcb99c63..09055d573 100644 --- a/web/src/app/not-found.tsx +++ b/web/src/app/not-found.tsx @@ -1,8 +1,34 @@ // SPDX-FileCopyrightText: 2022 Free Mobile // SPDX-License-Identifier: AGPL-3.0-only +import type { Metadata } from "next"; +import Link from "next/link"; import { ErrorPage } from "@/components/error-page"; +import { buttonVariants } from "@/components/ui/button"; + +export const metadata: Metadata = { + title: "FlowScope | Not found", +}; export default function NotFound() { - return ; + return ( + + + Back to home + + + Open the documentation + + + ); } diff --git a/web/src/components/error-page.tsx b/web/src/components/error-page.tsx index 2a50e26d4..3ae71cbb4 100644 --- a/web/src/components/error-page.tsx +++ b/web/src/components/error-page.tsx @@ -1,15 +1,37 @@ // SPDX-FileCopyrightText: 2022 Free Mobile // SPDX-License-Identifier: AGPL-3.0-only +import type { ReactNode } from "react"; import { Logo } from "@/components/logo"; import { SectionLabel } from "@/components/brand"; -export function ErrorPage({ code, error }: { code: string; error: string }) { +/** A page for an error, with what happened and the ways out as children. */ +export function ErrorPage({ + code, + error, + description, + children, +}: { + code: string; + error: string; + description?: string; + children?: ReactNode; +}) { return (
- + {code} -

{error}

+

+ {error} +

+ {description && ( +

{description}

+ )} + {children && ( +
+ {children} +
+ )}
); } From 31226249b893fa8505746b4e905f6680e60d7cc4 Mon Sep 17 00:00:00 2001 From: Rinjani Analytics <209478350+rinjanianalytics@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:36:06 +0700 Subject: [PATCH 2/5] console: move the user and the theme switch to the menu On a large screen, the bar above the pages only held the theme switch and the avatar. Both move to the foot of the menu, with the name of the user, and the bar is gone. On a small screen the menu is closed, so the bar stays: it opens the menu and shows the logo. Also in the menu: - the current page gets the tint of the brand, in place of a solid black or white block, - the button to collapse the menu says "Collapse menu", not "Close", - the links are in a nav landmark. The page had two nested main landmarks, one from the sidebar block and one from the layout. The sidebar block is now a plain block. --- docs/99-changelog.md | 3 + web/src/components/navigation-bar.tsx | 99 +++++++++++++++------------ web/src/components/ui/sidebar.tsx | 5 +- web/src/components/user-menu.tsx | 17 ++++- 4 files changed, 75 insertions(+), 49 deletions(-) diff --git a/docs/99-changelog.md b/docs/99-changelog.md index 9f3ccf694..7f6ffe9fd 100644 --- a/docs/99-changelog.md +++ b/docs/99-changelog.md @@ -16,6 +16,9 @@ identified with a specific icon: ## FlowScope (unreleased) +- 🌱 *console*: the user and the theme switch are at the foot of the menu. + The bar above the pages is only shown on a small screen, where it opens + the menu - 🩹 *console*: the page for an unknown address says what happened and links to the home page and to the documentation diff --git a/web/src/components/navigation-bar.tsx b/web/src/components/navigation-bar.tsx index c9657dbf4..3967919d6 100644 --- a/web/src/components/navigation-bar.tsx +++ b/web/src/components/navigation-bar.tsx @@ -59,8 +59,9 @@ const navigation = [ }, ]; +// The current page gets the tint of the brand, not a solid block. const activeItem = - "relative h-9 rounded-xl font-medium data-active:bg-sidebar-primary data-active:text-sidebar-primary-foreground data-active:hover:bg-sidebar-primary data-active:hover:text-sidebar-primary-foreground"; + "relative h-10 rounded-xl font-medium data-active:bg-accent data-active:font-semibold data-active:text-accent-foreground data-active:hover:bg-accent data-active:hover:text-accent-foreground"; function CollapseButton() { const { open, isMobile, toggleSidebar } = useSidebar(); @@ -71,13 +72,13 @@ function CollapseButton() { {open ? : } - Close + Collapse menu @@ -107,40 +108,47 @@ export function AppSidebar() { ref={navRef} className="no-scrollbar scroll-fade gap-0 pb-6 group-data-[collapsible=icon]:overflow-x-hidden group-data-[collapsible=icon]:overflow-y-auto" > - {navigation.map((group) => ( - - {/* In the icon rail, the hidden heading is pulled over the row - above it: it must not take its clicks. */} - - {group.group} - - - - {group.items.map((item) => ( - - isMobile && setOpenMobile(false)} - render={} - > - - - {item.name} - - - - ))} - - - - ))} + +
+ + +
{config?.version && (

{config.version} @@ -152,25 +160,28 @@ export function AppSidebar() { ); } -/** The bar above the pages. */ +/** + * The bar above the pages on a small screen, where the menu is closed by + * default. On a larger screen, all it would hold is in the menu. + */ export function TopBar() { const { setOpenMobile, openMobile } = useSidebar(); return ( -

-
+
+
-
- - + + +
); diff --git a/web/src/components/ui/sidebar.tsx b/web/src/components/ui/sidebar.tsx index e79e97f6e..75dbe9f5e 100644 --- a/web/src/components/ui/sidebar.tsx +++ b/web/src/components/ui/sidebar.tsx @@ -304,9 +304,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { ) } -function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { +// A plain block: the page provides the main landmark. +function SidebarInset({ className, ...props }: React.ComponentProps<"div">) { return ( -
{user && } {name.slice(0, 1).toUpperCase()} + + + {name} + + {user?.name && user?.email && ( + + {user.email} + + )} + - + From 8838d5b2eaa108d5c90404ecfe2a913ca4ced110 Mon Sep 17 00:00:00 2001 From: Rinjani Analytics <209478350+rinjanianalytics@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:36:21 +0700 Subject: [PATCH 3/5] console: put the numbers first on the home page The home page had two numbers on half of its width, five small pies whose shares were not written, and a list of about 60 fields for the last flow, many of them empty, with a blank area next to it. - Four numbers in one row: the flow rate, the exporters, the traffic now and its peak. The last two come from the data of the graph, with the period computed from it. - The top lists are ranked bars with the share of each entry. The protocols and the IP versions, which have a few classes, get one split bar. - The fields of the last flow are in sections (flow, exporter, source, destination, interfaces). The fields without a value are listed by name behind a button. A zero is a value and stays. - The axis of the graph shows the unit ("150 Gbps", not "150.00G"). - The titles of the panels are headings. The header of a page can also drop its description on a small screen. The visualize page will use it. --- docs/99-changelog.md | 3 + web/src/app/page.tsx | 48 +++-- web/src/components/brand.tsx | 20 +- web/src/components/home/widgets.tsx | 273 ++++++++++++++++++++-------- web/src/lib/format.test.ts | 97 +++++++++- web/src/lib/format.ts | 80 +++++++- 6 files changed, 419 insertions(+), 102 deletions(-) diff --git a/docs/99-changelog.md b/docs/99-changelog.md index 7f6ffe9fd..ce3366d2d 100644 --- a/docs/99-changelog.md +++ b/docs/99-changelog.md @@ -16,6 +16,9 @@ identified with a specific icon: ## FlowScope (unreleased) +- 🌱 *console*: the home page shows the traffic now and its peak, ranks the + top lists with the share of each entry, and groups the fields of the last + flow. The fields without a value are behind a button - 🌱 *console*: the user and the theme switch are at the foot of the menu. The bar above the pages is only shown on a small screen, where it opens the menu diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 5632b934a..85570f940 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -14,11 +14,16 @@ import { WidgetGraph, WidgetLastFlow, WidgetTop, + WidgetTrafficNow, + WidgetTrafficPeak, + isShareWidget, } from "@/components/home/widgets"; export default function HomePage() { const config = useServerConfig(); const topWidgets = config?.homepageTopWidgets ?? []; + const ranked = topWidgets.filter((widget) => !isShareWidget(widget)); + const shares = topWidgets.filter(isShareWidget); return (
+ Explore flows } /> -
-
-
- - -
- {/* The last row grows to fill the width, whatever their number. */} -
- {topWidgets.map((widget) => ( -
- -
- ))} -
- +
+ + + + +
+
+
+ + {/* Each row fills the width, whatever the number of widgets. */} + {[ranked, shares].map( + (widgets, row) => + widgets.length > 0 && ( +
+ {widgets.map((widget) => ( + + ))} +
+ ), + )}
diff --git a/web/src/components/brand.tsx b/web/src/components/brand.tsx index e3fd155db..dc3926e95 100644 --- a/web/src/components/brand.tsx +++ b/web/src/components/brand.tsx @@ -41,11 +41,14 @@ export function PageHeader({ title, description, actions, + dense, }: { eyebrow: ReactNode; title: ReactNode; description?: ReactNode; actions?: ReactNode; + /** Keep the description for a screen with room for it. */ + dense?: boolean; }) { return (
@@ -55,7 +58,12 @@ export function PageHeader({ {title} {description && ( -

+

{description}

)} @@ -74,6 +82,7 @@ export function Panel({ description, action, children, + size, className, contentClassName, }: { @@ -82,15 +91,20 @@ export function Panel({ description?: ReactNode; action?: ReactNode; children: ReactNode; + size?: "default" | "sm"; className?: string; contentClassName?: string; }) { return ( - + {(eyebrow || title || action) && ( {eyebrow && {eyebrow}} - {title && {title}} + {title && ( + +

{title}

+
+ )} {description && {description}} {action && ( diff --git a/web/src/components/home/widgets.tsx b/web/src/components/home/widgets.tsx index 066d12057..637ee8afa 100644 --- a/web/src/components/home/widgets.tsx +++ b/web/src/components/home/widgets.tsx @@ -3,14 +3,21 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { EChart } from "@/components/echart"; import { useIsDark } from "@/components/providers"; import { Panel, StatTile } from "@/components/brand"; +import { Button } from "@/components/ui/button"; import { apiFetch } from "@/lib/api"; import { graphic, type ECOption } from "@/lib/echarts"; -import { compareFields, formatXps } from "@/lib/format"; +import { + formatSpan, + formatXps, + formatXpsLabel, + groupFlow, + scaleXps, +} from "@/lib/format"; import { chartChrome, dataColor, dataColorGrey } from "@/lib/palette"; const refreshOften = 10_000; @@ -66,6 +73,74 @@ const widgetTitles: Record = { "dst-port": "Top destination ports", }; +/** The widgets with a few classes sharing the whole: they get a split bar. */ +export const isShareWidget = (what: string) => + what === "protocol" || what === "etype"; + +type Slice = { name: string; value: number; color: string }; + +/** A ranked list: the share of each entry is written out. */ +function RankedList({ slices }: { slices: Slice[] }) { + return ( +
    + {slices.map(({ name, value, color }) => ( +
  1. +
    + + {name} + + + {value.toFixed(1)}% + +
    +
    +
    +
    +
  2. + ))} +
+ ); +} + +/** One bar split between the entries, with their share below. */ +function SplitBar({ slices }: { slices: Slice[] }) { + return ( + <> +
`${name} ${value.toFixed(1)}%`) + .join(", ")} + className="flex h-3.5 gap-0.5 overflow-hidden rounded-full" + > + {slices.map(({ name, value, color }) => ( +
+ ))} +
+
    + {slices.map(({ name, value, color }) => ( +
  • + + {name} + + {value.toFixed(1)}% + +
  • + ))} +
+ + ); +} + export function WidgetTop({ what }: { what: string }) { const isDark = useIsDark(); const data = useWidget<{ top: { name: string; percent: number }[] }>( @@ -73,7 +148,8 @@ export function WidgetTop({ what }: { what: string }) { refreshOccasionally, ); const theme = isDark ? "dark" : "light"; - const slices = useMemo(() => { + const share = isShareWidget(what); + const slices = useMemo((): Slice[] => { if (!data) return []; const top = data.top ?? []; return [ @@ -88,74 +164,89 @@ export function WidgetTop({ what }: { what: string }) { .filter(({ value }) => value > 0.05) .map((slice, index) => ({ ...slice, + // A ranked list has one color: the length of the bar says it all. color: slice.name === "Others" ? dataColorGrey(0, false, theme) - : dataColor(index, false, theme), + : dataColor(share ? index : 0, false, theme), })); - }, [data, theme]); - const option = useMemo( - (): ECOption => ({ - darkMode: isDark, - backgroundColor: "transparent", - tooltip: { - trigger: "item", - confine: true, - valueFormatter: (value) => - ((value?.valueOf() as number) ?? 0).toFixed(2) + "%", - }, - series: [ - { - type: "pie", - label: { show: false }, - radius: "90%", - data: slices.map(({ name, value, color }) => ({ - name, - value, - itemStyle: { color }, - })), - }, - ], - }), - [slices, isDark], - ); - // The legend is not drawn by the chart: it wraps on narrow tiles without - // covering the pie. + }, [data, share, theme]); return ( - -
- -
-
    - {slices.map(({ name, color }) => ( -
  • - - {name.split(": ")[0]} -
  • - ))} -
+ + {share ? : } ); } -const formatGbps = (value: number) => formatXps(value * 1_000_000_000); - -export function WidgetGraph({ className }: { className?: string }) { - const isDark = useIsDark(); +/** The points of the graph of the home page, without the one being filled. */ +function useTraffic() { const data = useWidget<{ data: { t: string; gbps: number }[] }>( "graph", refreshInfrequently, ); + return useMemo(() => { + if (!data) return null; + const points = data.data ?? []; + const first = points[0]; + const last = points[points.length - 1]; + return { + points: points.slice(0, -1), + span: + first && last + ? new Date(last.t).valueOf() - new Date(first.t).valueOf() + : 0, + }; + }, [data]); +} + +function TrafficTile({ + label, + gbps, +}: { + label: string; + gbps: number | undefined; +}) { + if (gbps === undefined) return ; + const { value, suffix } = scaleXps(gbps * 1_000_000_000); + return ( + + ); +} + +export function WidgetTrafficNow() { + const traffic = useTraffic(); + return ( + + ); +} + +export function WidgetTrafficPeak() { + const traffic = useTraffic(); + return ( + gbps)) + : undefined + } + /> + ); +} + +const formatGbps = (value: number) => + formatXps(value * 1_000_000_000) + "bps"; + +export function WidgetGraph({ className }: { className?: string }) { + const isDark = useIsDark(); + const traffic = useTraffic(); const option = useMemo((): ECOption => { const theme = isDark ? "dark" : "light"; const chrome = chartChrome(theme); return { darkMode: isDark, backgroundColor: "transparent", - grid: { left: 60, top: 20, right: 20, bottom: 30 }, + grid: { left: 76, top: 20, right: 20, bottom: 30 }, textStyle: { fontFamily: "Poppins, sans-serif" }, xAxis: { type: "time", @@ -166,7 +257,11 @@ export function WidgetGraph({ className }: { className?: string }) { yAxis: { type: "value", min: 0, - axisLabel: { formatter: formatGbps, color: chrome.text }, + axisLabel: { + formatter: (value: number) => + formatXpsLabel(value * 1_000_000_000, "bps"), + color: chrome.text, + }, splitLine: { lineStyle: { color: chrome.grid } }, }, tooltip: { @@ -187,12 +282,11 @@ export function WidgetGraph({ className }: { className?: string }) { { offset: 1, color: dataColor(0, false, theme) + "08" }, ]), }, - // The last point is still being filled. - data: (data?.data ?? []).map(({ t, gbps }) => [t, gbps]).slice(0, -1), + data: (traffic?.points ?? []).map(({ t, gbps }) => [t, gbps]), }, ], }; - }, [data, isDark]); + }, [traffic, isDark]); return ( - Array.isArray(value) ? value.join(" ") : String(value); - export function WidgetLastFlow() { const data = useWidget>("flow-last", refreshOften); - const fields = Object.entries(data ?? {}).sort(([f1], [f2]) => - compareFields(f1, f2), - ); + const [showEmpty, setShowEmpty] = useState(false); + const { sections, empty } = useMemo(() => groupFlow(data ?? {}), [data]); return ( - - + {sections.map(({ title, fields }) => ( +
+

+ {title} +

+
{fields.map(([field, value]) => ( -
- - - +
+
{field}
+
+ {value} +
+
+ ))} + + + ))} + {showEmpty && ( +
+

+ Empty in this flow +

+
    + {empty.map((field) => ( +
  • + {field} +
  • ))} -
-
- {field} - - {flowValue(value)} -
+ + + )} + {empty.length > 0 && ( + + )}
); } diff --git a/web/src/lib/format.test.ts b/web/src/lib/format.test.ts index 31bf47ad5..f249a0886 100644 --- a/web/src/lib/format.test.ts +++ b/web/src/lib/format.test.ts @@ -2,7 +2,14 @@ // SPDX-License-Identifier: AGPL-3.0-only import { describe, expect, it } from "vitest"; -import { formatXps, compareFields, reverseDimension } from "./format"; +import { + formatXps, + formatXpsLabel, + formatSpan, + compareFields, + groupFlow, + reverseDimension, +} from "./format"; describe("formatXps", () => { it("formats small values without suffix", () => { @@ -58,6 +65,94 @@ describe("formatXps", () => { }); }); +describe("formatXpsLabel", () => { + it("adds the unit and drops the zeros at the end", () => { + expect(formatXpsLabel(0, "bps")).toBe("0 bps"); + expect(formatXpsLabel(150_000_000_000, "bps")).toBe("150 Gbps"); + expect(formatXpsLabel(1_500_000, "pps")).toBe("1.5 Mpps"); + expect(formatXpsLabel(62_250, "fps")).toBe("62.25 Kfps"); + }); + + it("preserves negative sign", () => { + expect(formatXpsLabel(-2_000_000_000, "bps")).toBe("-2 Gbps"); + }); +}); + +describe("formatSpan", () => { + it("uses the largest unit which reads well", () => { + expect(formatSpan(30 * 60_000)).toBe("30 minutes"); + expect(formatSpan(3_600_000)).toBe("1 hour"); + expect(formatSpan(24 * 3_600_000)).toBe("24 hours"); + expect(formatSpan(7 * 24 * 3_600_000)).toBe("7 days"); + }); + + it("rounds a duration which is a few minutes short", () => { + expect(formatSpan(24 * 3_600_000 - 7 * 60_000)).toBe("24 hours"); + }); +}); + +describe("groupFlow", () => { + const flow = { + SrcAddr: "192.0.2.152", + SrcCountry: "", + DstAS: 20940, + DstASPath: [174, 20940], + DstLargeCommunities: [], + Dst3rdAS: 0, + InIfName: "Gi0/0/0/20", + OutIfProvider: "", + OutIfName: "Gi0/0/0/11", + ExporterName: "dc5-edge2.example.com", + Bytes: 120, + TimeReceived: "2026-10-11T01:23:53Z", + Proto: 6, + }; + + it("puts the fields in sections, in order", () => { + expect(groupFlow(flow).sections).toEqual([ + { + title: "Flow", + fields: [ + ["TimeReceived", "2026-10-11T01:23:53Z"], + ["Bytes", "120"], + ["Proto", "6"], + ], + }, + { title: "Exporter", fields: [["ExporterName", "dc5-edge2.example.com"]] }, + { title: "Source", fields: [["SrcAddr", "192.0.2.152"]] }, + { + title: "Destination", + fields: [ + ["Dst3rdAS", "0"], + ["DstAS", "20940"], + ["DstASPath", "174 20940"], + ], + }, + { + title: "Interfaces", + fields: [ + ["InIfName", "Gi0/0/0/20"], + ["OutIfName", "Gi0/0/0/11"], + ], + }, + ]); + }); + + it("returns the fields without a value apart", () => { + expect(groupFlow(flow).empty).toEqual([ + "SrcCountry", + "DstLargeCommunities", + "OutIfProvider", + ]); + }); + + it("drops a section without a field", () => { + expect(groupFlow({ Bytes: 1 }).sections.map((s) => s.title)).toEqual([ + "Flow", + ]); + }); +}); + describe("compareFields", () => { it("compares fields by prefix priority", () => { expect(compareFields("TimeReceived", "Bytes")).toBeLessThan(0); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 8e92db088..7d6077547 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -1,17 +1,43 @@ // SPDX-FileCopyrightText: 2022 Free Mobile // SPDX-License-Identifier: AGPL-3.0-only -export function formatXps(value: number) { - const isNegative = value < 0; - let absValue = Math.abs(value); - const suffixes = ["", "K", "M", "G", "T", "P"]; +const suffixes = ["", "K", "M", "G", "T", "P"]; + +/** A value as a multiple of a power of 1000, with the suffix of this power. */ +export function scaleXps(value: number) { + let scaled = Math.abs(value); let idx = 0; - while (absValue >= 1000 && idx < suffixes.length - 1) { - absValue /= 1000; + while (scaled >= 1000 && idx < suffixes.length - 1) { + scaled /= 1000; idx++; } - const sign = isNegative ? "-" : ""; - return `${sign}${absValue.toFixed(2)}${suffixes[idx]}`; + return { value: value < 0 ? -scaled : scaled, suffix: suffixes[idx] }; +} + +export function formatXps(value: number) { + const { value: scaled, suffix } = scaleXps(value); + return `${scaled.toFixed(2)}${suffix}`; +} + +/** + * A value with its unit and without the zeros at the end ("150 Gbps"), for + * the labels of an axis. + */ +export function formatXpsLabel(value: number, unit: string) { + const { value: scaled, suffix } = scaleXps(value); + return `${parseFloat(scaled.toFixed(2))} ${suffix}${unit}`; +} + +/** A duration in words ("24 hours", "7 days"), from milliseconds. */ +export function formatSpan(duration: number) { + const plural = (count: number, unit: string) => + `${count} ${unit}${count === 1 ? "" : "s"}`; + const minutes = Math.round(duration / 60_000); + if (minutes < 60) return plural(Math.max(1, minutes), "minute"); + const hours = Math.round(minutes / 60); + return hours < 48 + ? plural(hours, "hour") + : plural(Math.round(hours / 24), "day"); } // Order function for field names @@ -36,6 +62,44 @@ export function compareFields(f1: string, f2: string) { return f1.localeCompare(f2); } +/** The value of a field of a flow, as text. */ +export const flowValue = (value: unknown) => + Array.isArray(value) ? value.join(" ") : String(value ?? ""); + +const flowGroups: { title: string; prefixes: string[] }[] = [ + { title: "Exporter", prefixes: ["Exporter"] }, + { title: "Source", prefixes: ["Src"] }, + { title: "Destination", prefixes: ["Dst"] }, + { title: "Interfaces", prefixes: ["InIf", "OutIf"] }, +]; + +/** + * The fields of a flow in sections, in order. The fields without a value are + * returned apart, by name only: there are many and they say little. A zero + * is a value. + */ +export function groupFlow(flow: Record) { + const sections = ["Flow", ...flowGroups.map(({ title }) => title)].map( + (title) => ({ title, fields: [] as [string, string][] }), + ); + const empty: string[] = []; + for (const field of Object.keys(flow).sort(compareFields)) { + const value = flowValue(flow[field]); + if (value === "") { + empty.push(field); + continue; + } + const group = flowGroups.findIndex(({ prefixes }) => + prefixes.some((prefix) => field.startsWith(prefix)), + ); + sections[group + 1].fields.push([field, value]); + } + return { + sections: sections.filter(({ fields }) => fields.length > 0), + empty, + }; +} + // Reverse the direction of a dimension name, the same way the backend does // for the reverse axis of a bidirectional query. The candidate name is only // used when it is part of the known dimensions, otherwise the name is kept From e5c8eee3b7d4a6debdd07580d1fb0edf75b2f2f2 Mon Sep 17 00:00:00 2001 From: Rinjani Analytics <209478350+rinjanianalytics@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:36:21 +0700 Subject: [PATCH 4/5] console: show the graph first on a small screen, and use plain words On a phone, the options filled the first screen and the graph was below it. The graph now comes first. A bar at the bottom opens the options in a sheet and runs the query. On a large screen, the options stay next to the graph. In the options: - the button to apply is out of the scrolling part, so it stays in view, - the unit is a list with full names, in place of six buttons like "L3b/s" and "->%" written with small letters, - the buttons with only an icon are 32 pixels wide, up from 16, - the fields which are not inputs are named for screen readers, and the section titles are headings. In the graphs: - ten series get ten colors which differ in hue and in lightness. The four hues of the brand were repeated in shades, and the fifth series was hard to tell from the first, - the axis shows the unit, - the summary and the title of the tab use a 24-hour clock, like the axis. The smallest text size goes from 11 to 12 pixels. --- docs/99-changelog.md | 5 + web/src/app/globals.css | 2 +- web/src/app/visualize/page.tsx | 15 +- .../components/visualize/dimensions-input.tsx | 2 +- web/src/components/visualize/field.tsx | 42 +- web/src/components/visualize/filter-input.tsx | 3 +- web/src/components/visualize/graphs.tsx | 24 +- .../components/visualize/options-panel.tsx | 629 ++++++++++-------- .../components/visualize/request-summary.tsx | 4 +- web/src/hooks/use-media-query.ts | 23 + web/src/lib/dates.test.ts | 27 + web/src/lib/dates.ts | 9 +- web/src/lib/graph-types.ts | 23 +- web/src/lib/palette.test.ts | 26 + web/src/lib/palette.ts | 42 +- 15 files changed, 544 insertions(+), 332 deletions(-) create mode 100644 web/src/hooks/use-media-query.ts create mode 100644 web/src/lib/dates.test.ts create mode 100644 web/src/lib/palette.test.ts diff --git a/docs/99-changelog.md b/docs/99-changelog.md index ce3366d2d..fdafd5e3a 100644 --- a/docs/99-changelog.md +++ b/docs/99-changelog.md @@ -16,6 +16,11 @@ identified with a specific icon: ## FlowScope (unreleased) +- 🌱 *console*: on a small screen, the graph of the visualize page comes + first and the options open in a sheet +- 🌱 *console*: units are written in words, the axis of a graph shows the + unit, times use a 24-hour clock and the first ten series each get a color + of their own - 🌱 *console*: the home page shows the traffic now and its peak, ranks the top lists with the share of each entry, and groups the fields of the last flow. The fields without a value are behind a button diff --git a/web/src/app/globals.css b/web/src/app/globals.css index c3e279814..2fe9c2b1d 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -47,7 +47,7 @@ --color-info: var(--info); --color-info-bg: var(--info-bg); --shadow-card: var(--shadow-card); - --text-2xs: 0.6875rem; + --text-2xs: 0.75rem; --text-2xs--line-height: 1rem; --text-ui: 0.8125rem; --text-ui--line-height: 1.25rem; diff --git a/web/src/app/visualize/page.tsx b/web/src/app/visualize/page.tsx index b0aacaf8a..067940b43 100644 --- a/web/src/app/visualize/page.tsx +++ b/web/src/app/visualize/page.tsx @@ -76,7 +76,9 @@ export default function VisualizePage() { const [error, setError] = useState(""); const [fetching, setFetching] = useState(false); const [highlight, setHighlight] = useState(null); - const [graphHeight, setGraphHeight] = useState(500); + // Unset until the handle is dragged: the height then follows the screen. + const [graphHeight, setGraphHeight] = useState(null); + const graph = useRef(null); // The last change comes from the auto refresh. const auto = useRef(false); const [quiet, setQuiet] = useState(false); @@ -199,7 +201,7 @@ export default function VisualizePage() { // Resize the graph by dragging the handle below it. const startResize = (event: React.PointerEvent) => { const startY = event.clientY; - const startHeight = graphHeight; + const startHeight = graph.current?.offsetHeight ?? 0; const move = (e: PointerEvent) => setGraphHeight(Math.max(200, startHeight + e.clientY - startY)); const stop = () => { @@ -221,9 +223,10 @@ export default function VisualizePage() { onCancel={cancel} /> )} -
+
)} -
+
event.stopPropagation()} onClick={(event) => { event.stopPropagation(); diff --git a/web/src/components/visualize/field.tsx b/web/src/components/visualize/field.tsx index d653a84a0..7c8463e76 100644 --- a/web/src/components/visualize/field.tsx +++ b/web/src/components/visualize/field.tsx @@ -26,18 +26,25 @@ export function Field({ label, error, htmlFor, + labelId, className, children, }: { label: string; error?: string; htmlFor?: string; + /** For a field which is not an input: it names itself with this label. */ + labelId?: string; className?: string; children: ReactNode; }) { return (
-