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
33 changes: 33 additions & 0 deletions apps/api/src/routes/partnerApi/inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ describe('partner reconstruction inventory exports', () => {
const printer = {
id: '76666666-6666-4666-8666-666666666667', type: 'printer', name: 'front-office-printer',
address: '10.0.0.25', macAddress: '00:aa:bb:cc:dd:ef', manufacturer: 'HP', model: 'LaserJet',
url: null, source: 'manual',
};
results.push([]);
results.push([{ ...siteInventoryRow(), networkEquipment: [printer], networkEquipmentCount: 1 }]);
Expand All @@ -203,6 +204,38 @@ describe('partner reconstruction inventory exports', () => {
expect((await response.json()).data[0].networkEquipment).toEqual([printer]);
});

// #5213 W03 — a website/service asset has no IP: `address` must come back
// null (not the string "null"), and the type filter + strict response
// schema must both know the two new asset types or the export fails closed.
it('exports approved website assets with a url, source, and a null address', async () => {
const website = {
id: '76666666-6666-4666-8666-666666666668', type: 'website', name: 'Shop',
address: null, macAddress: null, manufacturer: null, model: null,
url: 'https://shop.example', source: 'manual',
};
results.push([]);
results.push([{ ...siteInventoryRow(), networkEquipment: [website], networkEquipmentCount: 1 }]);
const response = await request('/partner-api/device-inventory');
expect(response.status).toBe(200);
expect((await response.json()).data[0].networkEquipment).toEqual([website]);
});

it('falls back to the url when a website asset has no label or hostname source value', async () => {
// The SQL projection already does COALESCE(label, hostname, url) for
// `name` — this asserts the response schema doesn't reject that value
// (a URL can be a long string, still under the 255-char name cap here).
const website = {
id: '76666666-6666-4666-8666-666666666669', type: 'service', name: 'https://api.internal.example',
address: null, macAddress: null, manufacturer: null, model: null,
url: 'https://api.internal.example', source: 'scan',
};
results.push([]);
results.push([{ ...siteInventoryRow(), networkEquipment: [website], networkEquipmentCount: 1 }]);
const response = await request('/partner-api/device-inventory');
expect(response.status).toBe(200);
expect((await response.json()).data[0].networkEquipment).toEqual([website]);
});

it('omits IP history whose current interface endpoint is missing', async () => {
const source = inventoryRow();
results.push([{
Expand Down
32 changes: 25 additions & 7 deletions apps/api/src/routes/partnerApi/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,16 +203,26 @@ function projectDeviceInventory(input: unknown) {
};
}

const DURABLE_EQUIPMENT_TYPES = new Set(['printer', 'router', 'switch', 'firewall', 'access_point', 'nas']);
// #5213 W03: 'website'/'service' joined the durable six — an IP-less manual
// asset whose identity is a URL rather than a physical network presence.
// Kept as one set (not "durable" + "virtual") because both feed the same
// `networkEquipment` projection and neither needs different limits/handling.
const NETWORK_EQUIPMENT_TYPES = new Set([
'printer', 'router', 'switch', 'firewall', 'access_point', 'nas', 'website', 'service',
]);

function projectSiteInventory(input: unknown) {
const row = object(input);
const networkEquipment = array(row.networkEquipment, PARTNER_INVENTORY_CHILD_LIMIT)
.filter((entry) => DURABLE_EQUIPMENT_TYPES.has(String(entry.type ?? entry.assetType)))
.filter((entry) => NETWORK_EQUIPMENT_TYPES.has(String(entry.type ?? entry.assetType)))
.map((entry) => ({
id: entry.id, type: entry.type ?? entry.assetType, name: nullableString(entry.name ?? entry.label ?? entry.hostname, 255),
address: String(entry.address ?? entry.ipAddress ?? '').slice(0, 45), macAddress: nullableString(entry.macAddress, 17),
// #5213: a website/service asset has no IP — host(NULL) comes back NULL
// from the SQL projection, not the string "null". nullableString maps
// both null and '' to null, so it does the right thing for every type.
address: nullableString(entry.address ?? entry.ipAddress, 45), macAddress: nullableString(entry.macAddress, 17),
manufacturer: nullableString(entry.manufacturer, 255), model: nullableString(entry.model, 255),
url: nullableString(entry.url, 2048), source: String(entry.source ?? 'scan'),
}));
const networkSegments = array(row.networkSegments, PARTNER_INVENTORY_CHILD_LIMIT).map((entry) => ({
id: entry.id, cidr: String(entry.cidr ?? entry.subnet ?? '').slice(0, 50),
Expand Down Expand Up @@ -350,19 +360,27 @@ async function selectSiteInventoryRows(orgIds: string[], query: ExportQueryInput
return db.select({
id, subjectId: sites.id, subjectType: sql<string>`'site'`, orgId: sites.orgId, siteId: sites.id,
createdAt: sites.createdAt, updatedAt,
// #5213 W03: 'website'/'service' added to the type filter, and 'url' +
// 'source' added to the projection — both are ordinary included fields
// per tenantExportPolicyRegistry.ts (W01), and partnerNetworkEquipmentSchema
// (schemas.ts) is the strict allowlist that must carry them too, or the
// response fails closed with a 500 on `.parse()`. 'address' now comes
// straight from host(a.ip_address), which is SQL NULL (not the string
// "null") for a website/service row with no IP.
networkEquipment: sql<unknown[]>`COALESCE((SELECT jsonb_agg(item ORDER BY item->>'id') FROM (
SELECT jsonb_build_object(
'id', a.id, 'type', a.asset_type, 'name', COALESCE(a.label, a.hostname), 'address', host(a.ip_address),
'macAddress', a.mac_address, 'manufacturer', a.manufacturer, 'model', a.model
'id', a.id, 'type', a.asset_type, 'name', COALESCE(a.label, a.hostname, a.url), 'address', host(a.ip_address),
'macAddress', a.mac_address, 'manufacturer', a.manufacturer, 'model', a.model,
'url', a.url, 'source', a.source
) item FROM ${discoveredAssets} a
WHERE a.site_id = ${sites.id} AND a.org_id = ${sites.orgId} AND a.approval_status = 'approved'
AND a.asset_type IN ('printer', 'router', 'switch', 'firewall', 'access_point', 'nas')
AND a.asset_type IN ('printer', 'router', 'switch', 'firewall', 'access_point', 'nas', 'website', 'service')
ORDER BY a.id LIMIT ${PARTNER_INVENTORY_CHILD_LIMIT}
) bounded), '[]'::jsonb)`,
networkEquipmentCount: sql<number>`(
SELECT COUNT(*)::integer FROM ${discoveredAssets} a
WHERE a.site_id = ${sites.id} AND a.org_id = ${sites.orgId} AND a.approval_status = 'approved'
AND a.asset_type IN ('printer', 'router', 'switch', 'firewall', 'access_point', 'nas')
AND a.asset_type IN ('printer', 'router', 'switch', 'firewall', 'access_point', 'nas', 'website', 'service')
)`,
networkSegments: sql<unknown[]>`COALESCE((SELECT jsonb_agg(item ORDER BY item->>'id') FROM (
SELECT jsonb_build_object('id', b.id, 'cidr', b.subnet) item
Expand Down
21 changes: 19 additions & 2 deletions apps/api/src/routes/partnerApi/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { discoveredAssetSourceEnum } from '../../db/schema/discovery';

export const PARTNER_EXPORT_RESOURCES = [
'organizations',
Expand Down Expand Up @@ -246,9 +247,25 @@ export const partnerDeviceInventoryExportRecordSchema = strictPartnerExportRecor
});

const partnerNetworkEquipmentSchema = z.object({
id: z.string().uuid(), type: z.enum(['printer', 'router', 'switch', 'firewall', 'access_point', 'nas']),
name: z.string().max(255).nullable(), address: z.string().min(1).max(45), macAddress: z.string().max(17).nullable(),
// #5213 W03: 'website'/'service' — an IP-less manual asset whose identity
// is a URL. `address` was already reachable as null even for a
// pre-existing type before this PR (a hand-entered printer/router/etc. only
// needs ONE of ip/hostname/url — see AddNetworkAssetModal's identity rule
// and discovered_assets_manual_identity_chk), it just wasn't declared
// `.nullable()` here yet; this only makes the schema match what the column
// has always allowed. `url`/`source` are new, ordinary (non-secret) fields
// — see the `included` bucket for `discovered_assets` in
// tenantExportPolicyRegistry.ts. `source`'s enum is DERIVED from the DB
// enum (not hand-copied) so a future 4th source value fails loudly at the
// type level instead of 500ing the whole export in production the moment
// one ships — same reasoning as DISCOVERED_ASSET_TYPES in
// routes/devices/schemas.ts. This schema is the strict allowlist
// projectSiteInventory's output is validated against, so a field missing
// here fails the whole export closed with a 500.
id: z.string().uuid(), type: z.enum(['printer', 'router', 'switch', 'firewall', 'access_point', 'nas', 'website', 'service']),
name: z.string().max(255).nullable(), address: z.string().min(1).max(45).nullable(), macAddress: z.string().max(17).nullable(),
manufacturer: z.string().max(255).nullable(), model: z.string().max(255).nullable(),
url: z.string().max(2048).nullable(), source: z.enum(discoveredAssetSourceEnum.enumValues),
}).strict();

export const partnerSiteInventoryExportRecordSchema = strictPartnerExportRecordSchema({
Expand Down
14 changes: 14 additions & 0 deletions apps/docs/src/content/docs/features/discovery.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,20 @@ Each asset in the response includes monitoring status flags:
| `monitoringEnabled` | `true` if either SNMP or network monitoring is active |
| `linkedDeviceName` | Display name or hostname of the linked enrolled device, if any |

### Adding a Network Asset Manually

Not every network asset can be discovered by a scan — a hosted website, a SaaS endpoint, or a device on a segment the agent can't reach still needs to show up alongside everything else in the unified **Devices** list. Use the **Add network asset** option from the Devices page's add-asset menu to hand-enter one.

A manually-added asset is a real discovered-asset row (`source: manual`) — it gets the same list presentation, monitors, alerts, and topology treatment as a scan-discovered one, it's just born without a first sighting (`status: unknown` until something actually reaches it).

**Identity.** Every asset needs at least one of: IP address, hostname, or URL. A `website` or `service` asset specifically requires a **URL** — those types have no stable IP (a hosted site's A record can change), so the URL is the identity, not a derived attribute.

**Manual vs. hand-added assets.** This is for assets with a *network identity* (IP, hostname, or URL) — printers, routers, websites, SaaS endpoints, anything reachable on the network or the internet. Equipment with no network identity (a UPS, a physical lock, a piece of furniture you just want tracked in inventory) is tracked separately as inventory, not a network asset. The two never overlap: if it has an IP, hostname, or URL, it's a network asset.

**A later scan updates it in place.** If a scan or the UniFi controller later finds the same IP, the existing row is updated — not duplicated — rather than creating a second, conflicting asset. A network scan never overwrites the hostname, manufacturer, or model you typed in on a manually-added row; MAC address and liveness fields (last-seen time, online status) always update from what the scan finds. A UniFi-adopted device that matches by MAC or IP is enriched the normal UniFi way and can update those same fields, since UniFi sync doesn't distinguish a manually-added row. A `website`/`service` asset, having no IP, is never touched by either path.

**Monitoring hand-off.** After creating a `website` or `service` asset, the form offers to add an HTTP check right away, pre-targeted at the URL you entered — see [Network Monitors](#network-monitors) below for what HTTP checks report. You can always add monitoring later from the asset's detail page instead.

### Network Device Detail Page

Clicking a network-discovered device in the unified **Devices** list opens a dedicated detail page (`/devices/network/:id`) instead of a popup. The page has breadcrumbs back to **Devices** and a working back button, so you can navigate in and out without losing your place.
Expand Down
106 changes: 106 additions & 0 deletions apps/web/src/components/devices/AddNetworkAssetModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,110 @@ describe('AddNetworkAssetModal', () => {
expect(body).not.toHaveProperty('source');
expect(body).not.toHaveProperty('approvalStatus');
});

// #5213 W03 — website/service targets. A URL is required outright (not
// just "any of IP/hostname/URL"), and MAC doesn't apply to an IP-less
// endpoint.
describe.each(['website', 'service'] as const)('%s asset type', (assetType) => {
it('hides the MAC field, swaps the identity hint, and requires a URL specifically — hostname alone is not enough', async () => {
render(<AddNetworkAssetModal isOpen onClose={vi.fn()} onCreated={vi.fn()} />);
await waitForDialogFocus();

// Default (non-url-required) type shows the "any of IP/hostname/URL" hint.
expect(screen.getByText(/provide at least one of/i)).toBeInTheDocument();

await userEvent.type(screen.getByTestId('asset-label'), 'Shop');
await userEvent.selectOptions(screen.getByTestId('asset-type'), assetType);

expect(screen.queryByTestId('asset-mac')).not.toBeInTheDocument();
expect(screen.getByText(/require a URL/i)).toBeInTheDocument();
expect(screen.queryByText(/provide at least one of/i)).not.toBeInTheDocument();

await userEvent.type(screen.getByTestId('asset-hostname'), 'shop.example');
expect(screen.getByTestId('asset-submit')).toBeDisabled();

await userEvent.type(screen.getByTestId('asset-url'), 'https://shop.example');
expect(screen.getByTestId('asset-submit')).not.toBeDisabled();
});

it('never posts a stale MAC address typed while a different asset type was selected', async () => {
fetchWithAuthMock.mockResolvedValue(makeJsonResponse({ id: 'a1' }));
render(<AddNetworkAssetModal isOpen onClose={vi.fn()} onCreated={vi.fn()} />);
await waitForDialogFocus();

await userEvent.type(screen.getByTestId('asset-label'), 'Shop');
// Type a MAC while the type still shows the field...
await userEvent.type(screen.getByTestId('asset-mac'), '00:11:22:33:44:55');
// ...then switch to a type that hides it. The field unmounts, but the
// React state behind it must not leak into the payload.
await userEvent.selectOptions(screen.getByTestId('asset-type'), assetType);
await userEvent.type(screen.getByTestId('asset-url'), 'https://shop.example');
await userEvent.click(screen.getByTestId('asset-submit'));

await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalled());
const call = fetchWithAuthMock.mock.calls[0]!;
const body = JSON.parse((call[1] as RequestInit).body as string);
expect(body.macAddress).toBeNull();
});

it('offers an HTTP-check hand-off after creating the asset instead of closing immediately', async () => {
fetchWithAuthMock.mockResolvedValue(
makeJsonResponse({ id: 'web-1', assetType, url: 'https://shop.example', source: 'manual' }),
);
const onCreated = vi.fn();
const onClose = vi.fn();
render(<AddNetworkAssetModal isOpen onClose={onClose} onCreated={onCreated} />);
await waitForDialogFocus();

await userEvent.type(screen.getByTestId('asset-label'), 'Shop');
await userEvent.selectOptions(screen.getByTestId('asset-type'), assetType);
await userEvent.type(screen.getByTestId('asset-url'), 'https://shop.example');
await userEvent.click(screen.getByTestId('asset-submit'));

await waitFor(() => expect(onCreated).toHaveBeenCalledWith('web-1'));
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByTestId('asset-post-create')).toBeInTheDocument();

await userEvent.click(screen.getByTestId('asset-post-create-done'));
expect(onClose).toHaveBeenCalled();
});

// The hand-off opens CreateMonitorForm pre-selected on http_check (via
// its `defaultMonitorType` prop) rather than the component's own
// icmp_ping default — this test never clicks the "HTTP Check" tile, so a
// regression back to the default type would fail it (either the
// name-field placeholder wouldn't be the one asserted below, since
// icmp_ping shows a different field set, or the submitted monitorType
// would be wrong).
it('creates an http_check monitor pre-targeted at the asset URL via the hand-off, with no extra clicks', async () => {
fetchWithAuthMock.mockResolvedValueOnce(
makeJsonResponse({ id: 'web-2', assetType, url: 'https://shop.example', source: 'manual' }),
);
render(<AddNetworkAssetModal isOpen onClose={vi.fn()} onCreated={vi.fn()} />);
await waitForDialogFocus();

await userEvent.type(screen.getByTestId('asset-label'), 'Shop');
await userEvent.selectOptions(screen.getByTestId('asset-type'), assetType);
await userEvent.type(screen.getByTestId('asset-url'), 'https://shop.example');
await userEvent.click(screen.getByTestId('asset-submit'));

await waitFor(() => expect(screen.getByTestId('asset-post-create-add-http-check')).toBeInTheDocument());
await userEvent.click(screen.getByTestId('asset-post-create-add-http-check'));

await userEvent.type(screen.getByPlaceholderText(/production web server/i), 'Shop check');

fetchWithAuthMock.mockResolvedValueOnce(makeJsonResponse({ id: 'mon-1' }));
await userEvent.click(screen.getByRole('button', { name: /create monitor/i }));

await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledWith(
'/monitors',
expect.objectContaining({ method: 'POST' }),
));
const monitorCall = fetchWithAuthMock.mock.calls.find(([url]) => url === '/monitors')!;
const monitorBody = JSON.parse((monitorCall[1] as RequestInit).body as string);
expect(monitorBody).toMatchObject({
assetId: 'web-2', monitorType: 'http_check', target: 'https://shop.example',
});
});
});
});
Loading
Loading