Make the network device table sortable by column
CI / web (push) Successful in 17s
CI / api (push) Successful in 24s

Click a header to sort by it (ascending), click again to reverse.
Status defaults to known-first (its display string sorts that way
naturally, no special-casing needed). IP sorts numerically by octet,
not lexically. Ties fall back to IP order so the table doesn't
reshuffle mid-poll for devices sharing a sort value (e.g. many
unnamed unknowns).

Added a Playwright test verifying IP asc/desc and Name asc against
real rendered data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 22:48:51 -06:00
parent 669c51d348
commit cdbdd01541
3 changed files with 117 additions and 10 deletions
+51 -8
View File
@@ -1,26 +1,69 @@
import { useState } from "react";
import type { Device } from "../api";
function ipSortKey(ip: string): number[] {
return ip.split(".").map(Number);
}
export function DeviceTable({ devices }: { devices: Device[] }) {
const sorted = [...devices].sort((a, b) => {
if (a.known !== b.known) return a.known ? -1 : 1;
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<SortKey, (a: Device, b: Device) => 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<SortKey>("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) => {
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 (
<table className="device-table">
<thead>
<tr>
<th>Status</th>
<th>IP</th>
<th>MAC</th>
<th>Name</th>
<th>Last seen</th>
{COLUMNS.map(({ key, label }) => (
<th key={key}>
<button className="sort-header" onClick={() => handleSort(key)}>
{label}
{sortKey === key && <span className="sort-arrow">{sortDir === "asc" ? " ▲" : " ▼"}</span>}
</button>
</th>
))}
</tr>
</thead>
<tbody>
+20 -1
View File
@@ -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;
+45
View File
@@ -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);
});