Repository navigation
Inventory Freshness Developer Guide
How inventory freshness (#200, part B) is built, and how the agent came to share one inventory implementation with the REST API (the #194 follow-up). For what it does, see Inventory freshness. For why it is shaped this way, see the design.
In one paragraph: every source that reports what it saw on a device - the agent, or a REST tool - goes through AssetInventoryService. It reconciles the source's rows in the inventory tables, and for each section records two things in asset_inventory_sections: the last good data (observed_at, fingerprint) and the last attempt (last_outcome, last_error, last_attempt_at). The asset page reads that back through get_asset_inventory_status.php and says, per source and section, how fresh each list is.
| File | What it does |
|---|---|
includes/services/asset_inventory.php |
AssetInventoryService: submit() (REST), agentReport() + agentState() (agent), provenance(), staleDays(), hasOutcomeColumns(); internally applySection(), replaceAgentOnly(), recordOutcome(), agentItems(), bindAgent()
|
api/external/system-info/submit/index.php |
The agent's main report. Matches the asset as before, then hands volumes, physical drives, adapters and software to agentReport()
|
api/external/device-manager/submit/index.php |
The agent's Device Manager report, through agentReport()
|
api/external/software-inventory/submit/index.php |
The older software-only agent, through agentReport()
|
scripts/Invoke-AssetInventory.ps1 |
The agent. Get-Section, $collectionFailures, report_version = 2
|
api/assets/get_asset_inventory_status.php |
What the asset page reads: provenance() + rows per source + the stale setting |
asset-management/index.php |
loadInventoryStatus(), renderInventoryLines(), renderInventoryPanel(), invEmptyHtml(), invNeverCollected(); the inv-* styles |
asset-management/settings/index.php, manifest.php
|
Inventory counts as stale after (days) on Discovery & reconciliation (asset_inventory_stale_days) |
api/v1/lib/openapi_schemas.php, api/v1/spec.json
|
AssetInventoryProvenance gains the outcome fields; GET /assets/{id}/inventory describes them |
includes/db_verify_schema.php, includes/db_verify_inventory.php, database/freeitsm.sql
|
The three new columns, and four relaxed to NULL |
tests/inventory-freshness.php |
The agent side (33 checks) |
tests/asset-api-inventory.php |
The REST side, now also checking that a failed section is recorded |
tests/agent-empty-report-guard.php |
Unchanged and still green: an older agent's empty list never wipes anything |
Before this, the same rows were written by four copies of the same logic: the REST service, and three agent endpoints that each did their own delete-and-reinsert. The review of #197 showed what that costs: narrowing one copy's delete quietly broke another source that relied on it.
Now there is one:
REST POST /assets/{id}/inventory ββΆ AssetInventoryService::submit() strict
agent system-info / device-manager / lenient
software-inventory ββΆ AssetInventoryService::agentReport()
β
ββββββββββββββββββββββΌβββββββββββββββββββββ
applySection() replaceAgentOnly() recordOutcome()
software, volumes, adapters physical drives, asset_inventory_sections
row by row, by identity Device Manager
The two front doors differ on purpose:
submit() (REST) |
agentReport() (agent) |
|
|---|---|---|
| Bad input | Refused - 422 invalid_field, the section fails |
Trimmed to fit - a long registry string must never stop a PC reporting |
| Two items resolving to one application | The section fails | The first is kept, as the agent always did |
| Observation time | The tool's observed_at
|
Now |
| Ownership, device references, replay and stale checks | Yes | No - the agent has already matched the asset with AssetsService::reconcileAsset()
|
| New catalogue application | source = 'api' |
The column default ('agent') |
Both reconcile through applySection(): rows matched by identity are updated in place (their ids survive), new ones are inserted, and for a complete section the rest are removed. The agent gives volumes and adapters an identity it never used to send, in agentItems():
| Section |
external_id the agent gets |
|---|---|
| Volumes | The drive letter, upper case (C:) |
| Network adapters | The MAC address, upper case; the name if there is none |
| Software | Not needed - the identity is the catalogue app_id
|
Two items with the same identity (Hyper-V gives virtual adapters the host's MAC) are told apart with a suffix (AA:BB:...#2) rather than one being lost.
Rows from before the upgrade have external_id NULL. applySection() collects them separately as $unnamed and a complete report removes them, so the first report after upgrading replaces them with identified rows rather than leaving them beside the new ones.
Physical drives and Device Manager live in tables only the agent writes, with no source or identity column, so replaceAgentOnly() replaces the asset's rows outright, as before - but inside the same savepoint and bookkeeping as everything else.
// TRAP: 'intune' too. Intune's volume is only a stand-in until the agent
// reports (intuneSyncDisks() skips an asset with agent rows) ...
$conn->prepare("DELETE FROM asset_disks WHERE asset_id = ? AND source = 'intune'")This is the regression the #197 review caught, now inside agentReport(). tests/agent-empty-report-guard.php fails if it goes.
asset_inventory_sections one row per (source link, section)
observed_at DATETIME NULL when the last GOOD data was observed - NULL until it has once succeeded
fingerprint CHAR(64) NULL sha256 of that data - the REST replay watermark
imported_at DATETIME NULL when it was received
imported_by_id INT NULL the analyst behind the REST key; NULL for the agent
last_outcome VARCHAR(16) succeeded | empty | skipped | failed
last_error VARCHAR(500) the failure, in the source's words
last_attempt_at DATETIME when the last attempt happened, whatever it came to
The first four were NOT NULL in 3.12.0. dbVerifyInventoryUpgrade() relaxes them, probe-then-MODIFY, because a section can now be on record before it has ever succeeded, and the agent is no analyst.
recordOutcome() writes one of four things:
| Outcome | Good data | Attempt | When |
|---|---|---|---|
succeeded |
moves on | now | A section applied with items (or a partial REST section) |
empty |
moves on | now | A complete section applied with no items |
failed |
untouched | now, with the error | The source said it could not collect, or applying it threw |
skipped |
untouched | now | The agent left it out - only updates a row that exists, so a section never sent stays "never collected" |
They are the REST replay watermark (REST-API-Assets). Moving them on a failure would make old data look new, and would make a retry of the failed report look like a replay.
The REST contract says an omitted section is untouched, and tools legitimately send sections on different schedules: software daily, disks weekly. Marking every omitted section skipped would paint them grey for no reason. The agent always sends everything in one report, so for the agent an omission means something.
An agent that predates this release sends [] both when a section is empty and when it could not find out. That is how a WMI blip once wiped 226 Device Manager rows (Inventory agent). So the server cannot believe an empty list unless the agent says it can:
{
"report_version": 2,
"collection_failures": { "devices": "Could not read Device Manager: ..." },
"hostname": "LAPTOP-01",
"software": [],
"disks": { "logical": [ ... ], "physical": [ ... ] }
}AssetInventoryService::agentState() is the one place that draws the line:
| Section is a list | Section is in collection_failures
|
Section is left out | |
|---|---|---|---|
report_version 2 |
apply it - empty included | failed | skipped |
| older agent | apply it if not empty; an empty list is skipped | (ignored) | skipped |
agentReport() takes the state it is given. Call it with 'apply' for an older agent's empty list and it will wipe the machine's inventory. Always go through agentState().
In the script, Get-Section wraps each collection, adds -ErrorAction Stop so a WMI failure throws, and records it in $collectionFailures. Volumes, physical drives, adapters and Device Manager pass -EmptyIsFailure: Windows answering nothing for something every reporting PC has is a failure to look, so it is reported as failed and the last list stays. Software is the one list trusted when empty.
Device Manager still goes to its own endpoint, now on every run (it used to be skipped when empty), so a failed or empty collection is recorded rather than looking like no report at all.
Compatibility, both ways:
- New agent, older server:
report_versionandcollection_failuresare ignored, and an empty list is still "left unchanged". Safe. - Older agent, new server:
agentState()treats it as version 1. Nothing changes for it, except that it now records outcomes too.
asset_inventory_sources links a source to an asset. A REST tool's link is made by its first report, with its own device reference (REST-API-Assets). The agent's is made by bindAgent() on its first report after upgrading, with source = 'agent' and a device reference of asset:<id>:
- The agent has already matched the machine to the asset (serial first, then hostname), so the asset is its device reference. A hostname would collide when a name is reused; the asset id never can.
-
'agent'is inRESERVED, so no REST tool can claim it, andunlink()refuses it. -
provenance()lists the agent first and hides its reference (source_device_ref: null).
The link carries the company (tenant_id), which is what keeps one device to one asset per company. So when an asset moves company, AssetsService::moveToCompany() moves its links in the same transaction, and refuses the move if the new company already links the same device to a different asset - see Moving an asset between companies - developer guide. Before 3.15.0 the links stayed on the old company.
loadInventoryStatus() runs beside the list loaders. The status and the lists arrive in either order, so both sides check:
-
"Never collected" needs no record AND no rows (
invNeverCollected()). An asset the agent reported before 3.15.0 has rows and no record, and must not say "never reported" - so it says nothing until its next report. - The empty message under a list comes from
invEmptyHtml(), which says never reported once the status is known. If the status arrives after the list drew its ordinary empty message,renderInventoryLines()swaps it. - The tab badge shows β for never collected, so 0 means a real empty list. The layout's badge observer reads
parseInt('β')as NaN and ignores it, so a hidden tab with nothing ever collected is not offered as "hidden details". - Ages are elapsed, never calendar ("3 hr ago"), for the reason in
seenHtml(). Amber afterstaleDays(). The exact time is in thetitle. - The Inventory sources panel is deliberately not a
data-layout-section. It is how a hidden list's freshness stays reachable, so no layout may hide it.
Nothing here reads the type's layout. Freshness comes from what each source did (design, Where we landed, point 1).
An install that has pulled this code but not yet run Database Verification must keep taking agent reports, or every agent in the estate stops at once. hasOutcomeColumns() asks once per request:
-
Agent: without the columns,
agentReport()skips the source link and the bookkeeping entirely and only reconciles the rows. -
REST:
recordOutcome()falls back to the 3.12.0 shape - success only, no failures recorded. -
Reads:
provenance()reports a 3.12.0 row aslast_outcome: succeeded, which is all it could ever have meant.
php tests/inventory-freshness.php # the agent: outcomes, identity, old vs new agent, status endpoint, stale setting
FREEITSM_TEST_URL=http://localhost/freeitsm-app php tests/asset-api-inventory.php # REST, incl. failed sections recorded
php tests/agent-empty-report-guard.php # an older agent's empty list never wipes anythingThe checks to keep:
- a failed section keeps its rows and its
observed_at - an older agent's empty software list is skipped, a version 2 agent's is believed
- a volume row from before the upgrade (no identity) is replaced, not left beside the new one
- two adapters sharing a MAC are both kept
-
Intune still writes its own rows its own way (
includes/intune.php) and records no outcomes. It is the next candidate foragentReport()-style treatment. - No alerting on a failed or stale section. Watchtower counts machines that stopped reporting entirely.
- Parts C and D followed in the same release: Connected assets and Asset review.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π€ Contributing (read before a pull request)
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π§ The sidebar (optional module rail)
- π§© Preference templates
- π Display order (modules, Watchtower cards) β Developer Guide
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ ποΈ Mobile: Files
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π Proposal: custom fields, inventory import and change evidence (#194)
- β³ π οΈ Custom fields, evidence + inventory - review (#195-#197)
- β³ π οΈ Long forms in a task's Work view - review (#198)
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ π§βπΌ REST API: Analysts
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Case reports - a ticket and its work as one PDF
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π§© Asset type layouts
- β³ π Inventory freshness
- β³ π Connected assets
- β³ π Asset review
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- ποΈ Files
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ π Escaped text could leave its attribute
- β³ π Task descriptions were not cleaned
- β³ π Task documents ignored company scope
- β³ π Repeating tasks ignored company scope
- β³ π¬ #183 Mailbox checks overlapped
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96