Connected Assets Developer Guide - edmozley/freeitsm GitHub Wiki
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.
| 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 |
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.
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.
"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 atobserved_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).
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.
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.
-
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 simplyconnect(). -
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-tabis nownowrap/flex: 0 0 autoand.detail-tabsscrolls sideways, as it already did on a phone.
-
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_idis checked against the reported asset's company, so a key can only connect assets it can already reach.
- The inventory agent does not read monitor EDIDs (design decision 5).
- Merging two assets (part D) moves their connections, both ways - see Asset review - Developer Guide.