Service state colours, activity badge colours, readable named colours

This commit is contained in:
2026-08-29 17:00:34 -04:00
parent b26bbd0456
commit 6beb2afc09
5 changed files with 141 additions and 3 deletions

View File

@@ -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)
})
})

View File

@@ -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) }
}

View File

@@ -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')
})
})

View File

@@ -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]])

View File

@@ -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',