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
25 changes: 25 additions & 0 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions backend/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions backend/app/models/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
85 changes: 80 additions & 5 deletions frontend/src/pages/DeviceDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -83,6 +83,13 @@ export function DeviceDetailPage() {
useDeviceRecommendations(deviceId);
const [togglingTrust, setTogglingTrust] = useState(false);
const [toastMsg, setToastMsg] = useState<string | null>(null);
const [label, setLabel] = useState<string | null | undefined>(undefined); // undefined = use device.label
const [editingLabel, setEditingLabel] = useState(false);
const [labelDraft, setLabelDraft] = useState("");
const [savingLabel, setSavingLabel] = useState(false);
const labelInputRef = useRef<HTMLInputElement>(null);

const displayLabel = label !== undefined ? label : device?.label;

// Build a map of risk_id → recommendation for O(1) lookup
const recByRiskId = useMemo(() => {
Expand Down Expand Up @@ -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`, {
Expand Down Expand Up @@ -158,7 +182,7 @@ export function DeviceDetailPage() {
</div>
<PageHeader
title={device.ip_address}
subtitle={device.hostname ?? undefined}
subtitle={displayLabel ?? device.hostname ?? undefined}
action={
<div className="flex items-center gap-3">
{device.trusted && (
Expand Down Expand Up @@ -190,6 +214,57 @@ export function DeviceDetailPage() {
{/* Device metadata */}
<Card className="mb-6">
<dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
{/* Label — inline editable */}
<div className="flex flex-col">
<dt className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
Label
</dt>
<dd className="font-mono text-[var(--color-text-primary)]">
{editingLabel ? (
<input
ref={labelInputRef}
autoFocus
value={labelDraft}
onChange={(e) => 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…"
/>
) : (
<span className="group flex items-center gap-1.5">
<span
className={
!displayLabel
? "text-[var(--color-text-secondary)]"
: undefined
}
>
{displayLabel ?? "—"}
</span>
<button
onClick={() => {
setLabelDraft(displayLabel ?? "");
setEditingLabel(true);
}}
aria-label="Edit label"
title="Edit label"
className="opacity-0 group-hover:opacity-60 hover:!opacity-100 text-xs transition-opacity"
>
</button>
</span>
)}
</dd>
</div>
{[
["Hostname", device.hostname ?? "—"],
["MAC Address", device.mac_address ?? "—"],
Expand All @@ -207,10 +282,10 @@ export function DeviceDetailPage() {
? new Date(device.last_seen).toLocaleString()
: "—",
],
].map(([label, value]) => (
<div key={label} className="flex flex-col">
].map(([lbl, value]) => (
<div key={lbl} className="flex flex-col">
<dt className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
{label}
{lbl}
</dt>
<dd className="font-mono text-[var(--color-text-primary)]">
{value}
Expand Down
107 changes: 104 additions & 3 deletions frontend/src/pages/DevicesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>(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 (
<input
ref={inputRef}
autoFocus
value={draft}
onChange={(e) => 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 (
<span className="group flex items-center gap-1.5">
<span
className={
isPlaceholder ? "text-[var(--color-text-secondary)]" : undefined
}
>
{display}
</span>
<button
onClick={startEdit}
aria-label={`Edit label for ${device.ip_address}`}
title="Edit label"
className="opacity-0 group-hover:opacity-60 hover:!opacity-100 text-xs transition-opacity"
>
</button>
</span>
);
}

type SortKey =
| "ip_address"
| "hostname"
Expand Down Expand Up @@ -59,7 +142,17 @@ function sortDevices(
}

export function DevicesPage() {
const { devices, loading, error } = useDevices();
const { devices: rawDevices, loading, error } = useDevices();
const [localLabels, setLocalLabels] = useState<Record<number, string | null>>(
{},
);
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("");
Expand Down Expand Up @@ -231,7 +324,15 @@ export function DevicesPage() {
)}
</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
{device.hostname ?? "—"}
<LabelCell
device={device}
onSaved={(label) =>
setLocalLabels((prev) => ({
...prev,
[device.id]: label,
}))
}
/>
</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
{device.os_guess ?? "—"}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading