diff --git a/apps/web/src/components/DeviceTable.tsx b/apps/web/src/components/DeviceTable.tsx index 9408b17..894efb4 100644 --- a/apps/web/src/components/DeviceTable.tsx +++ b/apps/web/src/components/DeviceTable.tsx @@ -1,26 +1,69 @@ +import { useState } from "react"; import type { Device } from "../api"; function ipSortKey(ip: string): number[] { return ip.split(".").map(Number); } +function compareIp(a: Device, b: Device): number { + const [ak, bk] = [ipSortKey(a.ip), ipSortKey(b.ip)]; + for (let i = 0; i < 4; i++) if (ak[i] !== bk[i]) return ak[i] - bk[i]; + return 0; +} + +type SortKey = "status" | "ip" | "mac" | "name" | "lastSeen"; + +// "known" sorts before "unknown" lexically already, so treating status as its +// display string keeps the original known-first default with no special-casing. +const COMPARATORS: Record number> = { + status: (a, b) => (a.known === b.known ? 0 : a.known ? -1 : 1), + ip: compareIp, + mac: (a, b) => a.mac.localeCompare(b.mac), + name: (a, b) => (a.name ?? "").localeCompare(b.name ?? ""), + lastSeen: (a, b) => a.lastSeen.localeCompare(b.lastSeen), +}; + +const COLUMNS: { key: SortKey; label: string }[] = [ + { key: "status", label: "Status" }, + { key: "ip", label: "IP" }, + { key: "mac", label: "MAC" }, + { key: "name", label: "Name" }, + { key: "lastSeen", label: "Last seen" }, +]; + export function DeviceTable({ devices }: { devices: Device[] }) { + const [sortKey, setSortKey] = useState("status"); + const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + + function handleSort(key: SortKey) { + if (key === sortKey) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDir("asc"); + } + } + const sorted = [...devices].sort((a, b) => { - if (a.known !== b.known) return a.known ? -1 : 1; - const [ak, bk] = [ipSortKey(a.ip), ipSortKey(b.ip)]; - for (let i = 0; i < 4; i++) if (ak[i] !== bk[i]) return ak[i] - bk[i]; - return 0; + const primary = COMPARATORS[sortKey](a, b); + const signed = sortDir === "asc" ? primary : -primary; + // Stable secondary key so ties (e.g. many devices with no name) don't + // reshuffle on every poll instead of just sitting still. + return signed !== 0 ? signed : compareIp(a, b); }); return ( - - - - - + {COLUMNS.map(({ key, label }) => ( + + ))} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 8462748..5340176 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -197,10 +197,29 @@ body { text-align: left; color: #8b949e; font-weight: 500; - padding: 0.4rem 0.6rem; + padding: 0; border-bottom: 1px solid #30363d; } +.sort-header { + width: 100%; + text-align: left; + padding: 0.4rem 0.6rem; + background: none; + border: none; + color: inherit; + font: inherit; + cursor: pointer; +} + +.sort-header:hover { + color: #e6edf3; +} + +.sort-arrow { + color: #58a6ff; +} + .device-table td { padding: 0.4rem 0.6rem; border-bottom: 1px solid #21262d; diff --git a/e2e/tests/device-table-sort.spec.ts b/e2e/tests/device-table-sort.spec.ts new file mode 100644 index 0000000..587fa5b --- /dev/null +++ b/e2e/tests/device-table-sort.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from "@playwright/test"; + +const USERNAME = process.env.LOCAL_USERNAME ?? "admin"; +const PASSWORD = process.env.LOCAL_PASSWORD ?? ""; + +async function login(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByPlaceholder("Username").fill(USERNAME); + await page.getByPlaceholder("Password").fill(PASSWORD); + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page.locator(".device-table tbody tr").first()).toBeVisible({ timeout: 15_000 }); +} + +test("network device table sorts by each column", async ({ page }) => { + test.skip(!PASSWORD, "LOCAL_PASSWORD not set"); + await login(page); + + const ipCells = () => page.locator(".device-table tbody tr td:nth-child(2)").allTextContents(); + + // Default: known-first (status ascending). + const firstBadge = page.locator(".device-table tbody tr td:first-child .device-badge").first(); + await expect(firstBadge).toHaveText("known"); + + // Click IP header -> ascending numeric IP order. + await page.getByRole("button", { name: "IP" }).click(); + const ascIps = await ipCells(); + const numeric = (ip: string) => ip.split(".").map(Number); + const sortedAsc = [...ascIps].sort((a, b) => { + const [an, bn] = [numeric(a), numeric(b)]; + for (let i = 0; i < 4; i++) if (an[i] !== bn[i]) return an[i] - bn[i]; + return 0; + }); + expect(ascIps).toEqual(sortedAsc); + + // Click IP header again -> descending. + await page.getByRole("button", { name: "IP" }).click(); + const descIps = await ipCells(); + expect(descIps).toEqual([...sortedAsc].reverse()); + + // Click a different column (Name) resets to ascending on that column. + await page.getByRole("button", { name: "Name" }).click(); + const names = await page.locator(".device-table tbody tr td:nth-child(4)").allTextContents(); + const sortedNames = [...names].sort((a, b) => a.localeCompare(b)); + expect(names).toEqual(sortedNames); +});
StatusIPMACNameLast seen + +