Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
42 changes: 41 additions & 1 deletion frontend/src/pages/DeviceDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>(null);

const displayLabel = label !== undefined ? label : device?.label;
Expand Down Expand Up @@ -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 (
<div>
{toastMsg && (
Expand Down Expand Up @@ -306,7 +324,29 @@ export function DeviceDetailPage() {
Device Type
</dt>
<dd className="mt-1">
<DeviceTypeBadge type={device.device_type} size="md" />
{device.trusted &&
(!device.device_type || device.device_type === "unknown") ? (
<select
defaultValue=""
onChange={(e) => {
if (e.target.value) handleSetDeviceType(e.target.value);
}}
disabled={savingType}
aria-label="Set device type"
className="rounded border border-[var(--color-accent-primary)] bg-[var(--color-background)] px-2 py-1 text-sm text-[var(--color-text-primary)] focus:outline-none"
>
<option value="" disabled>
Set type…
</option>
<option value="iot">📡 IoT</option>
<option value="server">🖥 Server</option>
<option value="router">🔀 Router</option>
<option value="workstation">💻 Workstation</option>
<option value="unknown">❓ Unknown</option>
</select>
) : (
<DeviceTypeBadge type={device.device_type} size="md" />
)}
</dd>
</div>
<div className="flex flex-col">
Expand Down
76 changes: 71 additions & 5 deletions frontend/src/pages/DevicesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <DeviceTypeBadge type={device.device_type} />;
}

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 (
<select
defaultValue=""
onChange={(e) => {
if (e.target.value) save(e.target.value);
}}
aria-label={`Set device type for ${device.ip_address}`}
className="rounded border border-[var(--color-accent-primary)] bg-[var(--color-background)] px-1.5 py-0.5 text-xs text-[var(--color-text-primary)] focus:outline-none"
>
<option value="" disabled>
Set type…
</option>
{DEVICE_TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}

type SortKey =
| "ip_address"
| "hostname"
Expand Down Expand Up @@ -158,12 +213,15 @@ export function DevicesPage() {
const [localLabels, setLocalLabels] = useState<Record<number, string | null>>(
{},
);
const [localTypes, setLocalTypes] = useState<Record<number, DeviceType>>({});
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("");
Expand Down Expand Up @@ -385,7 +443,15 @@ export function DevicesPage() {
: "—"}
</td>
<td className="px-4 py-3">
<DeviceTypeBadge type={device.device_type} />
<DeviceTypeCell
device={device}
onSaved={(type) =>
setLocalTypes((prev) => ({
...prev,
[device.id]: type,
}))
}
/>
</td>
</tr>
))}
Expand Down
Loading