diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 6093c2c..a14f1ef 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -33,6 +33,7 @@ class DeviceOut(BaseModel): vendor: str | None hostname: str | None os_guess: str | None + label: str | None trusted: bool first_seen: str | None # ISO-8601 string last_seen: str | None @@ -113,6 +114,29 @@ def set_device_trusted( return _device_to_out(device) +class _LabelUpdate(BaseModel): + label: str | None + + +@router.patch("/devices/{device_id}/label", response_model=DeviceOut) +def set_device_label( + device_id: int, + body: _LabelUpdate, + db: Annotated[Session, Depends(get_db)], +) -> DeviceOut: + """Set or clear the user-defined label on a device.""" + from app.models.device import Device + + stmt = select(Device).options(selectinload(Device.ports)).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.label = body.label.strip() if body.label else None + db.commit() + db.refresh(device) + return _device_to_out(device) + + def _device_to_out(d) -> DeviceOut: # noqa: ANN001 — SQLAlchemy instance, validated via Pydantic return DeviceOut( id=d.id, @@ -121,6 +145,7 @@ def _device_to_out(d) -> DeviceOut: # noqa: ANN001 — SQLAlchemy instance, val vendor=d.vendor, hostname=d.hostname, os_guess=d.os_guess, + label=d.label, trusted=bool(d.trusted), first_seen=d.first_seen.isoformat() if d.first_seen else None, last_seen=d.last_seen.isoformat() if d.last_seen else None, diff --git a/backend/app/db.py b/backend/app/db.py index 1685c74..dde7c65 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -34,6 +34,7 @@ def _migrate_schema(engine) -> None: ("scans", "warning_message", "TEXT"), ("risks", "acknowledged_at", "DATETIME"), ("risks", "acknowledged_note", "TEXT"), + ("devices", "label", "TEXT"), ] with engine.connect() as conn: for table, column, col_def in migrations: diff --git a/backend/app/models/device.py b/backend/app/models/device.py index eac4b15..0b3aabe 100644 --- a/backend/app/models/device.py +++ b/backend/app/models/device.py @@ -30,6 +30,7 @@ class Device(Base): vendor = Column(String, nullable=True) # hardware vendor from arp-scan OUI lookup hostname = Column(String, nullable=True) os_guess = Column(String, nullable=True) + label = Column(String, nullable=True) # user-assigned friendly name trusted = Column(Boolean, nullable=False, default=False, server_default="0") first_seen = Column(DateTime, default=func.now()) last_seen = Column(DateTime, default=func.now(), onupdate=func.now()) diff --git a/frontend/src/pages/DeviceDetailPage.tsx b/frontend/src/pages/DeviceDetailPage.tsx index 223fbea..f897cb6 100644 --- a/frontend/src/pages/DeviceDetailPage.tsx +++ b/frontend/src/pages/DeviceDetailPage.tsx @@ -2,7 +2,7 @@ * DeviceDetailPage — ports/services table, risk list, timestamps. * Route: /devices/:id */ -import { memo, useMemo, useState } from "react"; +import { memo, useRef, useMemo, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { Card, Badge, SkeletonCard, PageHeader } from "../components"; import { SEV_LEVELS } from "../constants/severity"; @@ -83,6 +83,13 @@ export function DeviceDetailPage() { useDeviceRecommendations(deviceId); const [togglingTrust, setTogglingTrust] = useState(false); const [toastMsg, setToastMsg] = useState(null); + const [label, setLabel] = useState(undefined); // undefined = use device.label + const [editingLabel, setEditingLabel] = useState(false); + const [labelDraft, setLabelDraft] = useState(""); + const [savingLabel, setSavingLabel] = useState(false); + const labelInputRef = useRef(null); + + const displayLabel = label !== undefined ? label : device?.label; // Build a map of risk_id → recommendation for O(1) lookup const recByRiskId = useMemo(() => { @@ -110,6 +117,23 @@ export function DeviceDetailPage() { (a, b) => SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity), ); + const handleSaveLabel = () => { + setSavingLabel(true); + const newLabel = labelDraft.trim() || null; + fetch(`/api/devices/${deviceId}/label`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label: newLabel }), + }) + .then((r) => r.json()) + .then((d: { label: string | null }) => setLabel(d.label)) + .catch(() => {}) + .finally(() => { + setSavingLabel(false); + setEditingLabel(false); + }); + }; + const handleToggleTrust = () => { setTogglingTrust(true); fetch(`/api/devices/${deviceId}/trusted`, { @@ -158,7 +182,7 @@ export function DeviceDetailPage() { {device.trusted && ( @@ -190,6 +214,57 @@ export function DeviceDetailPage() { {/* Device metadata */}
+ {/* Label — inline editable */} +
+
+ Label +
+
+ {editingLabel ? ( + setLabelDraft(e.target.value)} + onBlur={handleSaveLabel} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + labelInputRef.current?.blur(); + } else if (e.key === "Escape") { + setEditingLabel(false); + } + }} + disabled={savingLabel} + className="w-full rounded border border-[var(--color-accent-primary)] bg-[var(--color-background)] px-1.5 py-0.5 text-sm focus:outline-none" + placeholder="Add label…" + /> + ) : ( + + + {displayLabel ?? "—"} + + + + )} +
+
{[ ["Hostname", device.hostname ?? "—"], ["MAC Address", device.mac_address ?? "—"], @@ -207,10 +282,10 @@ export function DeviceDetailPage() { ? new Date(device.last_seen).toLocaleString() : "—", ], - ].map(([label, value]) => ( -
+ ].map(([lbl, value]) => ( +
- {label} + {lbl}
{value} diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index a90ea11..6a88a29 100644 --- a/frontend/src/pages/DevicesPage.tsx +++ b/frontend/src/pages/DevicesPage.tsx @@ -2,12 +2,95 @@ * DevicesPage — sortable/filterable table of all discovered devices. * Route: /devices */ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { Card, Badge, SkeletonTable, PageHeader } from "../components"; import { useDevices, useRisks } from "../hooks"; import type { Device } from "../types/api"; +/** Inline label editor that saves on blur or Enter, cancels on Escape. */ +function LabelCell({ + device, + onSaved, +}: { + device: Device; + onSaved: (label: string | null) => void; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(device.label ?? ""); + const [saving, setSaving] = useState(false); + const inputRef = useRef(null); + + const display = device.label ?? device.hostname ?? "—"; + const isPlaceholder = !device.label && !device.hostname; + + const startEdit = () => { + setDraft(device.label ?? ""); + setEditing(true); + setTimeout(() => inputRef.current?.select(), 0); + }; + + const save = () => { + setSaving(true); + const newLabel = draft.trim() || null; + fetch(`/api/devices/${device.id}/label`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label: newLabel }), + }) + .then((r) => r.json()) + .then((d: Device) => onSaved(d.label)) + .catch(() => {}) + .finally(() => { + setSaving(false); + setEditing(false); + }); + }; + + if (editing) { + return ( + setDraft(e.target.value)} + onBlur={save} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + inputRef.current?.blur(); + } else if (e.key === "Escape") { + setEditing(false); + } + }} + disabled={saving} + className="w-full rounded border border-[var(--color-accent-primary)] bg-[var(--color-background)] px-1.5 py-0.5 text-sm text-[var(--color-text-primary)] focus:outline-none" + placeholder="Add label…" + /> + ); + } + + return ( + + + {display} + + + + ); +} + type SortKey = | "ip_address" | "hostname" @@ -59,7 +142,17 @@ function sortDevices( } export function DevicesPage() { - const { devices, loading, error } = useDevices(); + const { devices: rawDevices, loading, error } = useDevices(); + const [localLabels, setLocalLabels] = useState>( + {}, + ); + const devices = useMemo( + () => + rawDevices.map((d) => + d.id in localLabels ? { ...d, label: localLabels[d.id] } : d, + ), + [rawDevices, localLabels], + ); const { risks } = useRisks(); const [filter, setFilter] = useState(""); const [osFilter, setOsFilter] = useState(""); @@ -231,7 +324,15 @@ export function DevicesPage() { )} - {device.hostname ?? "—"} + + setLocalLabels((prev) => ({ + ...prev, + [device.id]: label, + })) + } + /> {device.os_guess ?? "—"} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index e3bb961..87a4cb1 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -18,6 +18,7 @@ export interface Device { vendor: string | null; hostname: string | null; os_guess: string | null; + label: string | null; trusted: boolean; first_seen: string | null; // ISO-8601 last_seen: string | null;