netalertx-scan-pipeline
GitHub解析NetAlertX扫描管道内部逻辑,涵盖设备存在性计算、事件生成与会话关联机制。用于修改扫描处理代码或分析设备在线状态及连接事件。
Trigger Scenarios
Install
npx skills add netalertx/NetAlertX --skill netalertx-scan-pipeline -g -y
SKILL.md
Frontmatter
{
"name": "netalertx-scan-pipeline",
"description": "NetAlertX scan pipeline internals — process_scan() call order and why it's load-bearing, CurrentScan\/Events\/Sessions\/DevicesView relationships, the Events-to-Sessions pairing mechanism, and the FIELD_SPECS field-write authority system. Use this when modifying server\/scan\/session_events.py or server\/scan\/device_handling.py, or reasoning about device presence, connect\/disconnect events, or session timelines."
}
Scan Pipeline & Device Presence Lifecycle
Scope
Covers what happens after a plugin's rows land in CurrentScan: presence computation, event generation, session/timeline derivation. Not plugin authoring (manifest, data contract, settings) — see plugin-development and docs/PLUGINS_DEV*.md. Not the general *Source attribution system or SQLite audit triggers — see database-patterns; the field-authority section below is the scan-pipeline-local half of that system.
Core tables and views
CurrentScan— ephemeral scratch table.process_plugin_events()populates it for any plugin whoseconfig.jsondeclaresmapped_to_table;process_scan()deletes all rows at the end of every cycle. A value written to aCurrentScanrow is not readable in a later cycle — the row is gone by then. See Gotcha 2.Devices— persistent identity + state table.Events— persistent, append-only log of state-transition events (New Device,Connected,Down Reconnected,Device Down,Disconnected,IP Changed). This is the audit trail;Sessionsis derived from it, not the reverse.Sessions— fully wiped and rebuilt every cycle fromConvert_Events_to_Sessions(below), not incrementally updated. Treat it as a materialized query result, not a live connection state machine.Online_History— one row per scan cycle, feeds the dashboard's online/offline graph. A rollup ofdevPresentLastScan/devAlertDown/devIsSleepingcounts onDevicesView— no state of its own.
Key views
LatestDeviceScan(server/db/db_upgrade.py) —DevicesLEFT JOIN'd to the most recentCurrentScanrow per(scanMac, scanSourcePlugin)pair, viaROW_NUMBER() OVER (PARTITION BY scanMac, scanSourcePlugin ...).update_devices_data_from_scan()loops overDISTINCT scanSourcePluginand re-queries this view once per plugin: when two plugins report the same device in one cycle, each contribution is evaluated separately, per field, through the authority mechanism below — they are not merged into one row first.LatestEventsPerMAC— most recent Event per MAC, joined toDevicesandCurrentScan- via an inner join onCurrentScan, so it silently returns no row at all for a MAC not inCurrentScanthis cycle, regardless of that MAC's real event history (see Gotcha 7). The "New Connections" query ininsert_events()uses it to decide whether a device was previously down (→Down Reconnected) or new (→Connected).Convert_Events_to_Sessions— defines "is this device's session still open." There is noclose_session()function anywhere in this codebase. A session closes as an emergent property:pair_sessions_events()setsevePairEventRowidon aNew Device/Connected/Down ReconnectedEvent to point at the nextDisconnected/Device DownEvent for that MAC; this view setssesStillConnected = 1exactly when that pairing isNULL. To close a session, insert the rightEventsrow — never mutateSessionsdirectly (the one exception iscreate_new_devices()'s reconnect-insert, in the call order below).DevicesView— adds computeddevIsSleeping/devFlapping/devStatuson top ofDevices. The UI andinsertOnlineHistory()read presence from this, not the rawDevicestable.
process_scan() call order (server/scan/session_events.py) — order is load-bearing
save_own_device(),exclude_ignored_devices()insert_events(db)— runs before presence updates for this cycle. The Down/Disconnected/Connected queries need the previous cycle'sdevPresentLastScanto detect a transition. If this ran after the presence update, every query would see the new value and the edge-triggered design would break — firing never, or every cycle.create_new_devices(db)— runs before presence updates so a brand-new device gets aNew Deviceevent, not aConnectedevent (it has noDevicesrow yet for step 2's queries to match). Also has a rawINSERT INTO Sessions ... sesStillConnected = 1for existing devices with no open session — the one place outside theEvents-derived path that writesSessionsdirectly.update_devices_data_from_scan(db)— field-level updates for existing devices; see the authority mechanism below.update_sync_hub_node,update_devLastConnection_from_CurrentScanupdate_presence_from_CurrentScan(db)— setsdevPresentLastScanfromCurrentScanfor this cycle (step 2 reads this as "previous" on the next cycle).update_devPresentLastScan_based_on_nics(db)— NIC/parent-child presence aggregation; can override step 6 for parent devices.update_devPresentLastScan_based_on_force_status(db)— the user's manualdevForceStatusoverride; runs last, wins over everything above.update_vendors_from_mac,update_ipv4_ipv6,update_icons_and_types—update_ipv4_ipv6()does not go throughLatestDeviceScan/FIELD_SPECS; it readsCurrentScandirectly with its ownPARTITION BY scanMac, address_familyranking, so a device reporting both an IPv4 and an IPv6 row in the same cycle gets bothdevPrimaryIPv4/devPrimaryIPv6set from that cycle, not just whichever family happened to windevLastIP's single-value reduction.pair_sessions_events(db)— pairsEventsrows as described above.create_sessions_snapshot(db)—DELETE FROM Sessions; INSERT INTO Sessions SELECT * FROM Convert_Events_to_Sessions.Sessionsreflects step 10's pairing from here.insertOnlineHistory(db)— dashboard graph rollup.skip_repeated_notifications(db)DELETE FROM CurrentScan— the table's entire lifetime is one call toprocess_scan().
Field-write authority for scan-derived updates
update_devices_data_from_scan() (server/scan/device_handling.py) does not overwrite fields from whichever plugin ran most recently. Each trackable field is declared once in FIELD_SPECS (scan_col, source_col, a priority list of plugin prefixes, optional allow_override_if_changed). can_overwrite_field() uses that plus get_plugin_authoritative_settings() (a plugin's own authority-override setting, if any) to decide, per field per row, whether this plugin's value may replace what's there. The paired <field>Source column (devNameSource, devLastIPSource, etc.) records who currently owns the field. devMac is never a target of these updates — it's the join key, not a tracked field — so no scan-derived update can alter a device's identity, only its attributes.
This is the scan-pipeline-local half of a bigger attribution system — see database-patterns for FIELD_SOURCE_MAP/server/db/authoritative_handler.py, the full *Source model, and the SQLite triggers that consume it for audit logging. Read both before touching anything that writes a *Source column.
Gotchas
-
A "presence" check exists in more than one place. A per-row signal meaning "don't count this as a live sighting" (e.g.
scanPresence) has to reach every query that independently re-derives "is this MAC currently present" fromCurrentScan.current_scan_presence_condition()(server/scan/presence.py) centralizes that check for five sites:update_presence_from_CurrentScan()(both statements),update_devLastConnection_from_CurrentScan(), and three ofinsert_events()'s four queries (bothDevice Downvariants,Disconnected). Two sites can't use it: the "New Connections" query and the rawSessionsinsert increate_new_devices()need the actualscanLastIP/scanVendorvalue off the presence-asserting row viaMIN()/GROUP BY, not just a boolean. Check any new presence-adjacent query against both patterns — a bare helper call isn't always enough. A second, deliberately separate predicate,nic_derived_presence_condition()(same file), answers a narrower question — "is this device's absence fromCurrentScanmasked by NIC-derived presence" (a parent device whosedevParentRelType='nic'children satisfydevReqNicsOnline, ANY/ALL, againstCurrentScanthis cycle) — and isOR-composed ontocurrent_scan_presence_condition()at four of those same sites (bothDevice Downvariants,Disconnected,update_devLastConnection_from_CurrentScan()), not folded into it. The fifth site - the NIC-derived reconnect query (Gotcha 7) - combines them differently:NOT current_scan_presence_condition(...) AND nic_derived_presence_condition(...), since it exists specifically to catch NIC-only presence that the mainline query already handles otherwise. A future presence-adjacent query needs to check both predicates, not just the first one, if it should also treat a NIC-covered parent as present. -
CurrentScanis deleted at the end of every cycle — a per-row flag on it can't express a decision that needs to survive to a cycle where the row is gone. Anything that fires because a row is missing (Device Down,Disconnected) can't read a flag that lived on that row. A per-row plugin signal that needs to affect behavior beyond its own cycle has to persist onto theDevicesrow at creation time (e.g. seedingdevAlertDown/devAlertEventsfrom the row's flag instead of the globalNEWDEV_*defaults), not ride on the ephemeral table. -
CurrentScanis not small, and it's indexed onscanMac. Real production users run 10,000+ devices; with one row per contributing plugin (seeLatestDeviceScanabove), a single cycle'sCurrentScanis routinely 20,000-50,000+ rows.idx_currentscan_scanmac(server/db/db_upgrade.py:ensure_CurrentScan(), mirrored inserver/db/schema/app.sql) covers everyscanMac-keyed lookup in this file.ensure_CurrentScan()'sDROP TABLE/CREATE TABLEruns once, at app startup (DB.initDB(),server/__main__.py) — don't confuse this with the per-cycleDELETE FROM CurrentScanin point 1, which clears rows but leaves the table and its index in place. -
server/plugins/sync/sync.pybypasses this pipeline on purpose, twice — a permanent exception, not a bug. It fires its own directINSERT OR IGNORE INTO Events (... 'New Device' ...)for newly-seen synced devices (hardcodedevePendingAlertEmail = 1, noscanNotificationModeawareness), and incarbon-copymode its own rawDevicesUPSERT viaON CONFLICT(devMac) DO UPDATE— both skipcreate_new_devices()/update_devices_data_from_scan()/can_overwrite_field()(sync.py's own comments: "Node is fully authoritative in this mode"). It's a normalmapped_to_table: CurrentScanplugin for its presence contribution, soIMPORT_ON/scanPresenceapply to it like any other plugin — but its two direct-write paths ignorescanNotificationMode = 'quiet'orscanCreatesDevice = 0. Don't assume everyEvents/Deviceswrite goes through the generic pipeline —sync.pydoesn't. -
A blank/null-equivalent
scanMaccan create a phantomDevicesrow.create_new_devices()'s two creation-path queries filterscanMac NOT IN (NULL_EQUIVALENTS_SQL)(server/scan/device_handling.py,const.NULL_EQUIVALENTS_SQL) as a backstop, becausescanCreatesDevicedefaults to1— any plugin reporting a row with no real MAC, without settingscanCreatesDevice = 0itself, would otherwise create adevMac = ''device, and every other blank-MAC row from every other plugin would then silently write onto it. The filter doesn't replacescanCreatesDevice = 0as the correct thing for a plugin to set on such rows; it keeps a MAC-less row inert when some other plugin forgets to. Check any new creation-adjacent query against blankscanMactoo. -
app.sqlis not dead code.install/production-filesystem/entrypoint.d/25-first-run-db.shpipes it intosqlite3to bootstrap a brand-new database on first install;scripts/db_cleanup/regenerate-database.shuses it too.CurrentScan,Parameters, andSettingsare safe from drift: each has a dedicatedensure_X()function (server/db/db_upgrade.py) that drops and recreates the table on every startup, superseding whateverapp.sqlbootstrapped.Plugins_Language_Stringsgets the same treatment inside the sharedensure_plugins_tables().AppEventsgets its own drop/recreate viaAppEvent_obj.__init__()(server/workflows/app_events.py), independent ofdb_upgrade.py.Deviceshas no drop/recreate, butserver/database.pyhas 18 explicitensure_column()calls that backfill any column missing from an olderapp.sqlsnapshot on every startup.Events,Sessions, andNotificationsget the same backfill viaensure_table_columns()(server/db/db_upgrade.py), driven by one Python column-list constant per table (server/db/schema_columns.py) that's diffed againstapp.sqlin CI (test/db/test_schema_drift_guard.py).AppEvents/Notificationseach also have a second schema-definition surface — their own inlineCREATE TABLE IF NOT EXISTSinserver/workflows/app_events.py/server/models/notification_instance.py— kept in sync by the same drift-check test. Check any new query here withEXPLAIN QUERY PLANat a realistic row count rather than assuming it's fine because it resembles an existing one — a correlated subquery re-evaluated per row (an accidental self-join) is the pattern most likely to look reasonable while actually being quadratic at this scale. -
LatestEventsPerMACis intentionallyCurrentScan-gated - correct for its one existing caller, a trap for a new one. ItINNER JOINsCurrentScan, which is exactly right for the mainline "New Connections" query (every MAC it looks up is already known to be inCurrentScanthis cycle, via its ownpresent_agg). It is not a general-purpose "last event for any MAC" lookup. A caller that needs the last event for a MAC not inCurrentScanthis cycle (e.g. a NIC-covered parent with no direct scan row of its own) gets no row at all from this view, regardless of that MAC's real event history - silently, not an error.insert_events()'s NIC-derived reconnect query readsEventsdirectly instead (a correlatedORDER BY eveDateTime DESC LIMIT 1, covered byidx_eve_mac_datetime_desc), sidestepping the view entirely rather than trying to make it handle both shapes. -
A correlated helper's
mac_columnargument silently binds to the helper's own inner row, not the caller's, whenever the helper's inner table already has a column of that name in scope - alias or no alias. SQL resolves an unqualified name in the innermost enclosing scope first and only searches outward if nothing matches there;nic_derived_presence_condition()'s inner scan isFROM Devices, andDeviceshas adevMaccolumn, so any bare"devMac"argument - not just one that happens to collide with an alias name - bound to the helper's own inner row. Every bare-"devMac"call site (bothDevice Downqueries,Disconnected,update_devLastConnection_from_CurrentScan()) was affected: any NIC-covered device anywhere inDevicesmade every other absent device, including one with no NIC children at all, look NIC-derived-present, silently suppressing its real event or bumping itsdevLastConnection. (A qualified-but-colliding argument, e.g."nic_parent.devMac"passed from a caller aliasing its own rownic_parentwhile the helper's own inner alias was alsonic_parent, is the same root cause in a narrower form.) Fixed by requiring every caller to pass a qualified reference to its own table/alias ("Devices.devMac","DevicesView.devMac") and havingnic_derived_presence_condition()reject a baremac_columnoutright, on top of the existingpresence_scan/nic_presence_parent-collision guards.current_scan_presence_condition()doesn't need this: its inner scan isFROM CurrentScan, which has nodevMaccolumn, so a bare"devMac"has nothing to bind to inward and correctly falls back to the caller's row. Only caught by a test with two sibling devices in one DB where one should match and the other shouldn't - every single-device test passed regardless, because "any row" and "this row" are the same row when there's only one. -
CurrentScan.scanMac/Events.eveMacare permanently MAC-keyed, independent of the devGUID-as-PK migration. Seedatabase-patterns' "Device Identity" section -devMacis today's actual schema PK,devGUIDis the intended long-term identity, but a plugin can only ever report a MAC from network discovery, never an app-internaldevGUID. Don't design around these tables ever becoming devGUID-keyed. -
insert_events()'sIP Changedquery evaluates every distinct present address for a MAC individually, not oneMIN()-reduced candidate. A device can have more than one simultaneously-valid, already-known address in the same family (e.g. two live IPv6 addresses); testing only theMIN()'d one againstdevPrimaryIPv4/devPrimaryIPv6/devLastIPcan pick the one address that happens not to be sitting in the single-valued slot, firing a false event even though nothing actually changed. The fix makes "known" additive: an address counts as known if it matches the three-slot check or if it's been reported (and isn'tmissing-in-last-scan) by an eligible plugin inPlugins_Objects, via_known_plugin_ip_addresses_sql()'s use ofplugin_field_views.get_plugin_columns_for_field(all_plugins, "ip")- the same plugin-to-column mapping the Field View tab uses.all_pluginsis optional on bothinsert_events()andprocess_scan()(defaultNone); when it's not eligible for any plugin (or a caller hasn't passed it), the additive half is skipped entirely and the query reduces to exactly the three-slot check. Addresses that failknown()are aggregated into oneEventsrow per MAC per cycle (today's shape), listing every new address ineveAdditionalInforather than firing one row per address.
When to read this vs. other docs/skills
- Writing or reviewing a plugin's
config.json/data contract →plugin-development,docs/PLUGINS_DEV*.md. This skill covers what happens after a plugin's rows land inCurrentScan, not the authoring contract. - Devices-table write paths,
*Sourceattribution, audit/history logging, SQLite triggers →database-patterns. - Implementing a change here → read the actual function in
server/scan/session_events.py/server/scan/device_handling.pyfirst; this skill's line numbers are a map, not a guarantee, and drift as the code moves.
Version History
-
fe29f5d
Current 2026-10-19 05:52
更新多IP处理逻辑,修复NIC覆盖下父设备遗漏连接/重连事件的问题,优化孤儿断开事件处理。
- f6010e0 2026-09-27 22:54
- cd1d0ed 2026-09-22 11:08


