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
+52 -9
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);
}
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) => {
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 (
<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;