Make the network device table sortable by column
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:
@@ -1,26 +1,69 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import type { Device } from "../api";
|
import type { Device } from "../api";
|
||||||
|
|
||||||
function ipSortKey(ip: string): number[] {
|
function ipSortKey(ip: string): number[] {
|
||||||
return ip.split(".").map(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[] }) {
|
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 sorted = [...devices].sort((a, b) => {
|
||||||
if (a.known !== b.known) return a.known ? -1 : 1;
|
const primary = COMPARATORS[sortKey](a, b);
|
||||||
const [ak, bk] = [ipSortKey(a.ip), ipSortKey(b.ip)];
|
const signed = sortDir === "asc" ? primary : -primary;
|
||||||
for (let i = 0; i < 4; i++) if (ak[i] !== bk[i]) return ak[i] - bk[i];
|
// Stable secondary key so ties (e.g. many devices with no name) don't
|
||||||
return 0;
|
// reshuffle on every poll instead of just sitting still.
|
||||||
|
return signed !== 0 ? signed : compareIp(a, b);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className="device-table">
|
<table className="device-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Status</th>
|
{COLUMNS.map(({ key, label }) => (
|
||||||
<th>IP</th>
|
<th key={key}>
|
||||||
<th>MAC</th>
|
<button className="sort-header" onClick={() => handleSort(key)}>
|
||||||
<th>Name</th>
|
{label}
|
||||||
<th>Last seen</th>
|
{sortKey === key && <span className="sort-arrow">{sortDir === "asc" ? " ▲" : " ▼"}</span>}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
+20
-1
@@ -197,10 +197,29 @@ body {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
color: #8b949e;
|
color: #8b949e;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0;
|
||||||
border-bottom: 1px solid #30363d;
|
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 {
|
.device-table td {
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
border-bottom: 1px solid #21262d;
|
border-bottom: 1px solid #21262d;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user