Rename Metrics to Monitors and move it second
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 43s
CI / backend (push) Successful in 4m17s

"Metrics" promised numbers. The tab holds tracked services, tracked processes
and network interfaces - things being WATCHED and their current state, only
one of which is a measurement. Monitors is what the panels actually are, and
it matches the word the agent's own config already uses for them.

Moved to second, ahead of Software. What is being watched on this box is the
question asked right after "what is it", and well before its application
inventory - which is reference data somebody goes looking for deliberately.

Tab order is now Details, Monitors, Software, Activity.

Contract 0.29.0 keeps its number. It is unreleased, so the group vocabulary it
introduces is still being settled and nothing can yet depend on the old name.
Both the server and the client fall back to 'details' for a group they do not
recognise, so a site that deploys these halves out of step gets a misplaced
card rather than a missing one.

Scope: product
This commit is contained in:
cproudlock
2026-08-31 15:21:46 -04:00
parent c71183375f
commit 2feb569686
7 changed files with 30 additions and 25 deletions

View File

@@ -47,7 +47,7 @@ Recorded in the comment block in `shopdb/__init__.py`:
| 0.25.0 | Added `caller_holds(permission)`, which answers whether the current caller holds a permission WITHOUT refusing the request - the complement to `require_permission` for a route that serves everyone but should only offer what the caller can act on. The endpoints services panel is the case: it emitted Start/Stop row actions to every reader, because the emission never consulted the caller. Clicking them was always refused server-side, so nothing was exploitable, but offering a read-only viewer controls over a shop-floor PC is the wrong signal, and it becomes a real problem once a site grants that panel to guests. Guests are included in the answer, since a signed-out visitor holds whatever the guest role was granted | additive surface (minor) | | 0.25.0 | Added `caller_holds(permission)`, which answers whether the current caller holds a permission WITHOUT refusing the request - the complement to `require_permission` for a route that serves everyone but should only offer what the caller can act on. The endpoints services panel is the case: it emitted Start/Stop row actions to every reader, because the emission never consulted the caller. Clicking them was always refused server-side, so nothing was exploitable, but offering a read-only viewer controls over a shop-floor PC is the wrong signal, and it becomes a real problem once a site grants that panel to guests. Guests are included in the answer, since a signed-out visitor holds whatever the guest role was granted | additive surface (minor) |
| 0.26.0 | Dashboard cards may declare `shortname`. The dashboard merged its per-source cards into one worst-first queue, and a card's `title` is written as a heading ("Endpoints with crashes or shutdowns") which as a row badge ran wider than the hostname it qualified. Optional, defaults to the title, so a plugin that does not declare one is unaffected | additive field (minor) | | 0.26.0 | Dashboard cards may declare `shortname`. The dashboard merged its per-source cards into one worst-first queue, and a card's `title` is written as a heading ("Endpoints with crashes or shutdowns") which as a row badge ran wider than the hostname it qualified. Optional, defaults to the title, so a plugin that does not declare one is unaffected | additive field (minor) |
| 0.27.0 | Added `Script` so a plugin can read the ShopDB-hosted script library that backs endpoint `run_script` delivery without importing core model internals. The endpoints plugin resolves a delivered script by name and serves its body by digest to the agent | additive surface (minor) | | 0.27.0 | Added `Script` so a plugin can read the ShopDB-hosted script library that backs endpoint `run_script` delivery without importing core model internals. The endpoints plugin resolves a delivered script by name and serves its body by digest to the agent | additive surface (minor) |
| 0.29.0 | Added an optional `group` to `get_asset_panels`, naming which detail-page tab a panel belongs to: `details` (the default) / `software` / `metrics` / `actions` / `activity`. The detail pages are tabbed, and a page that placed panels by knowing their ids would defeat the hook. Additive: an absent or unrecognised group resolves to `details` server-side, so every existing plugin keeps working with no change | additive surface (minor) | | 0.29.0 | Added an optional `group` to `get_asset_panels`, naming which detail-page tab a panel belongs to: `details` (the default) / `monitors` / `software` / `activity`. The detail pages are tabbed, and a page that placed panels by knowing their ids would defeat the hook. Additive: an absent or unrecognised group resolves to `details` server-side, so every existing plugin keeps working with no change | additive surface (minor) |
| 0.28.0 | Added `client_ip` and `strip_port` so a plugin does not hand-roll caller-IP extraction. Two plugins had, and both read the first `X-Forwarded-For` hop, which the caller supplies - so a rate limit keyed on it was defeated by rotating the header. The helper resolves from `remote_addr`, which ProxyFix has already filled from the header when `TRUSTED_PROXY_COUNT` says a proxy is really in front | additive surface (minor) | | 0.28.0 | Added `client_ip` and `strip_port` so a plugin does not hand-roll caller-IP extraction. Two plugins had, and both read the first `X-Forwarded-For` hop, which the caller supplies - so a rate limit keyed on it was defeated by rotating the header. The helper resolves from `remote_addr`, which ProxyFix has already filled from the header when `TRUSTED_PROXY_COUNT` says a proxy is really in front | additive surface (minor) |
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0, The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0,

View File

@@ -200,8 +200,9 @@ const panels = ref([])
// has since been renamed or removed silently disappears. That is what happened // has since been renamed or removed silently disappears. That is what happened
// to tracked services when it moved out of 'actions' - the backend still said // to tracked services when it moved out of 'actions' - the backend still said
// 'actions', this build had no such tab, and the card was simply gone with // 'actions', this build had no such tab, and the card was simply gone with
// nothing logged. Fall back the same way the server does rather than drop it. // nothing logged. The same applies to the 'metrics' to 'monitors' rename.
const RENDERED_GROUPS = ['details', 'software', 'metrics', 'activity'] // Fall back the same way the server does rather than drop it.
const RENDERED_GROUPS = ['details', 'monitors', 'software', 'activity']
function groupOf(panel) { function groupOf(panel) {
return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details' return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details'
} }

View File

@@ -302,13 +302,14 @@
</div> </div>
</div> </div>
<!-- Metrics and Activity are entirely plugin-contributed: the telemetry, <!-- Monitors and Activity are entirely plugin-contributed: what is being
the service state and its controls, and the event feed all belong to watched on the box, the service state and its controls, and the event
the endpoints plugin, and core knows nothing about them. feed all belong to the endpoints plugin, and core knows nothing about
them.
Metrics keeps the two-column flow because it is short cards. --> Monitors keeps the two-column flow because it is short cards. -->
<div v-show="activegroup === 'metrics'" class="content-grid"> <div v-show="activegroup === 'monitors'" class="content-grid">
<PluginAssetPanels :assetid="computer.assetid" group="metrics" /> <PluginAssetPanels :assetid="computer.assetid" group="monitors" />
</div> </div>
<div v-show="activegroup === 'activity'" class="detail-full"> <div v-show="activegroup === 'activity'" class="detail-full">
<PluginAssetPanels :assetid="computer.assetid" group="activity" /> <PluginAssetPanels :assetid="computer.assetid" group="activity" />
@@ -384,10 +385,12 @@ const detailtabs = computed(() => {
const filled = assetPanels.value?.populatedGroups ?? new Set() const filled = assetPanels.value?.populatedGroups ?? new Set()
const has = (key) => filled.has ? filled.has(key) : false const has = (key) => filled.has ? filled.has(key) : false
const tabs = [{ key: 'details', label: 'Details' }] const tabs = [{ key: 'details', label: 'Details' }]
// Monitors sits second: what is being watched on this box is the question
// asked right after "what is it", and well before its software inventory.
if (has('monitors')) tabs.push({ key: 'monitors', label: 'Monitors' })
if (has('software') || installedApps.value.length > 0) { if (has('software') || installedApps.value.length > 0) {
tabs.push({ key: 'software', label: 'Software', count: installedApps.value.length || null }) tabs.push({ key: 'software', label: 'Software', count: installedApps.value.length || null })
} }
if (has('metrics')) tabs.push({ key: 'metrics', label: 'Metrics' })
if (has('activity')) tabs.push({ key: 'activity', label: 'Activity' }) if (has('activity')) tabs.push({ key: 'activity', label: 'Activity' })
return tabs return tabs
}) })

View File

@@ -351,9 +351,9 @@ class EndpointsPlugin(BasePlugin):
'permission': 'endpoints.command', 'permission': 'endpoints.command',
'empty': 'No monitored services reported.', 'empty': 'No monitored services reported.',
'position': 12, 'position': 12,
# Service state is telemetry first; the Start/Stop controls # Service state is what is being watched; the Start/Stop
# belong beside it rather than in a tab of their own. # controls belong beside it, not in a tab of their own.
'group': 'metrics', 'group': 'monitors',
}, },
{ {
'id': 'endpoints-tracked-processes', 'id': 'endpoints-tracked-processes',
@@ -393,7 +393,7 @@ class EndpointsPlugin(BasePlugin):
'empty': 'No monitored processes reported.', 'empty': 'No monitored processes reported.',
'position': 13, 'position': 13,
# observed process state, not something you act on here # observed process state, not something you act on here
'group': 'metrics', 'group': 'monitors',
}, },
{ {
# One feed instead of two cards. Events and commands answer the # One feed instead of two cards. Events and commands answer the
@@ -458,8 +458,8 @@ class EndpointsPlugin(BasePlugin):
], ],
'empty': 'No network interfaces reported.', 'empty': 'No network interfaces reported.',
'position': 16, 'position': 16,
# adapter state is telemetry, not something you act on # adapter state is watched, not something you act on
'group': 'metrics', 'group': 'monitors',
}, },
{ {
# Installed-software (SBOM) inventory. Raw discovered list for # Installed-software (SBOM) inventory. Raw discovered list for

View File

@@ -25,7 +25,7 @@ pluginui_bp = Blueprint('pluginui', __name__)
# #
# 'details' is first and is the fallback: it holds what an asset IS, which is # 'details' is first and is the fallback: it holds what an asset IS, which is
# what a panel declaring nothing almost always contributes. # what a panel declaring nothing almost always contributes.
ASSET_PANEL_GROUPS = ('details', 'software', 'metrics', 'activity') ASSET_PANEL_GROUPS = ('details', 'monitors', 'software', 'activity')
def _collect(hookname): def _collect(hookname):

View File

@@ -371,7 +371,7 @@ class BasePlugin(ABC):
# keys, rendered generically - see warranty) # keys, rendered generically - see warranty)
'position': int, # order among panels 'position': int, # order among panels
'group': str, # OPTIONAL. Which tab on the detail page: 'group': str, # OPTIONAL. Which tab on the detail page:
# 'details' | 'software' | 'metrics' # 'details' | 'monitors' | 'software'
# | 'activity'. Omitted means 'details'. # | 'activity'. Omitted means 'details'.
} }
@@ -383,12 +383,13 @@ class BasePlugin(ABC):
existing plugin working untouched: an unknown or absent group resolves existing plugin working untouched: an unknown or absent group resolves
to 'details', which is the tab that holds what an asset IS. Reach for to 'details', which is the tab that holds what an asset IS. Reach for
another group only when the panel is the thing that tab exists for - another group only when the panel is the thing that tab exists for -
an inventory listing, live telemetry, or a history feed. what is being watched on the box, an inventory listing, or a history
feed.
There is deliberately no group for controls. A panel that acts on the There is deliberately no group for controls. A panel that acts on the
machine belongs beside the state it acts on: tracked services read as machine belongs beside the state it acts on: tracked services read as
telemetry first and a Start/Stop second, so they live in 'metrics' with state first and a Start/Stop second, so they live in 'monitors' with
the rest of what the endpoint reports. Splitting the buttons away from the rest of what is being watched. Splitting the buttons away from
the state they change made a tab that held one card and answered no the state they change made a tab that held one card and answered no
question on its own. question on its own.

View File

@@ -198,7 +198,7 @@ def test_a_group_nobody_defined_falls_back_rather_than_disappearing(
monkeypatch.setattr(plugin, 'get_asset_panels', lambda: [{ monkeypatch.setattr(plugin, 'get_asset_panels', lambda: [{
'id': 'typo', 'title': 'Typo', 'assettypes': ['*'], 'id': 'typo', 'title': 'Typo', 'assettypes': ['*'],
'endpoint': '/api/whatever', 'render': 'keyvalue', 'position': 1, 'endpoint': '/api/whatever', 'render': 'keyvalue', 'position': 1,
'group': 'metrcis', 'group': 'montiors',
}]) }])
assetid = _make_asset(db) assetid = _make_asset(db)
@@ -210,18 +210,18 @@ def test_a_group_nobody_defined_falls_back_rather_than_disappearing(
def test_group_matching_is_not_case_or_space_sensitive(app, client, db, def test_group_matching_is_not_case_or_space_sensitive(app, client, db,
auth_headers, monkeypatch): auth_headers, monkeypatch):
"""'Metrics ' is obviously meant to be metrics; do not send it to details.""" """'Monitors ' is obviously meant to be monitors; do not send it to details."""
pm = app.extensions['plugin_manager'] pm = app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
plugin = next(iter(pm.get_all_plugins().values())) plugin = next(iter(pm.get_all_plugins().values()))
monkeypatch.setattr(plugin, 'get_asset_panels', lambda: [{ monkeypatch.setattr(plugin, 'get_asset_panels', lambda: [{
'id': 'shouty', 'title': 'Shouty', 'assettypes': ['*'], 'id': 'shouty', 'title': 'Shouty', 'assettypes': ['*'],
'endpoint': '/api/whatever', 'render': 'keyvalue', 'position': 1, 'endpoint': '/api/whatever', 'render': 'keyvalue', 'position': 1,
'group': ' Metrics ', 'group': ' Monitors ',
}]) }])
assetid = _make_asset(db) assetid = _make_asset(db)
panels = _data(client, '/api/pluginui/asset-panels?assetid=%d' % assetid, panels = _data(client, '/api/pluginui/asset-panels?assetid=%d' % assetid,
auth_headers) auth_headers)
panel = next(p for p in panels if p['id'] == 'shouty') panel = next(p for p in panels if p['id'] == 'shouty')
assert panel['group'] == 'metrics' assert panel['group'] == 'monitors'