From 0c9fb76d761c14e33f3fba2a028f8119730ddc9b Mon Sep 17 00:00:00 2001 From: wind Date: Mon, 2 Mar 2026 15:13:17 +0100 Subject: [PATCH] feat: allow trusted devices with unknown type to set device type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - PATCH /api/devices/{id}/device_type — validates against allowed set (iot|server|router|workstation|unknown), 422 on invalid value - 3 new tests (set value, reject invalid, 404) Frontend: - DevicesPage: DeviceTypeCell replaces static badge in table; shows 'Set type…' select only when trusted=true AND type is null or 'unknown' - DeviceDetailPage: same condition — device type section shows select instead of badge; calls refetch() on save - Both pages maintain local state so UI updates immediately on save Closes #110 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/app/api/__init__.py | 35 ++++++++++++ backend/tests/test_api.py | 27 +++++++++ frontend/src/pages/DeviceDetailPage.tsx | 42 +++++++++++++- frontend/src/pages/DevicesPage.tsx | 76 +++++++++++++++++++++++-- 4 files changed, 174 insertions(+), 6 deletions(-) diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 3a3a058..ca5ef15 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -132,6 +132,41 @@ class _LabelUpdate(BaseModel): label: str | None +_VALID_DEVICE_TYPES = {"iot", "server", "router", "workstation", "unknown"} + + +class _DeviceTypeUpdate(BaseModel): + device_type: str + + +@router.patch("/devices/{device_id}/device_type", response_model=DeviceOut) +def set_device_type( + device_id: int, + body: _DeviceTypeUpdate, + db: Annotated[Session, Depends(get_db)], +) -> DeviceOut: + """Set the user-defined device type override.""" + from app.models.device import Device + + if body.device_type not in _VALID_DEVICE_TYPES: + raise HTTPException( + status_code=422, + detail=f"Invalid device_type. Must be one of: {sorted(_VALID_DEVICE_TYPES)}", + ) + stmt = ( + select(Device) + .options(selectinload(Device.ports), selectinload(Device.risks)) + .where(Device.id == device_id) + ) + device = db.execute(stmt).scalar_one_or_none() + if device is None: + raise HTTPException(status_code=404, detail="Device not found") + device.device_type = body.device_type + db.commit() + db.refresh(device) + return _device_to_out(device) + + @router.patch("/devices/{device_id}/label", response_model=DeviceOut) def set_device_label( device_id: int, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 5c81023..02875a5 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -615,6 +615,33 @@ def test_patch_device_trusted_404_for_unknown(client): assert resp.status_code == 404 +def test_patch_device_type_sets_value(client, seeded_db): + """PATCH /api/devices/{id}/device_type should update device_type.""" + device_id = seeded_db["device_id"] + resp = client.patch( + f"/api/devices/{device_id}/device_type", + json={"device_type": "server"}, + ) + assert resp.status_code == 200 + assert resp.json()["device_type"] == "server" + + +def test_patch_device_type_rejects_invalid(client, seeded_db): + """PATCH /api/devices/{id}/device_type rejects unknown type values.""" + device_id = seeded_db["device_id"] + resp = client.patch( + f"/api/devices/{device_id}/device_type", + json={"device_type": "toaster"}, + ) + assert resp.status_code == 422 + + +def test_patch_device_type_404_for_unknown(client): + """PATCH /api/devices/{id}/device_type returns 404 for non-existent device.""" + resp = client.patch("/api/devices/999999/device_type", json={"device_type": "iot"}) + assert resp.status_code == 404 + + def test_scan_response_includes_current_stage(client, seeded_db): """GET /api/scans response must include the current_stage field (may be null).""" resp = client.get("/api/scans") diff --git a/frontend/src/pages/DeviceDetailPage.tsx b/frontend/src/pages/DeviceDetailPage.tsx index f49f2f6..b21bd84 100644 --- a/frontend/src/pages/DeviceDetailPage.tsx +++ b/frontend/src/pages/DeviceDetailPage.tsx @@ -96,6 +96,7 @@ export function DeviceDetailPage() { const [editingLabel, setEditingLabel] = useState(false); const [labelDraft, setLabelDraft] = useState(""); const [savingLabel, setSavingLabel] = useState(false); + const [savingType, setSavingType] = useState(false); const labelInputRef = useRef(null); const displayLabel = label !== undefined ? label : device?.label; @@ -160,6 +161,23 @@ export function DeviceDetailPage() { .finally(() => setTogglingTrust(false)); }; + const handleSetDeviceType = (value: string) => { + setSavingType(true); + fetch(`/api/devices/${deviceId}/device_type`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ device_type: value }), + }) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return refetch(); + }) + .catch(() => { + setToastMsg("Failed to update device type. Please try again."); + }) + .finally(() => setSavingType(false)); + }; + return (
{toastMsg && ( @@ -306,7 +324,29 @@ export function DeviceDetailPage() { Device Type
- + {device.trusted && + (!device.device_type || device.device_type === "unknown") ? ( + + ) : ( + + )}
diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index e9ddf5f..bb35c17 100644 --- a/frontend/src/pages/DevicesPage.tsx +++ b/frontend/src/pages/DevicesPage.tsx @@ -98,6 +98,61 @@ function LabelCell({ ); } +const DEVICE_TYPE_OPTIONS: { value: DeviceType; label: string }[] = [ + { value: "iot", label: "📡 IoT" }, + { value: "server", label: "🖥 Server" }, + { value: "router", label: "🔀 Router" }, + { value: "workstation", label: "💻 Workstation" }, + { value: "unknown", label: "❓ Unknown" }, +]; + +/** Inline device-type selector — shown only for trusted devices with no known type. */ +function DeviceTypeCell({ + device, + onSaved, +}: { + device: Device; + onSaved: (type: DeviceType) => void; +}) { + const needsType = + device.trusted && (!device.device_type || device.device_type === "unknown"); + + if (!needsType) { + return ; + } + + const save = (value: string) => { + fetch(`/api/devices/${device.id}/device_type`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ device_type: value }), + }) + .then((r) => r.json()) + .then((d: Device) => onSaved(d.device_type as DeviceType)) + .catch(() => {}); + }; + + return ( + + ); +} + type SortKey = | "ip_address" | "hostname" @@ -158,12 +213,15 @@ export function DevicesPage() { const [localLabels, setLocalLabels] = useState>( {}, ); + const [localTypes, setLocalTypes] = useState>({}); const devices = useMemo( () => - rawDevices.map((d) => - d.id in localLabels ? { ...d, label: localLabels[d.id] } : d, - ), - [rawDevices, localLabels], + rawDevices.map((d) => ({ + ...d, + ...(d.id in localLabels ? { label: localLabels[d.id] } : {}), + ...(d.id in localTypes ? { device_type: localTypes[d.id] } : {}), + })), + [rawDevices, localLabels, localTypes], ); const { risks } = useRisks(); const [filter, setFilter] = useState(""); @@ -385,7 +443,15 @@ export function DevicesPage() { : "—"} - + + setLocalTypes((prev) => ({ + ...prev, + [device.id]: type, + })) + } + /> ))}