{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 (
+
+
+ )}
+ {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 (