Wire up on-demand deep-check: API route + dashboard button
POST /api/devices/:ip/deep-check runs deep-check-device.sh on the CT122 host via SSH (reaches its own LAN IP), returns mDNS/SSDP/port scan results. "Deep check" button on unknown device rows in the dashboard shows results inline below the row. Verified end-to-end via SSH before wiring into the API: correctly identified Home Assistant via SSDP (friendlyName/manufacturer/model), and confirmed both a shell-injection attempt and an out-of-subnet IP get rejected cleanly by the forced command's input validation. Closes #15 (all four pieces: OUI, mDNS, manual labels, deep check). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -78,3 +78,21 @@ export function setDeviceLabel(mac: string, label: string): Promise<{ ok: true }
|
||||
export function clearDeviceLabel(mac: string): Promise<{ ok: true }> {
|
||||
return request(`/api/devices/${encodeURIComponent(mac)}/label`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export interface DeepCheckResult {
|
||||
ip: string;
|
||||
mdnsHostname: string | null;
|
||||
reverseDns: string | null;
|
||||
ssdp: {
|
||||
location: string;
|
||||
friendlyName: string | null;
|
||||
manufacturer: string | null;
|
||||
modelName: string | null;
|
||||
} | null;
|
||||
openPorts: number[];
|
||||
http: { port: number; title: string | null; server: string | null }[];
|
||||
}
|
||||
|
||||
export function deepCheckDevice(ip: string): Promise<DeepCheckResult> {
|
||||
return request(`/api/devices/${encodeURIComponent(ip)}/deep-check`, { method: "POST" });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { setDeviceLabel, clearDeviceLabel, type Device } from "../api";
|
||||
import { Fragment, useState } from "react";
|
||||
import { setDeviceLabel, clearDeviceLabel, deepCheckDevice, type Device, type DeepCheckResult } from "../api";
|
||||
|
||||
function ipSortKey(ip: string): number[] {
|
||||
return ip.split(".").map(Number);
|
||||
@@ -90,6 +90,70 @@ function NameCell({ device, onDeviceChanged }: { device: Device; onDeviceChanged
|
||||
);
|
||||
}
|
||||
|
||||
function DeepCheckResultPanel({ result }: { result: DeepCheckResult }) {
|
||||
const nothingFound =
|
||||
!result.mdnsHostname && !result.reverseDns && !result.ssdp && result.openPorts.length === 0;
|
||||
|
||||
if (nothingFound) {
|
||||
return <p className="deep-check-empty">No additional information found for {result.ip}.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className="deep-check-results">
|
||||
{result.mdnsHostname && (
|
||||
<>
|
||||
<dt>mDNS hostname</dt>
|
||||
<dd>{result.mdnsHostname}</dd>
|
||||
</>
|
||||
)}
|
||||
{result.reverseDns && (
|
||||
<>
|
||||
<dt>Reverse DNS</dt>
|
||||
<dd>{result.reverseDns}</dd>
|
||||
</>
|
||||
)}
|
||||
{result.ssdp && (
|
||||
<>
|
||||
{result.ssdp.friendlyName && (
|
||||
<>
|
||||
<dt>SSDP name</dt>
|
||||
<dd>{result.ssdp.friendlyName}</dd>
|
||||
</>
|
||||
)}
|
||||
{result.ssdp.manufacturer && (
|
||||
<>
|
||||
<dt>Manufacturer</dt>
|
||||
<dd>{result.ssdp.manufacturer}</dd>
|
||||
</>
|
||||
)}
|
||||
{result.ssdp.modelName && (
|
||||
<>
|
||||
<dt>Model</dt>
|
||||
<dd>{result.ssdp.modelName}</dd>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{result.openPorts.length > 0 && (
|
||||
<>
|
||||
<dt>Open ports</dt>
|
||||
<dd>{result.openPorts.join(", ")}</dd>
|
||||
</>
|
||||
)}
|
||||
{result.http.map((h) => (
|
||||
<div key={h.port} className="deep-check-http">
|
||||
<dt>Port {h.port}</dt>
|
||||
<dd>
|
||||
{h.title && <span>{h.title}</span>}
|
||||
{h.server && <span className="deep-check-server"> ({h.server})</span>}
|
||||
{!h.title && !h.server && <span className="name-hint">no title/server header</span>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceTable({
|
||||
devices,
|
||||
onDeviceChanged,
|
||||
@@ -99,6 +163,9 @@ export function DeviceTable({
|
||||
}) {
|
||||
const [sortKey, setSortKey] = useState<SortKey>("status");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
const [deepCheckResults, setDeepCheckResults] = useState<
|
||||
Map<string, { status: "loading" } | { status: "done"; result: DeepCheckResult } | { status: "error"; message: string }>
|
||||
>(new Map());
|
||||
|
||||
function handleSort(key: SortKey) {
|
||||
if (key === sortKey) {
|
||||
@@ -109,6 +176,18 @@ export function DeviceTable({
|
||||
}
|
||||
}
|
||||
|
||||
async function runDeepCheck(ip: string) {
|
||||
setDeepCheckResults((prev) => new Map(prev).set(ip, { status: "loading" }));
|
||||
try {
|
||||
const result = await deepCheckDevice(ip);
|
||||
setDeepCheckResults((prev) => new Map(prev).set(ip, { status: "done", result }));
|
||||
} catch (err) {
|
||||
setDeepCheckResults((prev) =>
|
||||
new Map(prev).set(ip, { status: "error", message: err instanceof Error ? err.message : "deep check failed" })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...devices].sort((a, b) => {
|
||||
const primary = COMPARATORS[sortKey](a, b);
|
||||
const signed = sortDir === "asc" ? primary : -primary;
|
||||
@@ -129,27 +208,58 @@ export function DeviceTable({
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((d) => (
|
||||
<tr key={d.ip} data-ip={d.ip}>
|
||||
<td>
|
||||
<span className={`device-badge ${d.known ? "known" : "unknown"}`}>
|
||||
{d.known ? "known" : "unknown"}
|
||||
</span>
|
||||
</td>
|
||||
<td>{d.ip}</td>
|
||||
<td className="mono">{d.mac}</td>
|
||||
<td>
|
||||
<NameCell device={d} onDeviceChanged={onDeviceChanged} />
|
||||
</td>
|
||||
<td>{d.lastSeen}</td>
|
||||
</tr>
|
||||
))}
|
||||
{sorted.map((d) => {
|
||||
const checkState = deepCheckResults.get(d.ip);
|
||||
return (
|
||||
<Fragment key={d.ip}>
|
||||
<tr data-ip={d.ip}>
|
||||
<td>
|
||||
<span className={`device-badge ${d.known ? "known" : "unknown"}`}>
|
||||
{d.known ? "known" : "unknown"}
|
||||
</span>
|
||||
</td>
|
||||
<td>{d.ip}</td>
|
||||
<td className="mono">{d.mac}</td>
|
||||
<td>
|
||||
<NameCell device={d} onDeviceChanged={onDeviceChanged} />
|
||||
</td>
|
||||
<td>{d.lastSeen}</td>
|
||||
<td>
|
||||
{!d.known && (
|
||||
<button
|
||||
className="deep-check-button"
|
||||
onClick={() => runDeepCheck(d.ip)}
|
||||
disabled={checkState?.status === "loading"}
|
||||
>
|
||||
{checkState?.status === "loading" ? "Checking…" : "Deep check"}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{checkState?.status === "done" && (
|
||||
<tr className="deep-check-row">
|
||||
<td colSpan={6}>
|
||||
<DeepCheckResultPanel result={checkState.result} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{checkState?.status === "error" && (
|
||||
<tr className="deep-check-row">
|
||||
<td colSpan={6}>
|
||||
<p className="error">Deep check failed: {checkState.message}</p>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{sorted.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="empty">
|
||||
<td colSpan={6} className="empty">
|
||||
No devices seen yet — discovery runs every 5 minutes.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -261,6 +261,60 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deep-check-button {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #30363d;
|
||||
background: #21262d;
|
||||
color: #e6edf3;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deep-check-button:hover:not(:disabled) {
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
.deep-check-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.deep-check-row td {
|
||||
background: #0d1117;
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
|
||||
.deep-check-empty {
|
||||
color: #8b949e;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.deep-check-results {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.2rem 1rem;
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.deep-check-results dt {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.deep-check-results dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.deep-check-http {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.deep-check-server {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.device-table td {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid #21262d;
|
||||
|
||||
Reference in New Issue
Block a user