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
45 changes: 42 additions & 3 deletions apps/docs/src/content/docs/features/devices.mdx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
---
title: Devices
description: Work with the device list and device Info tab — customizable columns, filters, VPN presence, and battery status.
description: Work with the device list and device Info tab — customizable columns, filters, VPN presence, battery status, and manual (hand-entered) assets.
---

import { Aside } from '@astrojs/starlight/components';

The **Devices** page is the home view for every managed endpoint — servers, workstations, laptops, and discovered network devices across all the organizations you can access. This page covers the device list itself (columns and filters) and the device detail **Info** tab, including VPN presence and battery status.
The **Devices** page is the home view for every managed endpoint — servers, workstations, laptops, discovered network devices, and hand-entered manual assets across all the organizations you can access. This page covers the device list itself (columns and filters) and the device detail **Info** tab, including VPN presence and battery status.

---

Expand All @@ -24,6 +24,7 @@ The list shows a compact set of columns by default — hostname, class, organiza
- **WAN IP** and **LAN IP** — the device's public (egress) address as seen from the server, and its local network interface address. Both are sortable; see [IP History](/features/ip-history/) for the timeline of address changes.
- **Tags, last logged-in user, uptime, enrollment date**
- **Desktop access** and **reliability score**
- **Serial, asset tag, location** — inventory fields for manual assets (asset tag and location) and, where reported, serial number (manual assets and agent devices); see [Manual Assets](#manual-assets).

Your column selection and order are remembered per browser, so each technician can tailor the list to their workflow. Most columns are sortable by clicking the header.

Expand All @@ -33,11 +34,49 @@ Above the list you can:

- **Search** by display name or hostname.
- Build **structured filters** with the filter toolbar — status, OS, role, organization, site, group, hardware attributes, and more, combinable into saved filter conditions.
- Switch the **class facet** between All, Agent (endpoints running the Breeze agent), and Network (devices found by network discovery).
- Switch the **class facet** between All, Agent (endpoints running the Breeze agent), Network (devices found by network discovery), and Manual (hand-entered assets — see [Manual Assets](#manual-assets)).
- Filter by **VPN** when the VPN column is enabled (see below).

---

## Manual Assets

Not everything an MSP is responsible for runs an agent or answers a ping. A spare laptop in a drawer, a desk phone, a non-networked label printer, a loaner tablet out with a field tech — a **manual asset** records that equipment as plain inventory so it shows up in the same unified Devices list as everything else.

### Discovered asset vs. manual asset

The rule is simple: **does it have a network identity?**

- **Has an IP address, hostname, or URL** — it's a discovered network asset (the **Network** class), found automatically by network discovery scanning and eligible for monitors, alerts, and topology. See [Network Discovery](/features/discovery/) if that's what you're looking for.
- **Does not** — it's a manual asset. You type it in by hand, and it carries no reachability at all: it was never "online" or "offline", so its **Status** column shows **Unknown** rather than a misleading Offline.

Manual assets deliberately carry no IP, MAC address, monitoring, alerts, or remote access — those are all agent/network concepts a hand-entered row cannot answer.

### Adding a manual asset

From the Devices page, open the **Add** menu next to the device list and choose **Add asset manually…** (the other item, **Install agent…**, is the existing agent-enrollment flow — the two share one menu since they're both ways of adding something to your fleet). Fill in:

- **Site** (required) — defaults to the organization's only site when it has just one.
- **Name** (required) — the label that appears everywhere else in the list.
- **Asset type** — the same device-type list (printer, workstation, phone, etc.) discovered devices use.
- **Manufacturer, model, serial number, asset tag, location** — free-text inventory fields. Location is a free-text field within the site ("Closet B, shelf 2"), not a structured address.
- **Assigned to** — an organization contact (not a Breeze technician login) responsible for the asset.
- **Tags** and **notes**.

Serial numbers are not required to be unique — the same manufacturer's serial format can legitimately repeat across vendors. If you enter a serial that already exists in the organization, the form shows a non-blocking warning; it does not stop you from saving.

### Editing, linking, and deleting

Select a manual asset's row to reopen the same modal for editing. From there you can also **link** the record to an agent device or a discovered network asset once one shows up for that physical machine — for example, after IT installs the agent on a spare laptop that had been tracked manually. Linking is reversible (**Unlink** restores the manual record to the list) and never merges data destructively: the manual record's inventory fields (serial, asset tag, location, assigned contact, notes) stay attached and surface alongside the linked device or asset. A linked manual asset drops out of the Manual segment — the fleet is never double-counted — until you unlink it.

**Delete** permanently removes a manual asset record (no agent to uninstall, so there's no separate "remove" step). It's available from the row actions and, for a manual-only selection, from the bulk actions menu.

### Filtering and search

The **Manual** segment (and its Devices-list count) is independent of network discovery being enabled — a manual asset still shows up even in an environment with no network-discovered devices at all. Search matches a manual asset's name, serial number, and asset tag. Structured filters apply only where they make sense for a manual asset: name, tags, asset type, organization, site, manufacturer, model, and serial number are supported; anything that depends on reachability or an agent (status, IP/MAC, last-seen, OS, agent version, metrics) is reported as *not applicable* rather than silently hiding the row without explanation.

---

## Billing Coverage

The device **Overview** tab carries a **Billing** card answering "which contract line bills this device?". It needs **Contracts read** access and a partner-scoped login, so techs without billing access do not see it at all. It shows one of four things:
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/components/devices/DeviceCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,54 @@ describe('DeviceCard sr-only status text', () => {
expect(screen.queryByText('Decommissioned')).not.toBeInTheDocument();
});
});

// #4622 W04: this card had no `deviceClass` handling for 'manual' at all when
// the class was first introduced, so the grid offered the FULL agent kebab
// (Terminal/Run Script/Reboot/Decommission/Permanent Delete) on a manual
// asset's foreign `manual_assets.id` — the same #4014 failure class the
// network arm was already fixed for. Locks in the fix: Edit/Delete only, no
// metrics fetch.
describe('DeviceCard manual asset class (#4622 W04)', () => {
const manualDevice: Device = {
...baseDevice,
id: 'manual-1',
hostname: 'spare-laptop',
deviceClass: 'manual',
assetType: 'workstation',
status: 'unknown',
};

beforeEach(() => {
vi.clearAllMocks();
});

it('never fires the agent metrics-history request for a manual row', async () => {
render(<DeviceCard device={manualDevice} />);
// Give any accidental effect a tick to fire before asserting its absence.
await Promise.resolve();
expect(fetchWithAuthMock).not.toHaveBeenCalled();
});

it('offers Edit and Delete, never the agent actions menu, for a manual row', () => {
const onClick = vi.fn();
const onAction = vi.fn();
render(<DeviceCard device={manualDevice} onClick={onClick} onAction={onAction} />);

expect(screen.getByTestId('device-manual-1-edit-manual')).toBeInTheDocument();
expect(screen.getByTestId('device-manual-1-delete-manual')).toBeInTheDocument();
expect(screen.queryByTestId('device-manual-1-actions-menu')).not.toBeInTheDocument();
expect(screen.queryByTestId('device-manual-1-open-network')).not.toBeInTheDocument();

screen.getByTestId('device-manual-1-edit-manual').click();
expect(onClick).toHaveBeenCalledWith(manualDevice);

screen.getByTestId('device-manual-1-delete-manual').click();
expect(onAction).toHaveBeenCalledWith('delete-manual', manualDevice);
});

it('renders the Unknown status chip, never Offline, and no CPU/RAM reading', () => {
render(<DeviceCard device={manualDevice} />);
expect(screen.getByText('Unknown')).toBeInTheDocument();
expect(screen.queryByText('Offline')).not.toBeInTheDocument();
});
});
80 changes: 69 additions & 11 deletions apps/web/src/components/devices/DeviceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Monitor,
MoreVertical,
Network,
Package,
Terminal,
RotateCcw,
FileCode,
Expand Down Expand Up @@ -162,12 +163,21 @@ export default function DeviceCard({
// row and 404s. The list row already collapses to a single "View"
// (DeviceList.tsx); the grid card mirrors that treatment exactly, reusing the
// same `deviceList.view` copy and `-open-network` test id.
const isNetwork = (device.deviceClass ?? "agent") === "network";
//
// #4622 W04: a manual asset's `id` is a `manual_assets.id`, the same foreign-
// id problem as network — the fix here mirrors DeviceList.tsx's manual row
// Actions cell (Edit + Delete instead of the agent kebab) rather than the
// network arm's single "View", since a manual asset IS editable, just not
// through the agent action funnel.
const deviceClass = device.deviceClass ?? "agent";
const isNetwork = deviceClass === "network";
const isManual = deviceClass === "manual";

useEffect(() => {
// A discovered asset has no agent and no metric history; firing the request
// anyway is a guaranteed 404 on every card mount.
if (isNetwork) return;
// A discovered asset or a manual asset has no agent and no metric
// history; firing the request anyway is a guaranteed 404 on every card
// mount.
if (isNetwork || isManual) return;

let isCancelled = false;

Expand Down Expand Up @@ -205,7 +215,7 @@ export default function DeviceCard({
return () => {
isCancelled = true;
};
}, [device.id, isNetwork]);
}, [device.id, isNetwork, isManual]);

const cpuHistory =
historyState === "ready" ? metricHistory.map((point) => point.cpu) : [];
Expand Down Expand Up @@ -245,6 +255,9 @@ export default function DeviceCard({
// to the generic monitor glyph — the same one a workstation gets.
// The list uses a Network glyph on these rows; match it.
<Network className="h-5 w-5" />
) : isManual ? (
// Same Package glyph DeviceList's class badge uses for manual rows.
<Package className="h-5 w-5" />
) : (
osIcons[device.os] || <Monitor className="h-5 w-5" />
)}
Expand Down Expand Up @@ -273,12 +286,56 @@ export default function DeviceCard({
<Network className="h-3 w-3" />
{t("deviceList.network")}
</span>
) : isManual ? (
<span
data-testid={`device-${device.id}-class-badge`}
title={t("deviceList.manualAsset")}
className="mt-0.5 inline-flex items-center gap-1 rounded-full border border-warning/30 bg-warning/15 px-2 py-0.5 text-[10px] font-medium text-warning"
>
<Package className="h-3 w-3" />
{t("deviceList.manual")}
</span>
) : (
<p className="text-xs text-muted-foreground">{device.osVersion}</p>
)}
</div>
</div>
{isNetwork ? (
{isManual ? (
// Mirrors DeviceList.tsx's manual row Actions cell: Edit (opens the
// add/edit modal via onClick, same as the list) and Delete (routes
// through onAction("delete-manual", ...) into the same confirm-
// gated funnel as the list row and the bulk bar).
<div className="flex items-center gap-1">
<button
type="button"
data-testid={`device-${device.id}-edit-manual`}
aria-label={t("deviceList.editManualAsset", {
name: device.displayName || device.hostname,
})}
onClick={(e) => {
e.stopPropagation();
onClick?.(device);
}}
className="rounded-md border px-2.5 py-1 text-xs font-medium text-muted-foreground hover:bg-muted"
>
{t("deviceList.edit")}
</button>
<button
type="button"
data-testid={`device-${device.id}-delete-manual`}
aria-label={t("deviceList.deleteManualAsset", {
name: device.displayName || device.hostname,
})}
onClick={(e) => {
e.stopPropagation();
onAction?.("delete-manual", device);
}}
className="rounded-md border px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10"
>
{t("deviceList.delete")}
</button>
</div>
) : isNetwork ? (
// Mirrors DeviceList.tsx's network row: the whole action surface
// collapses to one "View", which opens the read-only network detail
// page (DevicesPage.handleSelectDevice routes `network` there).
Expand Down Expand Up @@ -431,11 +488,12 @@ export default function DeviceCard({
)}
</div>

{isNetwork ? (
// A discovered asset reports no CPU/RAM — DevicesPage fills those
// fields with a placeholder 0, which would render as a confident
// "0%" reading for a printer. The list already shows "—" in those
// columns for exactly this reason (DeviceList.tsx agentCell).
{isNetwork || isManual ? (
// A discovered asset or a manual asset reports no CPU/RAM —
// DevicesPage fills those fields with a placeholder 0, which would
// render as a confident "0%" reading for a printer or a spare laptop.
// The list already shows "—" in those columns for exactly this reason
// (DeviceList.tsx agentCell).
<div className="mt-4 grid grid-cols-2 gap-4">
{(["CPU", "RAM"] as const).map((label) => (
<div
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/devices/DeviceClassSegment.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { describe, expect, it, vi } from 'vitest';
import { DeviceClassSegment } from './DeviceClassSegment';

describe('DeviceClassSegment', () => {
const counts = { all: 12, agent: 10, network: 2 };
const counts = { all: 15, agent: 10, network: 2, manual: 3 };

it('renders the three segments with their counts', () => {
it('renders the four segments with their counts', () => {
render(<DeviceClassSegment value="all" counts={counts} onChange={() => {}} />);
expect(screen.getByTestId('device-class-segment-all')).toHaveTextContent('All');
expect(screen.getByTestId('device-class-segment-all')).toHaveTextContent('12');
expect(screen.getByTestId('device-class-segment-all')).toHaveTextContent('15');
expect(screen.getByTestId('device-class-segment-agent')).toHaveTextContent('10');
expect(screen.getByTestId('device-class-segment-network')).toHaveTextContent('2');
expect(screen.getByTestId('device-class-segment-manual')).toHaveTextContent('3');
});

it('marks the active segment as pressed', () => {
Expand Down
14 changes: 10 additions & 4 deletions apps/web/src/components/devices/DeviceClassSegment.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { Cpu, LayoutGrid, Network } from "lucide-react";
import { Cpu, LayoutGrid, Network, Package } from "lucide-react";
import type { ComponentType } from "react";
import type { DeviceClassFilter } from "./deviceClassFilter";
import { useTranslation } from "react-i18next";
import "../../lib/i18n";

type DeviceClassSegmentProps = {
value: DeviceClassFilter;
counts: { all: number; agent: number; network: number };
counts: { all: number; agent: number; network: number; manual: number };
onChange: (value: DeviceClassFilter) => void;
};

// Icons mirror the Class column in DeviceList (Cpu = agent, Network = network)
// so the segment and the per-row badge read as the same vocabulary.
// Icons mirror the Class column in DeviceList (Cpu = agent, Network = network,
// Package = manual) so the segment and the per-row badge read as the same
// vocabulary.
const SEGMENTS: Array<{
id: DeviceClassFilter;
labelKey: string;
Expand All @@ -24,6 +25,11 @@ const SEGMENTS: Array<{
labelKey: "deviceClassSegment.segments.network",
icon: Network,
},
{
id: "manual",
labelKey: "deviceClassSegment.segments.manual",
icon: Package,
},
];

/**
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/devices/DeviceCompare.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,9 @@ export default function DeviceCompare({ timezone }: DeviceCompareProps = {}) {
quarantined: t("deviceCompare.status.quarantined"),
updating: t("deviceCompare.status.updating"),
pending: t("deviceCompare.status.pending"),
// No compare entry point exists for a manual asset (#4622 W04) and the
// `unknown` status is only produced for unprobed network rows (#5213) —
// kept for the shared DeviceStatus exhaustiveness.
unknown: t("deviceCompare.status.unknown"),
};
const osLabels: Record<OSType, string> = {
Expand Down
12 changes: 7 additions & 5 deletions apps/web/src/components/devices/DeviceDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ const statusLabels: Record<DeviceStatus, string> = {
quarantined: "Quarantined",
updating: "Updating",
pending: "Pending",
// No detail page exists for a manual asset in v1 (#4622 W04 spec), and
// `unknown` is only produced for unprobed network rows (#5213), so this
// never renders today — kept for the shared DeviceStatus exhaustiveness.
unknown: "Unknown",
};

Expand Down Expand Up @@ -951,11 +954,10 @@ export default function DeviceDetails({
{activeTab === "backup" && (
<DeviceBackupTab
deviceId={device.id}
// 'unknown' (#5213) exists only for network-discovered rows, which
// never reach this agent-only detail page's backup tab in
// practice; DeviceBackupTab's status prop predates that value, so
// treat it as "not provided" rather than widening a backup-module
// type for a status it can never actually see.
// 'unknown' (#5213 network rows, #4622 manual assets) never reaches
// this agent-only detail page; DeviceBackupTab's status prop predates
// that value, so treat it as "not provided" rather than widening a
// backup-module type for a status it can never actually see.
deviceStatus={device.status === "unknown" ? undefined : device.status}
timezone={effectiveTimezone}
/>
Expand Down
Loading
Loading