Skip to content

Connected Assets Developer Guide

Ed Mozley edited this page Oct 23, 2026 · 2 revisions

πŸ› οΈ Connected assets - Developer Guide

How connected assets (#200, part C) are built. For what they do, see Connected assets. For why they are shaped this way, see the design.

In one paragraph: a connection is a row in asset_connections saying that one asset is physically attached to another, according to one source: a person (manual) or a REST tool (rest:<name>). AssetConnectionsService is the only writer. People connect and disconnect through two small endpoints; tools report through a connections section of POST /assets/{id}/inventory, which AssetInventoryService::submit() hands to AssetConnectionsService::applyObserved(). A serial that matches no asset, or several, goes to asset_review_items for part D instead of being guessed.


1. πŸ“ The files

File What it does
includes/services/asset_connections.php AssetConnectionsService: listFor(), connect(), disconnect(), applyObserved(); internally close(), history()
includes/services/asset_review.php AssetReviewService::record() / settle() - where an unmatched or ambiguous peripheral is kept (part D builds the list on top)
includes/services/asset_inventory.php Accepts the connections section (REST_EXTRA), validates its items in item(), and unlink() removes the source's connection rows
api/assets/get_asset_connections.php, connect_asset.php, disconnect_asset.php The page's three endpoints
api/v1/resources/assets.php, lib/routes.php, lib/openapi_schemas.php, spec.json GET /assets/{id}/connections; the connections section in AssetInventorySnapshot; InventoryConnectionItem, AssetConnections
includes/services/asset_type_layout.php connected added to TABS, so a type's layout can hide it
asset-management/index.php The Connected tab and panel, loadConnections(), renderConnections(), the picker (openConnectPicker(), searchConnectable()), connectAsset(), disconnectAsset(); conn-* styles; connection in the history labels
includes/analyst_lifecycle.php confirmed_by_id, disconnected_by_id (and asset_review_items.resolved_by_id) registered as history
tests/asset-connections.php 27 checks, through the real endpoints and the REST API

2. πŸ—„οΈ One row per source

asset_connections
  asset_id            the side that reported it: the asset a REST report was about, or the one a person was on
  connected_asset_id  the other side
  source              'manual' | 'rest:<name>'
  connected_at        when it began (a person: now; a tool: its first observation)
  first_observed_at   a tool's first and latest sighting - NULL for a person
  last_observed_at
  disconnected_at     NULL = connected
  confirmed_by_id     the analyst, for a person's row
  disconnected_by_id  the analyst who ended a person's row
  FKs to assets ON DELETE CASCADE, both columns

Why one row per source, not one row per pair. The design's rule is that no source overwrites another, or a person. With one row per pair, a tool's report would have to update a row a person confirmed, or skip it and lose the observation. With one row per source, each writer only ever touches its own rows, and the page shows them side by side.

Direction is bookkeeping, not meaning. asset_id is just the side that recorded it. listFor() finds a row from either end (asset_id = ? OR connected_asset_id = ?) and returns the other asset, with reported_here saying which side recorded it.

A reconnection is a new row. An ended row keeps its dates; when the same monitor is seen again, a new row starts. That is the history.

TRAP: a source only closes its own rows

applyObserved() reads WHERE asset_id = ? AND source = ? AND disconnected_at IS NULL and closes only from that set. disconnect() refuses any row whose source is not manual. Widening either would let one writer end another's view, which is the thing the design forbids.


3. πŸ“‘ The connections section

"connections": {
  "complete": true,
  "items": [
    { "connected_asset_id": 512 },
    { "serial": "CN-0F7RDV-74445", "name": "Dell P2422H", "model": "P2422H" },
    { "serial": "3CM1234XYZ", "connected": false }
  ]
}

Each item names the other asset in exactly one way:

How it is matched If it cannot be
connected_asset_id Must exist and be in the reported asset's company (which the key could already reach) The section fails: 422 invalid_field
serial AssetsService::resolveAssetIdentity() - the same resolver as every inventory source, scoped to the company, never guessing between two assets Kept for review, listed in the section's unmatched; the rest of the section still applies

Then, per item:

  • connected (default) - updates this source's open row's last_observed_at, or opens a new row.
  • connected: false - ends this source's open row for that asset, even in a partial section.
  • complete: true - this source's other open rows on this asset end at observed_at. A partial section never ends anything.

Everything else about the report applies unchanged: ownership of the source name, the device link, stale and replayed observations, per-section savepoints and outcomes (Inventory freshness).

Unmatched and ambiguous peripherals

AssetReviewService::record($conn, $tenant, 'unmatched_peripheral' | 'ambiguous_match',
    'peripheral:<source>:<asset>:<serial>', $hostId, null, $stored, json_encode([...]));

match_key makes repeated sightings one case. When a later report matches the serial to exactly one asset, settle() marks the case resolved and the connection opens. A case a person has already decided stays decided. Part D's review list shows them.


4. 🧾 History

history() writes two asset_history rows per event, one on each asset, field connection:

Event old_value new_value
connected - MONITOR-01 (confirmed by a person) / (observed by lansweeper)
disconnected MONITOR-01 (...) -

The analyst is the person for manual rows and none (automatic) for a tool's. The history view labels the field through asset-management.field.connection.


5. πŸ–ΌοΈ The page

  • Grouped by the other asset. Each card lists every source's current row: a person's with Disconnect, a tool's as Last observed connected by X 3 hr ago, switching to a date once it is a week old (invAgoOrDate()) so an old observation reads as old. A card with no person's row offers Confirm, which is simply connect().
  • The picker reuses get_assets.php?search=, so it searches what the asset list searches (name, serial, tag, ...) in the active company, and the contract picker's styles.
  • The badge counts connected assets, not rows. The layout's badge observer reads it, so a type that hides Connected still says Hidden by this type's layout: Connected (2).
  • The tab strip stopped squeezing when Connected made nine tabs: .detail-tab is now nowrap / flex: 0 0 auto and .detail-tabs scrolls sideways, as it already did on a phone.

6. πŸ”’ Company and access

  • connect() locks both assets, checks both against $ctx->companyScope (one "not found" for missing and out of reach), and refuses two companies.
  • The page endpoints also check analystCanAccessAsset() on both assets, the same check every asset endpoint makes.
  • A REST connected_asset_id is checked against the reported asset's company, so a key can only connect assets it can already reach.

7. ⬜ Not done

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally