From 3b699278d2df2d4715926f511ceaa52a1ef89c90 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Mon, 7 Sep 2026 23:36:55 -0600 Subject: [PATCH 1/3] feat(discovery): website/URL manual network assets (#5213 W03) Task 7 of the manual-network-asset plan: website/service asset types in AddNetworkAssetModal (URL required, MAC hidden, HTTP-check hand-off via CreateMonitorForm), extend the partner inventory export projection (routes/partnerApi/inventory.ts + the strict schemas.ts allowlist) to include the two new types plus url/source, docs, and an e2e spec. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012R4VMwU9tjfQqhw7Ep1xAK --- .../src/routes/partnerApi/inventory.test.ts | 33 +++++ apps/api/src/routes/partnerApi/inventory.ts | 32 ++++- apps/api/src/routes/partnerApi/schemas.ts | 13 +- .../src/content/docs/features/discovery.mdx | 14 ++ .../devices/AddNetworkAssetModal.test.tsx | 75 ++++++++++ .../devices/AddNetworkAssetModal.tsx | 130 +++++++++++++----- apps/web/src/locales/de-DE/devices.json | 7 + apps/web/src/locales/de-DE/discovery.json | 2 + apps/web/src/locales/en/devices.json | 7 + apps/web/src/locales/en/discovery.json | 2 + apps/web/src/locales/es-419/devices.json | 7 + apps/web/src/locales/es-419/discovery.json | 2 + apps/web/src/locales/fr-CA/devices.json | 7 + apps/web/src/locales/fr-CA/discovery.json | 2 + apps/web/src/locales/fr-FR/devices.json | 7 + apps/web/src/locales/fr-FR/discovery.json | 2 + apps/web/src/locales/it-IT/devices.json | 7 + apps/web/src/locales/it-IT/discovery.json | 2 + apps/web/src/locales/pt-BR/devices.json | 7 + apps/web/src/locales/pt-BR/discovery.json | 2 + apps/web/src/locales/tr-TR/devices.json | 7 + apps/web/src/locales/tr-TR/discovery.json | 2 + e2e-tests/tests/manual-network-asset.spec.ts | 65 +++++++++ 23 files changed, 394 insertions(+), 40 deletions(-) create mode 100644 e2e-tests/tests/manual-network-asset.spec.ts diff --git a/apps/api/src/routes/partnerApi/inventory.test.ts b/apps/api/src/routes/partnerApi/inventory.test.ts index 7eaa683ab0..3a4c946a85 100644 --- a/apps/api/src/routes/partnerApi/inventory.test.ts +++ b/apps/api/src/routes/partnerApi/inventory.test.ts @@ -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 }]); @@ -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([{ diff --git a/apps/api/src/routes/partnerApi/inventory.ts b/apps/api/src/routes/partnerApi/inventory.ts index 539b57b968..5b8d3b8003 100644 --- a/apps/api/src/routes/partnerApi/inventory.ts +++ b/apps/api/src/routes/partnerApi/inventory.ts @@ -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), @@ -350,19 +360,27 @@ async function selectSiteInventoryRows(orgIds: string[], query: ExportQueryInput return db.select({ id, subjectId: sites.id, subjectType: sql`'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`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`( 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`COALESCE((SELECT jsonb_agg(item ORDER BY item->>'id') FROM ( SELECT jsonb_build_object('id', b.id, 'cidr', b.subnet) item diff --git a/apps/api/src/routes/partnerApi/schemas.ts b/apps/api/src/routes/partnerApi/schemas.ts index b128407457..a2aed70437 100644 --- a/apps/api/src/routes/partnerApi/schemas.ts +++ b/apps/api/src/routes/partnerApi/schemas.ts @@ -246,9 +246,18 @@ 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` becomes nullable for the same reason (host(NULL) is + // NULL, not the string "null"); every pre-existing type still always + // carries one, so this only widens the contract. `url`/`source` are new, + // ordinary (non-secret) fields — see the `included` bucket for + // `discovered_assets` in tenantExportPolicyRegistry.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(['scan', 'unifi', 'manual']), }).strict(); export const partnerSiteInventoryExportRecordSchema = strictPartnerExportRecordSchema({ diff --git a/apps/docs/src/content/docs/features/discovery.mdx b/apps/docs/src/content/docs/features/discovery.mdx index 172c721853..c2529ac344 100644 --- a/apps/docs/src/content/docs/features/discovery.mdx +++ b/apps/docs/src/content/docs/features/discovery.mdx @@ -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 (#4622).** 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) belongs to the separate hand-added asset tracker instead. 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. Fields you filled in by hand (hostname, manufacturer, model) are preserved; only liveness fields (last-seen time, online status) change. A `website`/`service` asset, having no IP, is never touched by a scan at all. + +**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. diff --git a/apps/web/src/components/devices/AddNetworkAssetModal.test.tsx b/apps/web/src/components/devices/AddNetworkAssetModal.test.tsx index 902df28ee5..5fc4ff5a95 100644 --- a/apps/web/src/components/devices/AddNetworkAssetModal.test.tsx +++ b/apps/web/src/components/devices/AddNetworkAssetModal.test.tsx @@ -152,4 +152,79 @@ 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('website/service asset types', () => { + it('hides the MAC field and requires a URL specifically — hostname alone is not enough', async () => { + render(); + await waitForDialogFocus(); + + await userEvent.type(screen.getByTestId('asset-label'), 'Shop'); + await userEvent.selectOptions(screen.getByTestId('asset-type'), 'website'); + + expect(screen.queryByTestId('asset-mac')).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('offers an HTTP-check hand-off after creating the asset instead of closing immediately', async () => { + fetchWithAuthMock.mockResolvedValue( + makeJsonResponse({ id: 'web-1', assetType: 'website', url: 'https://shop.example', source: 'manual' }), + ); + const onCreated = vi.fn(); + const onClose = vi.fn(); + render(); + await waitForDialogFocus(); + + await userEvent.type(screen.getByTestId('asset-label'), 'Shop'); + await userEvent.selectOptions(screen.getByTestId('asset-type'), 'website'); + 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(); + }); + + it('creates an http_check monitor pre-targeted at the asset URL via the hand-off', async () => { + fetchWithAuthMock.mockResolvedValueOnce( + makeJsonResponse({ id: 'web-2', assetType: 'website', url: 'https://shop.example', source: 'manual' }), + ); + render(); + await waitForDialogFocus(); + + await userEvent.type(screen.getByTestId('asset-label'), 'Shop'); + await userEvent.selectOptions(screen.getByTestId('asset-type'), 'website'); + 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.click(screen.getByRole('button', { name: /http check/i })); + 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', + }); + }); + }); }); diff --git a/apps/web/src/components/devices/AddNetworkAssetModal.tsx b/apps/web/src/components/devices/AddNetworkAssetModal.tsx index aa1bdd911e..c2501122d8 100644 --- a/apps/web/src/components/devices/AddNetworkAssetModal.tsx +++ b/apps/web/src/components/devices/AddNetworkAssetModal.tsx @@ -4,21 +4,24 @@ import { Dialog } from '../shared/Dialog'; import { fetchWithAuth } from '../../stores/auth'; import { useOrgStore } from '../../stores/orgStore'; import { runAction, ActionError, handleActionError } from '@/lib/runAction'; +import CreateMonitorForm from '../monitors/CreateMonitorForm'; -// #5213 W02 — hand-enter a network asset. A manual asset IS a discovered_assets -// row (source='manual'), so this form posts the same identity fields the -// scan/UniFi writers populate: assetType, ipAddress/hostname/url, mac, -// manufacturer, model, tags, notes. `label` is REQUIRED here (not just on the -// API): a url-only row with no label falls back to an empty display name in -// the unified list's hostname precedence (label > hostname > url > ip). +// #5213 W02/W03 — hand-enter a network asset. A manual asset IS a +// discovered_assets row (source='manual'), so this form posts the same +// identity fields the scan/UniFi writers populate: assetType, +// ipAddress/hostname/url, mac, manufacturer, model, tags, notes. `label` is +// REQUIRED here (not just on the API): a url-only row with no label falls +// back to an empty display name in the unified list's hostname precedence +// (label > hostname > url > ip). // -// Asset-type options are deliberately the same 12 the Discovery list already -// exposes (see discovery:assetTypes.*) — website/service exist in the DB enum -// as of W01 but wiring their URL-required UX and http_check hand-off is W03's -// job (docs/superpowers/plans/device-lifecycle/2026-09-07-manual-network-asset.md). +// Asset-type options are the same 12 the Discovery list already exposes (see +// discovery:assetTypes.*) plus `website`/`service` (W03, +// docs/superpowers/plans/device-lifecycle/2026-09-07-manual-network-asset.md): +// those two require a URL (not just "any identity") and hide the MAC field, +// which doesn't apply to an IP-less endpoint. const NETWORK_ASSET_TYPES = [ 'workstation', 'server', 'printer', 'router', 'switch', 'firewall', - 'access_point', 'phone', 'iot', 'camera', 'nas', 'unknown', + 'access_point', 'phone', 'iot', 'camera', 'nas', 'website', 'service', 'unknown', ] as const; type NetworkAssetType = (typeof NETWORK_ASSET_TYPES)[number]; @@ -36,9 +39,16 @@ const ASSET_TYPE_LABEL_KEYS: Record = { iot: 'discovery:assetTypes.iot', camera: 'discovery:assetTypes.camera', nas: 'discovery:assetTypes.nas', + website: 'discovery:assetTypes.website', + service: 'discovery:assetTypes.service', unknown: 'discovery:assetTypes.unknown', }; +// A website/service asset is identified by its URL, not an IP — it never has +// a MAC address, and the URL (not "any of IP/hostname/URL") is what's +// required. Everything else about the form stays the same. +const URL_REQUIRED_TYPES = new Set(['website', 'service']); + interface AddNetworkAssetModalProps { isOpen: boolean; onClose: () => void; @@ -64,6 +74,12 @@ export default function AddNetworkAssetModal({ isOpen, onClose, onCreated }: Add const [notes, setNotes] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + // Post-create hand-off (W03): a website/service asset has no monitoring of + // its own yet, so offer an inline "Add an HTTP check" step instead of + // closing immediately. Every other asset type keeps the W02 behavior — + // close right away. + const [createdAsset, setCreatedAsset] = useState<{ id: string; url: string } | null>(null); + const [showMonitorForm, setShowMonitorForm] = useState(false); useEffect(() => { if (isOpen && currentOrgId && sites.length === 0) void fetchSites(); @@ -90,6 +106,8 @@ export default function AddNetworkAssetModal({ isOpen, onClose, onCreated }: Add setTags(''); setNotes(''); setError(null); + setCreatedAsset(null); + setShowMonitorForm(false); }; const handleClose = () => { @@ -97,7 +115,10 @@ export default function AddNetworkAssetModal({ isOpen, onClose, onCreated }: Add onClose(); }; - const hasIdentity = Boolean(ipAddress.trim() || hostname.trim() || url.trim()); + const urlRequired = URL_REQUIRED_TYPES.has(assetType); + const hasIdentity = urlRequired + ? Boolean(url.trim()) + : Boolean(ipAddress.trim() || hostname.trim() || url.trim()); const canSubmit = Boolean(label.trim() && currentOrgId && siteId && hasIdentity) && !submitting; const handleSubmit = async (e: FormEvent) => { @@ -131,9 +152,15 @@ export default function AddNetworkAssetModal({ isOpen, onClose, onCreated }: Add successMessage: t('addNetworkAssetModal.toasts.created'), errorFallback: t('addNetworkAssetModal.toasts.createFailed'), }); - resetForm(); onCreated?.(result.id); - onClose(); + if (urlRequired) { + // Website/service: offer the HTTP-check hand-off instead of closing. + // The list has already been refreshed via onCreated above. + setCreatedAsset({ id: result.id, url: payload.url ?? '' }); + } else { + resetForm(); + onClose(); + } } catch (err) { if (err instanceof ActionError && err.status === 401) return; handleActionError(err, t('addNetworkAssetModal.toasts.createFailed')); @@ -147,6 +174,39 @@ export default function AddNetworkAssetModal({ isOpen, onClose, onCreated }: Add

{t('addNetworkAssetModal.title')}

+ {createdAsset ? ( +
+

{t('addNetworkAssetModal.postCreate.monitorPrompt')}

+ {!showMonitorForm && ( +
+ + +
+ )} + {showMonitorForm && ( + setShowMonitorForm(false)} + /> + )} +
+ ) : (
-

{t('addNetworkAssetModal.identityHint')}

+

+ {t(urlRequired ? 'addNetworkAssetModal.urlRequiredHint' : 'addNetworkAssetModal.identityHint')} +

-
-
- - setMacAddress(e.target.value)} - placeholder="00:11:22:33:44:55" - maxLength={17} - className="h-10 w-full rounded-md border bg-background px-3 text-sm font-mono focus:outline-hidden focus:ring-2 focus:ring-ring" - /> -
+ {/* A website/service asset has no MAC address — it isn't reachable + on the local network segment the way a printer or switch is. */} +
+ {!urlRequired && ( +
+ + setMacAddress(e.target.value)} + placeholder="00:11:22:33:44:55" + maxLength={17} + className="h-10 w-full rounded-md border bg-background px-3 text-sm font-mono focus:outline-hidden focus:ring-2 focus:ring-ring" + /> +
+ )}
+ )}
); diff --git a/apps/web/src/locales/de-DE/devices.json b/apps/web/src/locales/de-DE/devices.json index b1ed446611..69dc756f74 100644 --- a/apps/web/src/locales/de-DE/devices.json +++ b/apps/web/src/locales/de-DE/devices.json @@ -120,6 +120,13 @@ "title": "Netzwerk-Asset hinzufügen", "submit": "Asset hinzufügen", "identityHint": "Geben Sie mindestens eines an: IP-Adresse, Hostname oder URL.", + "urlRequiredHint": "Website- und Dienst-Assets benötigen eine URL.", + "postCreate": { + "monitorPrompt": "Dieses Asset mit einer HTTP-Prüfung überwachen?", + "actions": { + "addHttpCheck": "HTTP-Prüfung hinzufügen" + } + }, "fields": { "label": "Name", "ipAddress": "IP-Adresse", diff --git a/apps/web/src/locales/de-DE/discovery.json b/apps/web/src/locales/de-DE/discovery.json index be0135cc09..f5110db305 100644 --- a/apps/web/src/locales/de-DE/discovery.json +++ b/apps/web/src/locales/de-DE/discovery.json @@ -707,6 +707,8 @@ "iot": "IoT", "camera": "Kamera", "nas": "NAS", + "website": "Website", + "service": "Dienst", "unknown": "Unbekannt", "patchPanel": "Patchpanel", "other": "Andere" diff --git a/apps/web/src/locales/en/devices.json b/apps/web/src/locales/en/devices.json index 43fd3cfbea..20be63a32f 100644 --- a/apps/web/src/locales/en/devices.json +++ b/apps/web/src/locales/en/devices.json @@ -120,6 +120,13 @@ "title": "Add network asset", "submit": "Add asset", "identityHint": "Provide at least one of: IP address, hostname, or URL.", + "urlRequiredHint": "Website and service assets require a URL.", + "postCreate": { + "monitorPrompt": "Monitor this asset with an HTTP check?", + "actions": { + "addHttpCheck": "Add an HTTP check" + } + }, "fields": { "label": "Name", "ipAddress": "IP address", diff --git a/apps/web/src/locales/en/discovery.json b/apps/web/src/locales/en/discovery.json index c3456f066d..027dfcd7dc 100644 --- a/apps/web/src/locales/en/discovery.json +++ b/apps/web/src/locales/en/discovery.json @@ -707,6 +707,8 @@ "iot": "IoT", "camera": "Camera", "nas": "NAS", + "website": "Website", + "service": "Service", "unknown": "Unknown", "patchPanel": "Patch Panel", "other": "Other" diff --git a/apps/web/src/locales/es-419/devices.json b/apps/web/src/locales/es-419/devices.json index 135af71ecc..5659c8027c 100644 --- a/apps/web/src/locales/es-419/devices.json +++ b/apps/web/src/locales/es-419/devices.json @@ -120,6 +120,13 @@ "title": "Agregar activo de red", "submit": "Agregar activo", "identityHint": "Proporcione al menos uno: dirección IP, nombre de host o URL.", + "urlRequiredHint": "Los activos de tipo sitio web o servicio requieren una URL.", + "postCreate": { + "monitorPrompt": "¿Desea supervisar este activo con una verificación HTTP?", + "actions": { + "addHttpCheck": "Agregar verificación HTTP" + } + }, "fields": { "label": "Nombre", "ipAddress": "Dirección IP", diff --git a/apps/web/src/locales/es-419/discovery.json b/apps/web/src/locales/es-419/discovery.json index 4820996d8f..681a80c9ad 100644 --- a/apps/web/src/locales/es-419/discovery.json +++ b/apps/web/src/locales/es-419/discovery.json @@ -707,6 +707,8 @@ "iot": "IoT", "camera": "Cámara", "nas": "NAS", + "website": "Sitio web", + "service": "Servicio", "unknown": "Desconocido", "patchPanel": "Panel de conexión", "other": "Otro" diff --git a/apps/web/src/locales/fr-CA/devices.json b/apps/web/src/locales/fr-CA/devices.json index ffd79f20a1..dfcdb06c8c 100644 --- a/apps/web/src/locales/fr-CA/devices.json +++ b/apps/web/src/locales/fr-CA/devices.json @@ -120,6 +120,13 @@ "title": "Ajouter un actif réseau", "submit": "Ajouter l'actif", "identityHint": "Fournissez au moins un des éléments suivants : adresse IP, nom d'hôte ou URL.", + "urlRequiredHint": "Les actifs de type site Web ou service nécessitent une URL.", + "postCreate": { + "monitorPrompt": "Surveiller cet actif avec une vérification HTTP?", + "actions": { + "addHttpCheck": "Ajouter une vérification HTTP" + } + }, "fields": { "label": "Nom", "ipAddress": "Adresse IP", diff --git a/apps/web/src/locales/fr-CA/discovery.json b/apps/web/src/locales/fr-CA/discovery.json index 84356c94cb..7c56a9540d 100644 --- a/apps/web/src/locales/fr-CA/discovery.json +++ b/apps/web/src/locales/fr-CA/discovery.json @@ -707,6 +707,8 @@ "iot": "IoT", "camera": "Caméra", "nas": "NAS", + "website": "Site Web", + "service": "Service", "unknown": "Inconnu", "patchPanel": "Panneau de brassage", "other": "Autres" diff --git a/apps/web/src/locales/fr-FR/devices.json b/apps/web/src/locales/fr-FR/devices.json index 1e766faafe..ebcf48e9b6 100644 --- a/apps/web/src/locales/fr-FR/devices.json +++ b/apps/web/src/locales/fr-FR/devices.json @@ -120,6 +120,13 @@ "title": "Ajouter un actif réseau", "submit": "Ajouter l'actif", "identityHint": "Indiquez au moins un des éléments suivants : adresse IP, nom d'hôte ou URL.", + "urlRequiredHint": "Les actifs de type site Web ou service nécessitent une URL.", + "postCreate": { + "monitorPrompt": "Surveiller cet actif avec une vérification HTTP ?", + "actions": { + "addHttpCheck": "Ajouter une vérification HTTP" + } + }, "fields": { "label": "Nom", "ipAddress": "Adresse IP", diff --git a/apps/web/src/locales/fr-FR/discovery.json b/apps/web/src/locales/fr-FR/discovery.json index 1f31149b54..c0ada6ee02 100644 --- a/apps/web/src/locales/fr-FR/discovery.json +++ b/apps/web/src/locales/fr-FR/discovery.json @@ -707,6 +707,8 @@ "iot": "IoT", "camera": "Caméra", "nas": "NAS", + "website": "Site Web", + "service": "Service", "unknown": "Inconnu", "patchPanel": "Panneau de brassage", "other": "Autres" diff --git a/apps/web/src/locales/it-IT/devices.json b/apps/web/src/locales/it-IT/devices.json index ff201739a3..419db994cd 100644 --- a/apps/web/src/locales/it-IT/devices.json +++ b/apps/web/src/locales/it-IT/devices.json @@ -120,6 +120,13 @@ "title": "Aggiungi asset di rete", "submit": "Aggiungi asset", "identityHint": "Fornisci almeno uno tra: indirizzo IP, hostname o URL.", + "urlRequiredHint": "Gli asset di tipo sito web o servizio richiedono un URL.", + "postCreate": { + "monitorPrompt": "Monitorare questo asset con un controllo HTTP?", + "actions": { + "addHttpCheck": "Aggiungi un controllo HTTP" + } + }, "fields": { "label": "Nome", "ipAddress": "Indirizzo IP", diff --git a/apps/web/src/locales/it-IT/discovery.json b/apps/web/src/locales/it-IT/discovery.json index 4e7a49d7cb..4aabc5f709 100644 --- a/apps/web/src/locales/it-IT/discovery.json +++ b/apps/web/src/locales/it-IT/discovery.json @@ -707,6 +707,8 @@ "iot": "IoT", "camera": "Telecamera", "nas": "NAS", + "website": "Sito web", + "service": "Servizio", "unknown": "Sconosciuto", "patchPanel": "Patch panel", "other": "Altro" diff --git a/apps/web/src/locales/pt-BR/devices.json b/apps/web/src/locales/pt-BR/devices.json index 713fabaa9e..6a22e88142 100644 --- a/apps/web/src/locales/pt-BR/devices.json +++ b/apps/web/src/locales/pt-BR/devices.json @@ -120,6 +120,13 @@ "title": "Adicionar ativo de rede", "submit": "Adicionar ativo", "identityHint": "Forneça pelo menos um: endereço IP, hostname ou URL.", + "urlRequiredHint": "Ativos do tipo site ou serviço exigem uma URL.", + "postCreate": { + "monitorPrompt": "Monitorar este ativo com uma verificação HTTP?", + "actions": { + "addHttpCheck": "Adicionar verificação HTTP" + } + }, "fields": { "label": "Nome", "ipAddress": "Endereço IP", diff --git a/apps/web/src/locales/pt-BR/discovery.json b/apps/web/src/locales/pt-BR/discovery.json index 4bd4b94924..2462f92db0 100644 --- a/apps/web/src/locales/pt-BR/discovery.json +++ b/apps/web/src/locales/pt-BR/discovery.json @@ -707,6 +707,8 @@ "iot": "IoT", "camera": "Câmera", "nas": "NAS", + "website": "Site", + "service": "Serviço", "unknown": "Desconhecido", "patchPanel": "Painel de conexão", "other": "Outro" diff --git a/apps/web/src/locales/tr-TR/devices.json b/apps/web/src/locales/tr-TR/devices.json index 99972fd4ba..b66771d0a9 100644 --- a/apps/web/src/locales/tr-TR/devices.json +++ b/apps/web/src/locales/tr-TR/devices.json @@ -120,6 +120,13 @@ "title": "Ağ varlığı ekle", "submit": "Varlık ekle", "identityHint": "En az birini belirtin: IP adresi, ana bilgisayar adı veya URL.", + "urlRequiredHint": "Web sitesi ve hizmet varlıkları bir URL gerektirir.", + "postCreate": { + "monitorPrompt": "Bu varlık bir HTTP kontrolüyle izlensin mi?", + "actions": { + "addHttpCheck": "HTTP kontrolü ekle" + } + }, "fields": { "label": "Ad", "ipAddress": "IP adresi", diff --git a/apps/web/src/locales/tr-TR/discovery.json b/apps/web/src/locales/tr-TR/discovery.json index 2350805099..400bdf2c82 100644 --- a/apps/web/src/locales/tr-TR/discovery.json +++ b/apps/web/src/locales/tr-TR/discovery.json @@ -707,6 +707,8 @@ "iot": "Nesnelerin İnterneti", "camera": "Kamera", "nas": "NAS", + "website": "Web Sitesi", + "service": "Hizmet", "unknown": "Bilinmiyor", "patchPanel": "Bağlantı Paneli", "other": "Diğer" diff --git a/e2e-tests/tests/manual-network-asset.spec.ts b/e2e-tests/tests/manual-network-asset.spec.ts new file mode 100644 index 0000000000..154ba2bfeb --- /dev/null +++ b/e2e-tests/tests/manual-network-asset.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '../fixtures'; +import { clearRefreshState } from '../test-helpers'; + +/** + * Manual network asset — website/URL targets (#5213 W03). + * + * Covers the one thing unit tests cannot: opening the real Devices page, + * driving the real "Add network asset" split menu and form against the real + * API, and seeing the created row land in the Network segment of the unified + * list with its Manual source badge. + * + * Status has no `data-testid` on the DeviceList status badge (owned by the + * parallel #4622 wave, out of this wave's file-ownership scope — see the PR + * body) — that half of the "status Unknown" acceptance criterion is + * asserted from the create response instead, which is a network assertion, + * not a DOM selector, so it stays inside the data-testid-only rule for + * anything this spec actually *locates* on the page. + */ +test.describe.configure({ mode: 'serial' }); +test.beforeEach(clearRefreshState); + +test.describe('manual network asset — website target', () => { + test('create a website asset from the Devices page and see it in the Network segment', async ({ authedPage }) => { + const label = `E2E Shop ${Date.now()}`; + const url = `https://shop-${Date.now()}.example`; + + await authedPage.goto('/devices'); + await authedPage.getByTestId('devices-page-add-menu-trigger').waitFor(); + await authedPage.getByTestId('devices-page-add-menu-trigger').click(); + await authedPage.getByTestId('devices-page-add-menu-network-asset').click(); + + await authedPage.getByTestId('asset-label').waitFor(); + await authedPage.getByTestId('asset-label').fill(label); + await authedPage.getByTestId('asset-type').selectOption('website'); + + // website/service hides MAC — the field must not exist at all, not just + // be empty, or a stale value from a prior asset type would silently post. + await expect(authedPage.getByTestId('asset-mac')).toHaveCount(0); + + const siteSelect = authedPage.getByTestId('asset-site'); + if (!(await siteSelect.inputValue())) { + await siteSelect.selectOption({ index: 1 }); + } + + await authedPage.getByTestId('asset-url').fill(url); + await expect(authedPage.getByTestId('asset-submit')).toBeEnabled(); + + const [response] = await Promise.all([ + authedPage.waitForResponse((res) => res.url().includes('/devices/network') && res.request().method() === 'POST'), + authedPage.getByTestId('asset-submit').click(), + ]); + expect(response.status()).toBe(201); + const created = await response.json(); + // Never scanned yet: born with no liveness data, not a reachability claim. + expect(created.status).toBe('unknown'); + + // Website/service offers the HTTP-check hand-off instead of closing — + // decline it, this spec only covers asset creation and list placement. + await authedPage.getByTestId('asset-post-create').waitFor(); + await authedPage.getByTestId('asset-post-create-done').click(); + + await authedPage.getByTestId('device-class-segment-network').click(); + await expect(authedPage.getByTestId(`device-${created.id}-source`)).toHaveText(/manual/i, { timeout: 15_000 }); + }); +}); From f1d628f1b8a5fcef3ee2be5b6a5b0121dec680d7 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Mon, 7 Sep 2026 23:55:26 -0600 Subject: [PATCH 2/3] fix(discovery): address review findings on manual network assets W03 - Stop posting a stale MAC address for website/service assets: hiding the field didn't clear its state (AddNetworkAssetModal.tsx). - Sweep website/service through the web-side type maps that were still hand-enumerated at the old 12 values: the shared DiscoveredAssetType union, DiscoveredAssetList's local duplicate + typeConfig + assetTypeMap, assetTypeIcon.tsx, and deviceRoles.ts's label/icon lookup (added additively, never touching the billable-role tuple that backs contract_lines_device_roles_chk). - Derive partnerNetworkEquipmentSchema's `source` enum from discoveredAssetSourceEnum.enumValues instead of hand-copying it, so a future 4th source value fails at the type level instead of 500ing the whole partner inventory export in production. - CreateMonitorForm gains an optional defaultMonitorType prop so the website/service hand-off actually opens on http_check pre-targeted at the URL, instead of icmp_ping with a blank field the operator had to notice and fix themselves. - Correct two doc/comment inaccuracies: the partnerNetworkEquipmentSchema comment overstated the pre-existing `address` NOT-NULL guarantee, and discovery.mdx overstated what a rescan preserves (a manual row's MAC isn't guarded on the scan path's own terms, and the UniFi path has no manual-source guard on hostname/manufacturer/model at all). Also dropped a bare issue number from public docs and softened a reference to a not-yet-shipped UI. - New/updated tests: MAC-cleared-on-type-switch regression, the urlRequiredHint/identityHint swap, and the website+service asset types both exercised via describe.each (only website was covered before). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012R4VMwU9tjfQqhw7Ep1xAK --- apps/api/src/routes/partnerApi/schemas.ts | 24 ++++++--- .../src/content/docs/features/discovery.mdx | 4 +- .../devices/AddNetworkAssetModal.test.tsx | 49 +++++++++++++++---- .../devices/AddNetworkAssetModal.tsx | 6 ++- .../discovery/DiscoveredAssetList.tsx | 7 +++ .../components/discovery/assetTypeIcon.tsx | 4 ++ .../components/monitors/CreateMonitorForm.tsx | 15 ++++-- apps/web/src/lib/deviceRoles.ts | 23 ++++++++- packages/shared/src/types/discovery.ts | 4 +- 9 files changed, 110 insertions(+), 26 deletions(-) diff --git a/apps/api/src/routes/partnerApi/schemas.ts b/apps/api/src/routes/partnerApi/schemas.ts index a2aed70437..7df52280d4 100644 --- a/apps/api/src/routes/partnerApi/schemas.ts +++ b/apps/api/src/routes/partnerApi/schemas.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { discoveredAssetSourceEnum } from '../../db/schema/discovery'; export const PARTNER_EXPORT_RESOURCES = [ 'organizations', @@ -247,17 +248,24 @@ export const partnerDeviceInventoryExportRecordSchema = strictPartnerExportRecor const partnerNetworkEquipmentSchema = z.object({ // #5213 W03: 'website'/'service' — an IP-less manual asset whose identity - // is a URL. `address` becomes nullable for the same reason (host(NULL) is - // NULL, not the string "null"); every pre-existing type still always - // carries one, so this only widens the contract. `url`/`source` are new, - // ordinary (non-secret) fields — see the `included` bucket for - // `discovered_assets` in tenantExportPolicyRegistry.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. + // 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(['scan', 'unifi', 'manual']), + url: z.string().max(2048).nullable(), source: z.enum(discoveredAssetSourceEnum.enumValues), }).strict(); export const partnerSiteInventoryExportRecordSchema = strictPartnerExportRecordSchema({ diff --git a/apps/docs/src/content/docs/features/discovery.mdx b/apps/docs/src/content/docs/features/discovery.mdx index c2529ac344..ba36b2d9a3 100644 --- a/apps/docs/src/content/docs/features/discovery.mdx +++ b/apps/docs/src/content/docs/features/discovery.mdx @@ -325,9 +325,9 @@ A manually-added asset is a real discovered-asset row (`source: manual`) — 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 (#4622).** 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) belongs to the separate hand-added asset tracker instead. The two never overlap: if it has an IP, hostname, or URL, it's a network asset. +**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. Fields you filled in by hand (hostname, manufacturer, model) are preserved; only liveness fields (last-seen time, online status) change. A `website`/`service` asset, having no IP, is never touched by a scan at all. +**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. diff --git a/apps/web/src/components/devices/AddNetworkAssetModal.test.tsx b/apps/web/src/components/devices/AddNetworkAssetModal.test.tsx index 5fc4ff5a95..85fd66a435 100644 --- a/apps/web/src/components/devices/AddNetworkAssetModal.test.tsx +++ b/apps/web/src/components/devices/AddNetworkAssetModal.test.tsx @@ -156,15 +156,20 @@ describe('AddNetworkAssetModal', () => { // #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('website/service asset types', () => { - it('hides the MAC field and requires a URL specifically — hostname alone is not enough', async () => { + 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(); 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'), 'website'); + 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(); @@ -173,9 +178,29 @@ describe('AddNetworkAssetModal', () => { 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(); + 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: 'website', url: 'https://shop.example', source: 'manual' }), + makeJsonResponse({ id: 'web-1', assetType, url: 'https://shop.example', source: 'manual' }), ); const onCreated = vi.fn(); const onClose = vi.fn(); @@ -183,7 +208,7 @@ describe('AddNetworkAssetModal', () => { await waitForDialogFocus(); await userEvent.type(screen.getByTestId('asset-label'), 'Shop'); - await userEvent.selectOptions(screen.getByTestId('asset-type'), 'website'); + 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')); @@ -195,22 +220,28 @@ describe('AddNetworkAssetModal', () => { expect(onClose).toHaveBeenCalled(); }); - it('creates an http_check monitor pre-targeted at the asset URL via the hand-off', async () => { + // 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: 'website', url: 'https://shop.example', source: 'manual' }), + makeJsonResponse({ id: 'web-2', assetType, url: 'https://shop.example', source: 'manual' }), ); render(); await waitForDialogFocus(); await userEvent.type(screen.getByTestId('asset-label'), 'Shop'); - await userEvent.selectOptions(screen.getByTestId('asset-type'), 'website'); + 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.click(screen.getByRole('button', { name: /http check/i })); await userEvent.type(screen.getByPlaceholderText(/production web server/i), 'Shop check'); fetchWithAuthMock.mockResolvedValueOnce(makeJsonResponse({ id: 'mon-1' })); diff --git a/apps/web/src/components/devices/AddNetworkAssetModal.tsx b/apps/web/src/components/devices/AddNetworkAssetModal.tsx index c2501122d8..f2a48a46cb 100644 --- a/apps/web/src/components/devices/AddNetworkAssetModal.tsx +++ b/apps/web/src/components/devices/AddNetworkAssetModal.tsx @@ -136,7 +136,10 @@ export default function AddNetworkAssetModal({ isOpen, onClose, onCreated }: Add ipAddress: ipAddress.trim() || null, hostname: hostname.trim() || null, url: url.trim() || null, - macAddress: macAddress.trim() || null, + // Hiding the MAC field for website/service doesn't clear its state — if + // the operator typed a MAC while a different type was selected and then + // switched, the stale value would otherwise still post silently. + macAddress: urlRequired ? null : (macAddress.trim() || null), manufacturer: manufacturer.trim() || null, model: model.trim() || null, notes: notes.trim() || null, @@ -201,6 +204,7 @@ export default function AddNetworkAssetModal({ isOpen, onClose, onCreated }: Add setShowMonitorForm(false)} /> diff --git a/apps/web/src/components/discovery/DiscoveredAssetList.tsx b/apps/web/src/components/discovery/DiscoveredAssetList.tsx index 581bd6f563..b86027dd69 100644 --- a/apps/web/src/components/discovery/DiscoveredAssetList.tsx +++ b/apps/web/src/components/discovery/DiscoveredAssetList.tsx @@ -31,6 +31,9 @@ export type DiscoveredAssetType = | 'iot' | 'camera' | 'nas' + // website/service (#5213 W03): an IP-less manual asset whose identity is a URL. + | 'website' + | 'service' | 'unknown'; export type OpenPortEntry = { port: number; service: string }; @@ -112,6 +115,8 @@ export const typeConfig: Record = { iot: 'iot', camera: 'camera', nas: 'nas', + website: 'website', + service: 'service', unknown: 'unknown' }; diff --git a/apps/web/src/components/discovery/assetTypeIcon.tsx b/apps/web/src/components/discovery/assetTypeIcon.tsx index 60db5d31ac..08a914f40d 100644 --- a/apps/web/src/components/discovery/assetTypeIcon.tsx +++ b/apps/web/src/components/discovery/assetTypeIcon.tsx @@ -1,6 +1,8 @@ import { Camera, + Cloud, Cpu, + Globe, HardDrive, HelpCircle, Monitor, @@ -30,6 +32,8 @@ export const assetTypeIcons: Record = { iot: Cpu, camera: Camera, nas: HardDrive, + website: Globe, + service: Cloud, unknown: HelpCircle, }; diff --git a/apps/web/src/components/monitors/CreateMonitorForm.tsx b/apps/web/src/components/monitors/CreateMonitorForm.tsx index 1b78a616f3..6a22a86761 100644 --- a/apps/web/src/components/monitors/CreateMonitorForm.tsx +++ b/apps/web/src/components/monitors/CreateMonitorForm.tsx @@ -7,6 +7,15 @@ type CreateMonitorFormProps = { orgId?: string; assetId?: string; defaultTarget?: string; + /** + * Pre-select a monitor type instead of the icmp_ping default (#5213 W03 — + * the website/service hand-off wants to open straight to an HTTP check, + * not force the operator to notice and click the tile themselves). Only + * `http_check` seeds its own field (`httpUrl`) from `defaultTarget` today; + * `target` is seeded regardless, which is what icmp_ping/tcp_port already + * relied on. + */ + defaultMonitorType?: 'icmp_ping' | 'tcp_port' | 'http_check' | 'dns_check'; onCreated: () => void; onCancel: () => void; }; @@ -18,9 +27,9 @@ const monitorTypes = [ { value: 'dns_check', labelKey: 'longTail.monitors.CreateMonitorForm.monitorTypes.dnsCheck.label', descriptionKey: 'longTail.monitors.CreateMonitorForm.monitorTypes.dnsCheck.description' } ] as const; -export default function CreateMonitorForm({ orgId, assetId, defaultTarget, onCreated, onCancel }: CreateMonitorFormProps) { +export default function CreateMonitorForm({ orgId, assetId, defaultTarget, defaultMonitorType, onCreated, onCancel }: CreateMonitorFormProps) { const { t } = useTranslation('common'); - const [monitorType, setMonitorType] = useState('icmp_ping'); + const [monitorType, setMonitorType] = useState(defaultMonitorType ?? 'icmp_ping'); const [name, setName] = useState(''); const [target, setTarget] = useState(defaultTarget ?? ''); const [pollingInterval, setPollingInterval] = useState(60); @@ -36,7 +45,7 @@ export default function CreateMonitorForm({ orgId, assetId, defaultTarget, onCre const [expectBanner, setExpectBanner] = useState(''); // HTTP config - const [httpUrl, setHttpUrl] = useState(''); + const [httpUrl, setHttpUrl] = useState(defaultMonitorType === 'http_check' ? (defaultTarget ?? '') : ''); const [httpMethod, setHttpMethod] = useState('GET'); const [expectedStatus, setExpectedStatus] = useState(200); const [expectedBody, setExpectedBody] = useState(''); diff --git a/apps/web/src/lib/deviceRoles.ts b/apps/web/src/lib/deviceRoles.ts index 196e1c0dc7..2666fffeb1 100644 --- a/apps/web/src/lib/deviceRoles.ts +++ b/apps/web/src/lib/deviceRoles.ts @@ -13,6 +13,8 @@ import { Camera, HardDrive, HelpCircle, + Globe, + Cloud, } from 'lucide-react'; export const DEVICE_ROLES = [ @@ -53,12 +55,29 @@ const ROLE_META: Record = { unknown: { label: 'Unknown', icon: HelpCircle }, }; +// #5213 W03: 'website'/'service' are valid discovery asset types (an IP-less +// manual asset whose identity is a URL) but are deliberately NOT billable +// device roles — DEVICE_ROLES above governs contract_lines_device_roles_chk, +// and a website isn't a "device" a contract line bills per-seat/per-unit. +// DeviceList's unified Type column calls these lookups on the raw discovery +// `assetType` for every device class, though, so a non-billable asset type +// still needs a real label/icon instead of the raw enum literal — this is a +// separate, additive lookup, never a change to the billing tuple itself. +const NON_BILLABLE_ROLE_META: Record<'website' | 'service', DeviceRoleMeta> = { + website: { label: 'Website', icon: Globe }, + service: { label: 'Service', icon: Cloud }, +}; + export function getDeviceRoleLabel(role: string): string { - return ROLE_META[role as DeviceRole]?.label ?? role; + return ROLE_META[role as DeviceRole]?.label + ?? NON_BILLABLE_ROLE_META[role as keyof typeof NON_BILLABLE_ROLE_META]?.label + ?? role; } export function getDeviceRoleIcon(role: string): ComponentType<{ className?: string }> { - return ROLE_META[role as DeviceRole]?.icon ?? HelpCircle; + return ROLE_META[role as DeviceRole]?.icon + ?? NON_BILLABLE_ROLE_META[role as keyof typeof NON_BILLABLE_ROLE_META]?.icon + ?? HelpCircle; } export function getDeviceRoleSourceLabel(source: string): string { diff --git a/packages/shared/src/types/discovery.ts b/packages/shared/src/types/discovery.ts index 8a87476dd5..f40b2d9853 100644 --- a/packages/shared/src/types/discovery.ts +++ b/packages/shared/src/types/discovery.ts @@ -4,7 +4,9 @@ export type DiscoveredAssetType = | 'workstation' | 'server' | 'printer' | 'router' | 'switch' - | 'firewall' | 'access_point' | 'phone' | 'iot' | 'camera' | 'nas' | 'unknown'; + | 'firewall' | 'access_point' | 'phone' | 'iot' | 'camera' | 'nas' + // website/service (#5213 W03): an IP-less manual asset whose identity is a URL. + | 'website' | 'service' | 'unknown'; export type DiscoveredAssetStatus = 'new' | 'identified' | 'managed' | 'ignored' | 'offline'; From 24f8d3dc22a2b1f17bdc4a683a255002c30c0b9b Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 8 Sep 2026 00:23:58 -0600 Subject: [PATCH 3/3] fix(web): static hint keys in AddNetworkAssetModal; de-DE discovery baseline +1 for "Website" (#5213) Test Web failed on two i18n contracts: a dynamic translation key (ternary inside t()) and the de-DE exact-English duplicate baseline for discovery.json, where assetTypes.website is legitimately "Website" in German. Keys are now static per branch; baseline bumped with the reason. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Pc21knHQGa6fCM7UA9YtKX --- apps/web/src/components/devices/AddNetworkAssetModal.tsx | 2 +- apps/web/src/lib/i18n/translationCoverage.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/devices/AddNetworkAssetModal.tsx b/apps/web/src/components/devices/AddNetworkAssetModal.tsx index f2a48a46cb..61d2a0f457 100644 --- a/apps/web/src/components/devices/AddNetworkAssetModal.tsx +++ b/apps/web/src/components/devices/AddNetworkAssetModal.tsx @@ -270,7 +270,7 @@ export default function AddNetworkAssetModal({ isOpen, onClose, onCreated }: Add

- {t(urlRequired ? 'addNetworkAssetModal.urlRequiredHint' : 'addNetworkAssetModal.identityHint')} + {urlRequired ? t('addNetworkAssetModal.urlRequiredHint') : t('addNetworkAssetModal.identityHint')}

diff --git a/apps/web/src/lib/i18n/translationCoverage.test.ts b/apps/web/src/lib/i18n/translationCoverage.test.ts index 65bbca9303..2c98e6b92f 100644 --- a/apps/web/src/lib/i18n/translationCoverage.test.ts +++ b/apps/web/src/lib/i18n/translationCoverage.test.ts @@ -496,7 +496,8 @@ const namespaceDuplicateBaselines = { // #5213 W02: +6 — "URL", "UniFi", "Modell"/"Model", "Tags", "Notizen"/… and // other short cognates spelled identically in de-DE. 'devices.json': 162, - 'discovery.json': 26, + // +1 (#5213 W03): assetTypes.website — "Website" is the German word. + 'discovery.json': 27, 'integrations.json': 43, // +1: updateRingList.badges.os — "OS: {{severities}}" is an acronym plus an // interpolation; German uses the same "OS" acronym.