Identify unknown devices: OUI vendor lookup, mDNS, manual labels
- OUI: mac-oui-lookup package resolves vendor from the MAC prefix (computed on read, no storage needed). Already correctly identifies the LXC host prefix as "Proxmox Server Solutions GmbH" and several "unknown" devices as "Amazon Technologies Inc." -- likely the Echo Dots / Ring gear. - mDNS: discover-devices.sh now runs avahi-resolve per discovered IP (parallel, bounded 2s timeout per host so one non-mDNS device can't stall the run), stored in a new devices.mdns_hostname column. - Manual labels: new device_labels table keyed by MAC (survives DHCP IP changes), PUT/DELETE /api/devices/:mac/label, inline-editable Name cell in the dashboard. Deliberately separate from vendor/mDNS info -- those are shown as an italic *hint* for unlabeled devices, not treated as "known" until the admin actually confirms one. - Fixed the Name column's sort comparator to match what's rendered (name, else vendor/mDNS hint) instead of just the raw name field -- caught while reasoning through what the existing sort test would actually need to assert once hints appear in the column. Part of #15 (OUI/mDNS/manual labels done; on-demand deep-check next). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,8 @@ export interface Device {
|
||||
mac: string;
|
||||
name: string | null;
|
||||
known: boolean;
|
||||
vendor: string | null;
|
||||
mdnsHostname: string | null;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
@@ -65,3 +67,14 @@ export interface Device {
|
||||
export function getDevices(): Promise<{ devices: Device[] }> {
|
||||
return request("/api/devices");
|
||||
}
|
||||
|
||||
export function setDeviceLabel(mac: string, label: string): Promise<{ ok: true }> {
|
||||
return request(`/api/devices/${encodeURIComponent(mac)}/label`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ label }),
|
||||
});
|
||||
}
|
||||
|
||||
export function clearDeviceLabel(mac: string): Promise<{ ok: true }> {
|
||||
return request(`/api/devices/${encodeURIComponent(mac)}/label`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import type { Device } from "../api";
|
||||
import { setDeviceLabel, clearDeviceLabel, type Device } from "../api";
|
||||
|
||||
function ipSortKey(ip: string): number[] {
|
||||
return ip.split(".").map(Number);
|
||||
@@ -13,13 +13,21 @@ function compareIp(a: Device, b: Device): number {
|
||||
|
||||
type SortKey = "status" | "ip" | "mac" | "name" | "lastSeen";
|
||||
|
||||
// Matches what NameCell actually renders (name, else vendor/mDNS hint, else
|
||||
// nothing) -- sorting by the raw `name` field alone would put hint-only rows
|
||||
// in "empty string" position while displaying vendor text that visually
|
||||
// belongs elsewhere in the alphabet.
|
||||
function displayName(d: Device): string {
|
||||
return d.name ?? d.mdnsHostname ?? d.vendor ?? "";
|
||||
}
|
||||
|
||||
// "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 ?? ""),
|
||||
name: (a, b) => displayName(a).localeCompare(displayName(b)),
|
||||
lastSeen: (a, b) => a.lastSeen.localeCompare(b.lastSeen),
|
||||
};
|
||||
|
||||
@@ -31,7 +39,64 @@ const COLUMNS: { key: SortKey; label: string }[] = [
|
||||
{ key: "lastSeen", label: "Last seen" },
|
||||
];
|
||||
|
||||
export function DeviceTable({ devices }: { devices: Device[] }) {
|
||||
function NameCell({ device, onDeviceChanged }: { device: Device; onDeviceChanged: () => void }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState(device.name ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<form
|
||||
className="label-edit-form"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) await setDeviceLabel(device.mac, trimmed);
|
||||
else await clearDeviceLabel(device.mac);
|
||||
setEditing(false);
|
||||
onDeviceChanged();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Label this device"
|
||||
disabled={saving}
|
||||
/>
|
||||
<button type="submit" disabled={saving}>
|
||||
Save
|
||||
</button>
|
||||
<button type="button" onClick={() => setEditing(false)} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// Vendor/mDNS hint shown only when there's no deliberate name yet -- these
|
||||
// are automated guesses, not something the admin has actually confirmed.
|
||||
const hint = device.mdnsHostname ?? device.vendor;
|
||||
|
||||
return (
|
||||
<span className="name-cell" onClick={() => setEditing(true)} title="Click to set a label">
|
||||
{device.name ?? (hint ? <span className="name-hint">{hint}</span> : "—")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceTable({
|
||||
devices,
|
||||
onDeviceChanged,
|
||||
}: {
|
||||
devices: Device[];
|
||||
onDeviceChanged: () => void;
|
||||
}) {
|
||||
const [sortKey, setSortKey] = useState<SortKey>("status");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
|
||||
@@ -76,7 +141,9 @@ export function DeviceTable({ devices }: { devices: Device[] }) {
|
||||
</td>
|
||||
<td>{d.ip}</td>
|
||||
<td className="mono">{d.mac}</td>
|
||||
<td>{d.name ?? "—"}</td>
|
||||
<td>
|
||||
<NameCell device={d} onDeviceChanged={onDeviceChanged} />
|
||||
</td>
|
||||
<td>{d.lastSeen}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -220,6 +220,47 @@ body {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
.name-cell:hover {
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
.name-hint {
|
||||
color: #8b949e;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.label-edit-form {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label-edit-form input {
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #30363d;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
font-size: 0.85rem;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.label-edit-form button {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #30363d;
|
||||
background: #21262d;
|
||||
color: #e6edf3;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-table td {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid #21262d;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getHosts, getDevices, logout, type HostSummary, type Device } from "../api";
|
||||
import { HostCard } from "../components/HostCard";
|
||||
import { DeviceTable } from "../components/DeviceTable";
|
||||
@@ -9,28 +9,30 @@ export function Dashboard({ username, onLoggedOut }: { username: string; onLogge
|
||||
const [hosts, setHosts] = useState<HostSummary[]>([]);
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
try {
|
||||
const [hostsRes, devicesRes] = await Promise.all([getHosts(), getDevices()]);
|
||||
if (!cancelledRef.current) {
|
||||
setHosts(hostsRes.hosts);
|
||||
setDevices(devicesRes.devices);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelledRef.current) setError(err instanceof Error ? err.message : "Failed to load dashboard data");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function poll() {
|
||||
try {
|
||||
const [hostsRes, devicesRes] = await Promise.all([getHosts(), getDevices()]);
|
||||
if (!cancelled) {
|
||||
setHosts(hostsRes.hosts);
|
||||
setDevices(devicesRes.devices);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load dashboard data");
|
||||
}
|
||||
}
|
||||
cancelledRef.current = false;
|
||||
void poll();
|
||||
const id = setInterval(poll, POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelledRef.current = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
}, [poll]);
|
||||
|
||||
const groups = groupBy(hosts, (h) => h.group);
|
||||
|
||||
@@ -65,7 +67,7 @@ export function Dashboard({ username, onLoggedOut }: { username: string; onLogge
|
||||
<h2>
|
||||
Network Devices <span className="device-count">({devices.length})</span>
|
||||
</h2>
|
||||
<DeviceTable devices={devices} />
|
||||
<DeviceTable devices={devices} onDeviceChanged={poll} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user