From 6beb2afc093e5f6e676705b8775ed49c3a860b74 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Sat, 29 Aug 2026 17:00:34 -0400 Subject: [PATCH] Service state colours, activity badge colours, readable named colours --- .../src/components/pluginAssetPanels.spec.js | 34 +++++++++++++++++++ frontend/src/utils/colorStyle.js | 29 ++++++++++++++-- frontend/src/utils/colorStyle.spec.js | 27 +++++++++++++++ plugins/endpoints/api/routes.py | 26 +++++++++++++- plugins/endpoints/plugin.py | 28 +++++++++++++++ 5 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 frontend/src/utils/colorStyle.spec.js diff --git a/frontend/src/components/pluginAssetPanels.spec.js b/frontend/src/components/pluginAssetPanels.spec.js index 5ef5b255..5e3810db 100644 --- a/frontend/src/components/pluginAssetPanels.spec.js +++ b/frontend/src/components/pluginAssetPanels.spec.js @@ -292,4 +292,38 @@ describe('facet chips on a card that shows a health dot', () => { it('offers no chips for a panel with no status rollup', () => { expect(facets({ id: 'x', render: 'list', _data: [{ a: 1 }] })).toEqual([]) }) + + it('prefers an explicit facets declaration over the status rollup', () => { + // Services carry BOTH: a statecolor status dot and a statelabel facet. + // Chips must follow the declaration, because a service is Running or + // Stopped - "OK" and "Critical" is not how anybody describes one. + const services = { + id: 'endpoints-services', + render: 'list', + status: { key: 'statecolor', levels: { green: 'ok', red: 'critical' } }, + facets: { key: 'statelabel', order: ['Running', 'Stopped', 'Paused'] }, + _data: [ + { statelabel: 'Running', statecolor: 'green' }, + { statelabel: 'Stopped', statecolor: 'red' }, + { statelabel: 'Paused', statecolor: 'red' }, + ], + } + const chips = facets(services) + expect(chips.map((c) => c.label)).toEqual(['Running', 'Stopped', 'Paused']) + expect(chips.map((c) => c.label)).not.toContain('Critical') + }) + + it('gives an unlisted state its own chip rather than dropping the row', () => { + // Windows has transitional states, and an agent can report something the + // order list never anticipated. Those rows must stay reachable. + const services = { + id: 'svc2', + render: 'list', + facets: { key: 'statelabel', order: ['Running', 'Stopped'] }, + _data: [{ statelabel: 'Running' }, { statelabel: 'Start Pending' }], + } + const chips = facets(services) + expect(chips.map((c) => c.value)).toEqual(['Running', 'Start Pending']) + expect(facetedRows(services, ['Start Pending']).length).toBe(1) + }) }) diff --git a/frontend/src/utils/colorStyle.js b/frontend/src/utils/colorStyle.js index e2b76c2c..a726f911 100644 --- a/frontend/src/utils/colorStyle.js +++ b/frontend/src/utils/colorStyle.js @@ -19,10 +19,33 @@ export const PALETTE = [ '#6c757d', // gray ] +// Plugin panels emit colour NAMES, not hex - a services row says 'yellow', an +// activity row says 'gray'. Without this the brightness test cannot parse them +// and falls through to white, which is why an amber badge rendered white text +// on yellow and could not be read. Green, red and grey are dark enough that +// white worked by luck, so the fault only showed when amber was introduced. +const NAMED_COLORS = { + red: '#dc3545', + green: '#28a745', + yellow: '#ffc107', + amber: '#ffc107', + orange: '#fd7e14', + blue: '#4181ff', + grey: '#6c757d', + gray: '#6c757d', + black: '#000000', + white: '#ffffff', +} + +export function resolveColor(color) { + if (!color || typeof color !== 'string') return color + return NAMED_COLORS[color.trim().toLowerCase()] || color +} + // Black or white text for a given background, by perceived brightness. export function readableText(bg) { if (!bg || typeof bg !== 'string') return '#ffffff' - let hex = bg.trim().replace('#', '') + let hex = resolveColor(bg).trim().replace('#', '') if (hex.length === 3) hex = hex.split('').map(c => c + c).join('') if (hex.length !== 6) return '#ffffff' const r = parseInt(hex.slice(0, 2), 16) @@ -34,6 +57,8 @@ export function readableText(bg) { // Style object for a badge/pill from a stored color (with a neutral fallback). export function colorStyle(color, fallback = '#6c757d') { - const backgroundColor = color || fallback + // Resolve the name for the BACKGROUND too, so the swatch and the text + // contrast are computed from the same value. + const backgroundColor = resolveColor(color) || fallback return { backgroundColor, color: readableText(backgroundColor) } } diff --git a/frontend/src/utils/colorStyle.spec.js b/frontend/src/utils/colorStyle.spec.js new file mode 100644 index 00000000..cdbc10bf --- /dev/null +++ b/frontend/src/utils/colorStyle.spec.js @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { readableText, colorStyle } from './colorStyle' + +describe('named colours', () => { + // Panels emit names, not hex. Before these resolved, the brightness test + // could not parse them and returned white for everything - which is how an + // amber badge shipped as white-on-yellow. + it('gives yellow dark text, not white', () => { + expect(readableText('yellow')).toBe('#1a1a1a') + expect(colorStyle('yellow').color).toBe('#1a1a1a') + }) + + it('still gives dark colours white text', () => { + expect(readableText('red')).toBe('#ffffff') + expect(readableText('green')).toBe('#ffffff') + expect(readableText('gray')).toBe('#ffffff') + }) + + it('resolves the name for the background as well as the text', () => { + expect(colorStyle('yellow').backgroundColor).toBe('#ffc107') + }) + + it('leaves hex and unknown values alone', () => { + expect(colorStyle('#ffffff').color).toBe('#1a1a1a') + expect(colorStyle('rebeccapurple').backgroundColor).toBe('rebeccapurple') + }) +}) diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index a5cd9554..6032331b 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -44,6 +44,17 @@ def _service_running(entry): return raw in _RUNNING_STATES +# Paused, plus the four transitional states Windows reports while a service is +# on its way somewhere. +_TRANSITIONAL_STATES = ('paused', 'start pending', 'stop pending', + 'continue pending', 'pause pending', 'starting', + 'stopping', 'pausing') + + +def _service_transitional(raw): + return (raw or '').strip().lower() in _TRANSITIONAL_STATES + + def _service_name(entry): return entry.get('name') or entry.get('service') or 'Unknown' @@ -465,7 +476,14 @@ def panel_services(asset_id: int): 'name': name, 'kind': (kind or '').lower() or None, 'statelabel': 'Running' if running else (raw.title() or 'Unknown'), - 'statecolor': 'green' if running else ('red' if raw else 'gray'), + # Amber for paused and the four Windows transitional states. A + # service stuck in Start Pending is failing to start on a retry + # cycle - which is what raises event 7011 - and a paused one was + # paused deliberately. Painting either the same red as Stopped + # loses the distinction that tells you which to chase. + 'statecolor': ('green' if running + else 'yellow' if _service_transitional(raw) + else 'red' if raw else 'gray'), } # Only offer Start/Stop to a caller who could actually run it. The # emission never consulted the caller, so a read-only viewer was shown @@ -601,9 +619,15 @@ def panel_activity(asset_id: int): key=lambda r: (r['eventtime'] is not None, r['eventtime']), reverse=True) + # The list renderer reads a badge colour from a ROW FIELD, so without this + # every badge falls back to grey and the feed loses the one signal it is + # there to carry. + colours = {'critical': 'red', 'warning': 'yellow', 'info': 'gray'} + return success_response([{ 'title': r['title'], 'severity': r['severity'], + 'severitycolor': colours.get(r['severity'], 'gray'), 'detail': r['detail'], 'eventtime': r['eventtime'].isoformat() + 'Z' if r['eventtime'] else None, } for r in rows[:15]]) diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py index 944d3a05..5b332855 100644 --- a/plugins/endpoints/plugin.py +++ b/plugins/endpoints/plugin.py @@ -244,6 +244,18 @@ class EndpointsPlugin(BasePlugin): 'render': 'list', 'collapsible': True, 'filter': {'key': 'kind', 'equals': 'service'}, + # Facet on the STATE, not the status colour. statecolor is only + # ever green, red or grey here, so chips derived from it would + # read "OK" and "Critical" - not how anybody talks about a + # service. statelabel carries the real vocabulary: Running, + # Stopped, Paused, and the Windows transitional states. Any + # value not listed still gets a chip, appended after these. + 'facets': { + 'key': 'statelabel', + 'order': ['Running', 'Stopped', 'Paused', 'Start Pending', + 'Stop Pending', 'Continue Pending', 'Pause Pending', + 'Unknown'], + }, 'status': { 'key': 'statecolor', 'levels': { @@ -270,6 +282,18 @@ class EndpointsPlugin(BasePlugin): 'render': 'list', 'collapsible': True, 'filter': {'key': 'kind', 'equals': 'process'}, + # Facet on the STATE, not the status colour. statecolor is only + # ever green, red or grey here, so chips derived from it would + # read "OK" and "Critical" - not how anybody talks about a + # service. statelabel carries the real vocabulary: Running, + # Stopped, Paused, and the Windows transitional states. Any + # value not listed still gets a chip, appended after these. + 'facets': { + 'key': 'statelabel', + 'order': ['Running', 'Stopped', 'Paused', 'Start Pending', + 'Stop Pending', 'Continue Pending', 'Pause Pending', + 'Unknown'], + }, 'status': { 'key': 'statecolor', 'levels': { @@ -313,6 +337,10 @@ class EndpointsPlugin(BasePlugin): 'title': 'title', 'badge': { 'label': 'severity', + # The renderer reads the colour from a row field; with + # no colour declared every badge renders grey, which is + # what the feed spent its first version doing. + 'color': 'severitycolor', 'labelmap': { 'critical': 'Critical', 'warning': 'Warning',