diff --git a/CHANGELOG.md b/CHANGELOG.md index 8288be8..a28e00d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to nxdns are recorded here. The format follows [Keep a Chang Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does. +## [0.0.16] - 2026-09-07 + +The Overview page is redesigned around the two dashboards people already know — Pi-hole's layout, NextDNS's charts — and every number in the admin is spelled one way. + +### Changed + +- **Overview reads like an analytics dashboard.** Four centred totals (queries, blocked, active clients, and the blocked share), a smooth area chart of total and blocked queries where hovering marks the point and shows its figures beside it, a stacked client-activity chart in eight distinct hues with everything else as one gray band, and a row of cards under them: the cache hit rate as a progress bar, the query types as a single-hue ring, and the upstream breakdown. +- **Overview can be scoped to one device.** A device selector sits beside the period selector; both are URL state, so `/overview?period=7d&client=192.0.2.30` is a link to exactly that view. The API takes the same `client` parameter on `GET /api/overview`, and the tile links carry the scope into Activity. +- **One formatting contract.** Counts are thousands-grouped, percentages always show two decimals (15.44%, never 15.4%), rates one decimal, and durations their two largest nonzero units ("6d 4h", "12m 5s", "6d 5s"). A test sweeps the sources for any number formatted outside the one module. +- **A quieter, more consistent chrome.** One 4px corner radius everywhere, cards with a prominent title and a one-line description, the sidebar grouped into Monitoring and System with a status block at its foot (protection, queries per minute on Overview, uptime), and a top bar in place of the sidebar on narrow screens. The active navigation item and the accent blue are darkened to clear WCAG AA on their washes. + ## [0.0.15] - 2026-08-31 The Activity page's history filters become a toolbar you can actually use. Filters apply as you set them, the client field stops asking you to remember IP addresses, and the whole admin picks up one coherent icon set. diff --git a/admin/src/auth/LoginPage.tsx b/admin/src/auth/LoginPage.tsx index 5f32232..8101fd9 100644 --- a/admin/src/auth/LoginPage.tsx +++ b/admin/src/auth/LoginPage.tsx @@ -5,6 +5,7 @@ import { ApiError } from "@/lib/api"; import { useAuth } from "@/auth/store"; import { styles as shared } from "@/ui/styles"; import { colors } from "@/ui/tokens.stylex"; +import { formatDuration } from "@/lib/format"; const styles = stylex.create({ /** Login renders outside AppShell, so it paints the page ground itself. */ @@ -65,7 +66,7 @@ function errorMessage(error: unknown, remaining: number | null): string { if (error.status === 401) return "Incorrect password."; if (error.status === 429) { return remaining !== null && remaining > 0 - ? `Too many attempts. Try again in ${remaining}s.` + ? `Too many attempts. Try again in ${formatDuration(remaining)}.` : "Too many attempts. Try again shortly."; } if (error.status === 503) return "The server is starting or degraded. Try again shortly."; diff --git a/admin/src/features/activity/ActivityFilters.tsx b/admin/src/features/activity/ActivityFilters.tsx index e90759f..7ba7423 100644 --- a/admin/src/features/activity/ActivityFilters.tsx +++ b/admin/src/features/activity/ActivityFilters.tsx @@ -106,7 +106,7 @@ const styles = stylex.create({ segment: { cursor: "pointer", borderStyle: "none", - borderRadius: "0.25rem", + borderRadius: metrics.radius, paddingInline: "0.625rem", fontSize: "0.875rem", lineHeight: "1.25rem", @@ -139,7 +139,7 @@ const styles = stylex.create({ color: colors.textSecondary, }, popover: { - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, diff --git a/admin/src/features/activity/ActivityPage.tsx b/admin/src/features/activity/ActivityPage.tsx index ac6e6c8..1a5fdf7 100644 --- a/admin/src/features/activity/ActivityPage.tsx +++ b/admin/src/features/activity/ActivityPage.tsx @@ -13,7 +13,7 @@ import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import * as stylex from "@stylexjs/stylex"; import { Tab, TabList, TabPanel, Tabs } from "react-aria-components"; import { styles as shared } from "@/ui/styles"; -import { colors } from "@/ui/tokens.stylex"; +import { colors, metrics } from "@/ui/tokens.stylex"; import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters"; import HistoryActivity from "./HistoryActivity"; import LiveActivity from "./LiveActivity"; @@ -40,7 +40,7 @@ const styles = stylex.create({ switch: { display: "flex", gap: "0.25rem", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, diff --git a/admin/src/features/activity/ClientFilter.tsx b/admin/src/features/activity/ClientFilter.tsx index dc9a7cb..15a1865 100644 --- a/admin/src/features/activity/ClientFilter.tsx +++ b/admin/src/features/activity/ClientFilter.tsx @@ -29,6 +29,7 @@ import { useEffect, useMemo, useRef } from "react"; import * as stylex from "@stylexjs/stylex"; import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components"; import { clientLabel, useClientNames } from "@/features/clients/clientNames"; +import { formatCount } from "@/lib/format"; import { styles as shared } from "@/ui/styles"; import { colors, metrics } from "@/ui/tokens.stylex"; import { MAX_CLIENTS } from "./search"; @@ -79,7 +80,7 @@ const styles = stylex.create({ popover: { maxHeight: "16rem", overflowY: "auto", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, @@ -220,7 +221,7 @@ export function joinClients(ips: readonly string[]): string | undefined { function triggerLabel(selected: readonly string[], options: readonly ClientOption[]): string { if (selected.length === 0) return "Clients"; if (selected.length === 1) return displayFor(selected[0] as string, options); - return `${selected.length} clients`; + return `${formatCount(selected.length)} clients`; } interface Props { @@ -373,7 +374,7 @@ export default function ClientFilter({ options, selected, onChange }: Props) { ))} {selected.length > MAX_CHIPS && ( - +{selected.length - MAX_CHIPS} more + +{formatCount(selected.length - MAX_CHIPS)} more )} ); diff --git a/admin/src/features/activity/HistoryActivity.tsx b/admin/src/features/activity/HistoryActivity.tsx index 272d7f9..4218ea6 100644 --- a/admin/src/features/activity/HistoryActivity.tsx +++ b/admin/src/features/activity/HistoryActivity.tsx @@ -12,12 +12,13 @@ import * as stylex from "@stylexjs/stylex"; import * as api from "@/lib/api"; import CoverageNotice from "@/lib/CoverageNotice"; import InlineError from "@/lib/InlineError"; +import { formatCount } from "@/lib/format"; import { queriesInfiniteQuery } from "@/lib/queries"; import type { QueryRow } from "@/lib/types"; import { useClientNames } from "@/features/clients/clientNames"; import { summarizeRow } from "@/features/provenance/querySummary"; import { styles as shared } from "@/ui/styles"; -import { colors } from "@/ui/tokens.stylex"; +import { colors, metrics } from "@/ui/tokens.stylex"; import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells"; import { queriesFilterOf, type ActivitySearch } from "./search"; @@ -38,7 +39,7 @@ const styles = stylex.create({ tableWrap: { marginTop: "1rem", overflowX: "auto", - borderRadius: "0.25rem", + borderRadius: metrics.radius, borderWidth: 1, borderStyle: "solid", borderColor: colors.border, @@ -152,7 +153,7 @@ export default function HistoryActivity({ search, onClear }: Props) { ) : ( <> -