diff --git a/docs/99-changelog.md b/docs/99-changelog.md index e5aa9c201..fdafd5e3a 100644 --- a/docs/99-changelog.md +++ b/docs/99-changelog.md @@ -14,6 +14,22 @@ identified with a specific icon: - 🩹: bug fix - 🌱: miscellaneous change +## 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 +- 🌱 *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 + ## 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/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/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/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/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} /> )} -
+
)} -
+
@@ -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/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} +
+ )}
); } 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/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} + + )} + - + diff --git a/web/src/components/visualize/dimensions-input.tsx b/web/src/components/visualize/dimensions-input.tsx index cccf10c31..7388391e7 100644 --- a/web/src/components/visualize/dimensions-input.tsx +++ b/web/src/components/visualize/dimensions-input.tsx @@ -60,7 +60,7 @@ function Chip({ 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 (
-