Say what a service state actually is, and make the badges readable
Three colour faults on the PC page, each hiding the next. ACTIVITY BADGES WERE ALL GREY. The list renderer reads a badge colour from a ROW FIELD, and the activity panel declared none, so every severity rendered identically. The feed's whole job is to make a critical event visible and it was painting it the same as a logon. The route now emits severitycolor - info grey, warning yellow, critical red - and the panel declares it. PAUSED AND THE TRANSITIONAL STATES WERE RED. statecolor was green-or-red-or- grey, so a paused service looked like a stopped one. They are different: a paused service was paused deliberately, and one stuck in Start Pending is failing to start on a retry cycle - which is exactly what raises event 7011, the thing behind the critical counts on the Fleet page. Both are amber now. NAMED COLOURS ALWAYS GOT WHITE TEXT. readableText only parsed hex and returned white for anything else, but panels emit NAMES. Green, red and grey are dark enough that white worked by luck; amber is not, so the fix above shipped as white-on-yellow and could not be read. Names resolve to hex before the contrast test, so this holds for any named colour anywhere in the app rather than these two panels. colorStyle had no test file at all. It has one now. Every one of these was found by looking at the rendered page. None of them would fail a test that checked markup. Full suite 2131 passed, vitest 273 passed.
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
27
frontend/src/utils/colorStyle.spec.js
Normal file
27
frontend/src/utils/colorStyle.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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]])
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user