Files
TheWitness 5f96b8b7e2 Monitor 3.0 board: full-width grouping cards, Tree ordering, and the Status->Events filter model (#274)
* Monitor: flex grouping cards to full width, order Tree cards by device count

* Monitor: drop inaccurate Site/Template ordering comparison from changelog

* CI: install the declared thold dependency so Monitor can install under Cacti's new install-failure reporting

* Monitor: remove the filter-status/criticality footer and prompt from the board

* Monitor: give the busiest grouping cards (span 3-4) a full row so their tiles span the page

* Monitor: changelog for full-width busy cards and footer removal

* Monitor: drop Site grouping header, tighten tile uptime spacing, add List column tips

* Monitor: add clickable List-view thold/servcheck/ping/criticality pills with filter toggles

* Monitor: unit tests for new filter branches and thold/servcheck count helpers

* Monitor: selectable status pill, fallback/empty pills, hide absent plugin columns, tile sizing, dialog UX

* Monitor: center and keep clickable the tile hostname, remove 'Names only' view option (backend retained)

* Monitor: multi-select Events filter + Events column (additive), Status becomes scope-only Show

* Monitor: apply the Events multi-select on dropdown close (and on change for native selects)

* Add All criticality, Device Template multi-select, servcheck scope

- Add an "All" option to the Criticality filter. Its value (-1) matches the
  default and the pill "clear to default" action, which previously set the
  select to a non-existent option and submitted crit=null, tripping
  validate_store_request_vars.
- Convert the Device Template filter to a checkbox multi-select (like Events)
  and actually apply it: renderWhereJoin now adds
  h.host_template_id IN (...) for the template grouping (0 = Non-Templated).
  Previously the template selection was never applied, so all hosts returned.
- Persist the template selection as a user setting and serialize the
  multi-value for request/save payloads.
- Add a "Devices without Service Checks" scope to the Show filter, shown only
  when the servcheck plugin is installed and permitted.
- Tests + regenerated .pot.

* Update template escaping assertions for the multiselect markup

* Cover empty-template branch in renderWhereJoin

* Left-align Actions glyph and harden Device Detail Panels dialog layout

- Left-align the List-view Actions column header and fa-layer-group glyph.
- Stack the detail panels as plain blocks and force the jQuery UI dialog
  content box to always fill the dialog width, so the panels can no longer
  collapse into a narrow right-hand strip.

* Size the detail dialog to its content instead of forcing overflow

The #monitorPanelDialog overflow override (higher specificity than the
theme's .ui-dialog .ui-dialog-content) turned the content into a fixed-height
scroll box that fought the theme's overflow:visible and squashed the panels.
Use height:auto (capped by maxHeight) and stop overriding overflow so the
dialog sizes to its content and the theme governs scrolling.

* Anchor the detail dialog 300px from the top instead of centering

* Make the detail panels dialog non-modal

* Move Notes out of the List view into the detail dialog

The Notes column consumed a lot of horizontal space in List view. Drop it
from the table and surface the notes in the Host Information panel of the
Device Detail Panels dialog instead (shown only when the device has notes).

* Add device-edit action icon and Devices filter button (realm-gated)

List view: add a pen-square action icon linking to host.php?action=edit for
users with the host.php (device edit) realm, beside the detail-panels icon.
Filter bar: add a Devices button (right of Acknowledge) that opens the Cacti
Devices page, shown only to users with the same realm. Also move the detail
dialog anchor from top+300 to top+150.

* Anchor the detail dialog at top+80

* List view: replace Hostname column with a Graphs action glyph

Remove the Hostname column and add an orange fas fa-chart-line Graphs glyph
(.monitorGraph, matching HMIB's colour) to the Actions column, linking to the
device's graphs where the hostname hyperlink used to point.

* List columns + filter layout refinements

- List columns reordered to Description, Location, Site, ID; add a Location
  column; Averages, Length in Status, Last Fail are now the last three
  columns, all right-aligned.
- rfilter Search now also matches the device location.
- Filter: move the Events multiselect to the second row (right of Search) and
  the Rows dropdown to the second row (left of Refresh); rename that dropdown
  from Devices to Rows.
- Add a renderWhereJoin test for the description/location rfilter search.

* Monitor: realm-gate service-check scope/column, persist events+template, legacy status reset, failed_svc independent, defer list counts, pill keyboard a11y, PHPDoc/changelog fixes
2026-10-12 06:26:35 -04:00

615 lines
20 KiB
PHP

<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDTool-based Graphing Solution |
+-------------------------------------------------------------------------+
| This code is designed, written, and maintained by the Cacti Group. See |
| about.php and/or the AUTHORS file for specific developer information. |
+-------------------------------------------------------------------------+
| http://www.cacti.net/ |
+-------------------------------------------------------------------------+
*/
/**
* Build thold SQL predicate based on selected monitor status filter.
*
* @return string
*/
function getTholdWhere(): string {
if (get_request_var('status') == '2') { // breached
return "(td.thold_enabled = 'on'
AND (td.thold_alert != 0 OR td.bl_alert > 0))";
} else { // triggered
return "(td.thold_enabled='on'
AND ((td.thold_alert != 0 AND td.thold_fail_count >= td.thold_fail_trigger)
OR (td.bl_alert > 0 AND td.bl_fail_count >= td.bl_fail_trigger)))";
}
}
/**
* Get host ids currently associated with triggered/breached thresholds.
*
* @return array
*/
function checkTholds(): array {
$thold_hosts = [];
if (api_plugin_is_enabled('thold')) {
return array_rekey(
db_fetch_assoc('SELECT DISTINCT dl.host_id
FROM thold_data AS td
INNER JOIN data_local AS dl
ON td.local_data_id=dl.id
WHERE ' . getTholdWhere()),
'host_id',
'host_id'
);
}
return $thold_hosts;
}
/**
* Get failing/triggered service checks for a host.
*
* The servcheck plugin keys its tests by hostname/ipaddress rather than by
* Cacti host id, so matching is done against the device's hostname. A check
* is considered failing when it is enabled, has run at least once, and is
* either in a triggered down state or its last result was not successful.
*
* The query adapts to the installed servcheck schema: released v0.3 names the
* check-time column `lastcheck` and keeps result data in plugin_servcheck_log,
* while v0.4+ renamed it to `last_check` and added last_result/last_error to
* the test table. When the result columns are absent the triggered/failures
* counters are used to detect a failing check.
*
* @param array $host Host row (requires the 'hostname' key).
*
* @return array Failing servcheck test rows, ordered most severe first.
*/
function getHostTriggeredServchecks(array $host): array {
if (!api_plugin_is_enabled('servcheck')) {
return [];
}
if (!isset($host['hostname']) || $host['hostname'] == '') {
return [];
}
if (!db_table_exists('plugin_servcheck_test')) {
return [];
}
$check_col = db_column_exists('plugin_servcheck_test', 'last_check') ? 'last_check' : 'lastcheck';
if (db_column_exists('plugin_servcheck_test', 'last_result')) {
$select = 'id, name, triggered, last_result, last_error';
$fail_predicate = "(triggered > 0 OR (last_result != 'ok' AND last_result != 'not yet'))";
} else {
$select = 'id, name, triggered';
$fail_predicate = '(triggered > 0 OR failures > 0)';
}
return db_fetch_assoc_prepared(
"SELECT $select
FROM plugin_servcheck_test
WHERE enabled = 'on'
AND (hostname = ? OR ipaddress = ?)
AND $check_col > 0
AND $fail_predicate
ORDER BY triggered DESC, $check_col DESC",
[$host['hostname'], $host['hostname']]
);
}
/**
* Get host ids that currently have a failing/triggered service check.
*
* Mirrors checkTholds() for the servcheck plugin: it resolves the servcheck
* tests (keyed by hostname/ipaddress) that are failing into Cacti host ids so
* the monitor board, list and status filters can flag them. Realm-gated so the
* promoted status is only visible to users with Service Check access, and
* schema-aware across servcheck v0.3 (lastcheck/failures) and v0.4+
* (last_check/last_result).
*
* @return array Map of host_id => host_id.
*/
function checkServchecks(): array {
$servcheck_hosts = [];
if (!api_plugin_is_enabled('servcheck') || !api_plugin_user_realm_auth('servcheck_test.php')) {
return $servcheck_hosts;
}
if (!db_table_exists('plugin_servcheck_test')) {
return $servcheck_hosts;
}
$check_col = db_column_exists('plugin_servcheck_test', 'last_check') ? 'last_check' : 'lastcheck';
if (db_column_exists('plugin_servcheck_test', 'last_result')) {
$fail_predicate = "(sct.triggered > 0 OR (sct.last_result != 'ok' AND sct.last_result != 'not yet'))";
} else {
$fail_predicate = '(sct.triggered > 0 OR sct.failures > 0)';
}
return array_rekey(
db_fetch_assoc("SELECT DISTINCT h.id
FROM host AS h
INNER JOIN plugin_servcheck_test AS sct
ON (sct.hostname = h.hostname OR sct.ipaddress = h.hostname)
WHERE sct.enabled = 'on'
AND sct.$check_col > 0
AND $fail_predicate
AND h.deleted = ''"),
'id',
'id'
);
}
/**
* Per-host threshold counts for the list-view Thresholds pill.
*
* @return array Map of host_id => ['total' => int, 'triggered' => int,
* 'breached' => int]. Empty when thold is unavailable.
*/
function monitorTholdCounts(): array {
if (!api_plugin_is_enabled('thold') || !db_table_exists('thold_data')) {
return [];
}
$rows = db_fetch_assoc("SELECT dl.host_id,
COUNT(DISTINCT td.id) AS total,
SUM(td.thold_enabled = 'on'
AND ((td.thold_alert != 0 AND td.thold_fail_count >= td.thold_fail_trigger)
OR (td.bl_alert > 0 AND td.bl_fail_count >= td.bl_fail_trigger))) AS triggered,
SUM(td.thold_enabled = 'on'
AND td.bl_alert > 0 AND td.bl_fail_count >= td.bl_fail_trigger) AS breached
FROM thold_data AS td
INNER JOIN data_local AS dl
ON td.local_data_id = dl.id
WHERE td.thold_enabled = 'on'
GROUP BY dl.host_id");
$map = [];
foreach ($rows as $row) {
$map[$row['host_id']] = [
'total' => (int) $row['total'],
'triggered' => (int) $row['triggered'],
'breached' => (int) $row['breached'],
];
}
return $map;
}
/**
* Per-host service-check counts for the list-view Service Checks pill.
*
* Servcheck tests are keyed by hostname/ipaddress (no host id), schema-aware
* across servcheck v0.3 (lastcheck/failures) and v0.4+ (last_check/last_result),
* and realm-gated like checkServchecks().
*
* @return array Map of host_id => ['total' => int, 'failed' => int]. Empty when
* servcheck is unavailable or not permitted.
*/
function monitorServcheckCounts(): array {
if (!api_plugin_is_enabled('servcheck') || !api_plugin_user_realm_auth('servcheck_test.php')) {
return [];
}
if (!db_table_exists('plugin_servcheck_test')) {
return [];
}
$check_col = db_column_exists('plugin_servcheck_test', 'last_check') ? 'last_check' : 'lastcheck';
if (db_column_exists('plugin_servcheck_test', 'last_result')) {
$fail_predicate = "(sct.triggered > 0 OR (sct.last_result != 'ok' AND sct.last_result != 'not yet'))";
} else {
$fail_predicate = '(sct.triggered > 0 OR sct.failures > 0)';
}
$rows = db_fetch_assoc("SELECT h.id AS host_id,
COUNT(DISTINCT sct.id) AS total,
SUM(sct.$check_col > 0 AND $fail_predicate) AS failed
FROM host AS h
INNER JOIN plugin_servcheck_test AS sct
ON (sct.hostname = h.hostname OR sct.ipaddress = h.hostname)
WHERE sct.enabled = 'on'
AND h.deleted = ''
GROUP BY h.id");
$map = [];
foreach ($rows as $row) {
$map[$row['host_id']] = [
'total' => (int) $row['total'],
'failed' => (int) $row['failed'],
];
}
return $map;
}
/**
* Append an IN-clause fragment to an existing SQL where string.
*
* @param string $sql_where SQL where fragment, updated in place.
* @param string $sql_join Join token used between predicates (e.g. AND/OR).
* @param string $sql_field Field name to compare with IN list.
* @param string $sql_data Comma-delimited values for the IN list.
* @param string $sql_suffix Optional suffix appended inside predicate.
*
* @return void
*/
function renderGroupConcat(string &$sql_where, string $sql_join, string $sql_field, string $sql_data, string $sql_suffix = ''): void {
// Remove empty entries if something was returned
if (!empty($sql_data)) {
$sql_data = trim(str_replace(',,', ',', $sql_data), ',');
if (!empty($sql_data)) {
$sql_where .= ($sql_where != '' ? $sql_join : '') . "($sql_field IN($sql_data) $sql_suffix)";
}
}
}
/**
* Build the additive (OR-combined) SQL predicate for the selected Events.
*
* Each event maps to a host-table predicate; a device matches when it satisfies
* any selected event. Triggered/muted sets come from the precomputed thold,
* servcheck and session mute id lists.
*
* @param array $events Validated event keys currently selected.
*
* @return string Parenthesised OR predicate, or '' when nothing is selected.
*/
function monitorEventFilterSql(array $events): string {
global $thold_hosts, $servcheck_hosts;
if (!cacti_sizeof($events)) {
return '';
}
$thold_ids = (isset($thold_hosts) && cacti_sizeof($thold_hosts)) ? implode(',', array_map('intval', $thold_hosts)) : '0';
$servcheck_ids = (isset($servcheck_hosts) && cacti_sizeof($servcheck_hosts)) ? implode(',', array_map('intval', $servcheck_hosts)) : '0';
$muted = (isset($_SESSION['monitor_muted_hosts']) && cacti_sizeof($_SESSION['monitor_muted_hosts'])) ? implode(',', array_map('intval', $_SESSION['monitor_muted_hosts'])) : '0';
$map = [
'down' => "(h.status = 1 AND h.id NOT IN ($muted))",
'recovering' => '(h.status = 2)',
'triggered' => "(h.id IN ($thold_ids) AND h.id NOT IN ($muted))",
'down_ma' => "(h.status = 1 AND h.id IN ($muted))",
'triggered_ma' => "(h.id IN ($thold_ids) AND h.id IN ($muted))",
'warn_ping' => '(h.status = 3 AND h.monitor_warn > 0 AND h.cur_time > h.monitor_warn AND NOT (h.monitor_alert > 0 AND h.cur_time > h.monitor_alert))',
'alert_ping' => '(h.status = 3 AND h.monitor_alert > 0 AND h.cur_time > h.monitor_alert)',
'failed_svc' => "(h.id IN ($servcheck_ids))",
];
$preds = [];
foreach ($events as $event) {
if (isset($map[$event])) {
$preds[] = $map[$event];
}
}
if (!cacti_sizeof($preds)) {
return '';
}
return '(' . implode(' OR ', $preds) . ')';
}
/**
* Build core monitor host query join/where fragments from current filters.
*
* @param string $sql_where SQL where fragment, updated in place.
* @param string $sql_join SQL join fragment, updated in place.
*
* @return void
*/
function renderWhereJoin(string &$sql_where, string &$sql_join): void {
global $servcheck_hosts;
if (get_request_var('crit') > 0) {
$awhere = 'h.monitor_criticality >= ' . get_request_var('crit');
} elseif (get_request_var('crit') == 0) {
// 'Not Set' pill / None selection: devices with no criticality assigned.
$awhere = 'h.monitor_criticality = 0';
} else {
$awhere = '';
}
if (get_request_var('grouping') == 'site') {
if (get_request_var('site') > 0) {
$awhere .= ($awhere == '' ? '' : ' AND ') . 'h.site_id = ' . get_request_var('site');
} elseif (get_request_var('site') == -2) {
$awhere .= ($awhere == '' ? '' : ' AND ') . ' h.site_id = 0';
}
}
if (get_request_var('rfilter') != '') {
$awhere .= ($awhere == '' ? '' : ' AND ') . " (h.description RLIKE '" . get_request_var('rfilter') . "' OR h.location RLIKE '" . get_request_var('rfilter') . "')";
}
if (get_request_var('grouping') == 'tree') {
if (get_request_var('tree') > 0) {
$hlist = db_fetch_cell_prepared(
'SELECT GROUP_CONCAT(DISTINCT host_id)
FROM graph_tree_items AS gti
INNER JOIN host AS h
ON h.id = gti.host_id
WHERE host_id > 0
AND graph_tree_id = ?
AND h.deleted = ""',
[get_request_var('tree')]
);
renderGroupConcat($awhere, ' AND ', 'h.id', $hlist);
} elseif (get_request_var('tree') == -2) {
$hlist = db_fetch_cell('SELECT GROUP_CONCAT(DISTINCT h.id)
FROM host AS h
LEFT JOIN (SELECT DISTINCT host_id FROM graph_tree_items WHERE host_id > 0) AS gti
ON h.id = gti.host_id
WHERE gti.host_id IS NULL
AND h.deleted = ""');
renderGroupConcat($awhere, ' AND ', 'h.id', $hlist);
}
}
// Multi-select Device Template filter (host_template_id IN list; 0 =
// Non-Templated). Empty selection means all templates.
if (get_request_var('grouping') == 'template') {
$tpl_raw = get_nfilter_request_var('template');
if (is_array($tpl_raw)) {
$tpls = $tpl_raw;
} elseif ($tpl_raw === '' || $tpl_raw === null) {
$tpls = [];
} else {
$tpls = explode(',', (string) $tpl_raw);
}
$tpls = array_map('intval', array_filter(array_map('trim', $tpls), static function ($t) {
return ctype_digit((string) $t);
}));
if (cacti_sizeof($tpls)) {
$awhere .= ($awhere == '' ? '' : ' AND ') . 'h.host_template_id IN (' . implode(',', $tpls) . ')';
}
}
// Additive Events filter (OR-combined) from the multi-select.
$events_raw = get_nfilter_request_var('events');
if (is_array($events_raw)) {
$events = $events_raw;
} elseif ($events_raw === '' || $events_raw === null) {
$events = [];
} else {
$events = array_map('trim', explode(',', (string) $events_raw));
}
$event_sql = monitorEventFilterSql($events);
if ($event_sql !== '') {
$awhere .= ($awhere != '' ? ' AND ' : '') . $event_sql;
}
if (!empty($awhere)) {
$awhere = ' AND ' . $awhere;
}
if (get_request_var('status') == -2) {
// All Devices (monitored or not).
$sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id';
$sql_where = 'WHERE h.disabled = ""
AND h.deleted = ""' . $awhere;
} elseif (get_request_var('status') == -3) {
// Devices not Monitored.
$sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id';
$sql_where = 'WHERE h.disabled = ""
AND h.monitor = ""
AND h.deleted = ""' . $awhere;
} elseif (get_request_var('status') == -4) {
// Devices without Thresholds.
$sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id';
$sql_where = 'WHERE (h.disabled = ""
AND h.deleted = ""
AND td.id IS NULL)' . $awhere;
} elseif (get_request_var('status') == -5 && api_plugin_is_enabled('servcheck') && api_plugin_user_realm_auth('servcheck_test.php') && db_table_exists('plugin_servcheck_test')) {
// Devices without Service Checks (no enabled servcheck test matches the
// device hostname/ipaddress). Realm-gated server-side: hiding the scope
// option in monitor.php is not authorization, so a user without Service
// Check access cannot submit status=-5 to probe which devices have them.
$sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id';
$sql_where = 'WHERE h.disabled = ""
AND h.deleted = ""
AND NOT EXISTS (
SELECT 1 FROM plugin_servcheck_test AS sct
WHERE sct.enabled = "on"
AND (sct.hostname = h.hostname OR sct.ipaddress = h.hostname)
)' . $awhere;
} else {
// All Monitored Devices (default scope, status -1). validateRequestVars()
// normalizes any legacy availability-status code (0/1/2/...) or other
// out-of-range value saved by an older release to -1 before we get here,
// so this branch only ever serves the -1 scope rather than silently
// broadening a stale status into All Monitored Devices.
$sql_join = 'LEFT JOIN thold_data AS td ON td.host_id=h.id';
$sql_where = 'WHERE h.disabled = ""
AND h.monitor = "on"
AND h.deleted = ""
AND (h.availability_method > 0 OR h.snmp_version > 0
OR ((td.thold_enabled="on" AND td.thold_alert > 0)
OR td.id IS NULL)
)' . $awhere;
}
}
/**
* Return host ids that are down/triggered and visible to current user.
*
* @param bool $prescan Whether to use prescan severity threshold.
*
* @return array
*/
function getHostsDownOrTriggeredByPermission(bool $prescan): array {
global $render_style;
$PreScanValue = 2;
if ($prescan) {
$PreScanValue = 3;
}
$result = [];
if (get_request_var('crit') > 0) {
$sql_add_where = 'monitor_criticality >= ' . get_request_var('crit');
} else {
$sql_add_where = '';
}
if (get_request_var('grouping') == 'tree') {
if (get_request_var('tree') > 0) {
$devices = db_fetch_cell_prepared(
'SELECT GROUP_CONCAT(DISTINCT host_id) AS hosts
FROM graph_tree_items AS gti
INNER JOIN host AS h
WHERE host_id > 0
AND h.deleted = ""
AND graph_tree_id = ?',
[get_request_var('tree')]
);
renderGroupConcat($sql_add_where, ' OR ', 'h.id', $devices, 'AND h.status < 2');
}
}
if (get_request_var('status') > 0) {
$triggered = db_fetch_cell('SELECT GROUP_CONCAT(DISTINCT host_id) AS hosts
FROM host AS h
INNER JOIN thold_data AS td
ON td.host_id = h.id
WHERE ' . getTholdWhere() . '
AND h.deleted = ""');
renderGroupConcat($sql_add_where, ' OR ', 'h.id', $triggered, 'AND h.status > 1');
$_SESSION['monitor_triggered'] = array_rekey(
db_fetch_assoc('SELECT td.host_id, COUNT(DISTINCT td.id) AS triggered
FROM thold_data AS td
INNER JOIN host AS h
ON td.host_id = h.id
WHERE ' . getTholdWhere() . '
AND h.deleted = ""
GROUP BY td.host_id'),
'host_id',
'triggered'
);
}
$sql_where = "h.monitor = 'on'
AND h.disabled = ''
AND h.deleted = ''
AND ((h.status < " . $PreScanValue . ' AND (h.availability_method > 0 OR h.snmp_version > 0)) ' .
($sql_add_where != '' ? ' OR (' . $sql_add_where . '))' : ')');
// do a quick loop through to pull the hosts that are down
$hosts = get_allowed_devices($sql_where);
if (cacti_sizeof($hosts)) {
foreach ($hosts as $host) {
$result[] = $host['id'];
sort($result);
}
}
return $result;
}
/**
* Return non-tree monitor hosts after applying current filters.
*
* @return array
*/
function getHostNonTreeArray(): array {
$leafs = [];
$sql_where = '';
$sql_join = '';
renderWhereJoin($sql_where, $sql_join);
$hierarchy = db_fetch_assoc("SELECT DISTINCT
h.*, gti.title, gti.host_id, gti.host_grouping_type, gti.graph_tree_id
FROM host AS h
LEFT JOIN graph_tree_items AS gti
ON h.id=gti.host_id
$sql_join
$sql_where
AND gti.graph_tree_id IS NULL
ORDER BY h.description");
if (cacti_sizeof($hierarchy) > 0) {
$leafs = [];
$branchleafs = 0;
foreach ($hierarchy as $leaf) {
$leafs[$branchleafs] = $leaf;
$branchleafs++;
}
}
return $leafs;
}
/**
* Render the Monitor status legend as a row of equal-width, rounded colour
* chips (one per interface/device status). Each chip is sized to the longest
* label via the --monitor-chip-min custom property so the grid stays a row of
* equal chips as it wraps.
*
* @param array $iclasses Status index => CSS status class (the chip carries "<class>Full").
* @param array $icolorsdisplay Status index => human-readable label.
*
* @return void Outputs the legend HTML directly.
*/
function monitor_legend(array $iclasses, array $icolorsdisplay): void {
$chip_min = 0;
foreach ($iclasses as $index => $class) {
$chip_min = max($chip_min, mb_strlen($icolorsdisplay[$index]));
}
print "<div class='monitorLegend' style='--monitor-chip-min: calc({$chip_min}ch + 1.5rem)'>";
foreach ($iclasses as $index => $class) {
print "<div class='monitorLegendItem $class" . "Full'>" . html_escape($icolorsdisplay[$index]) . '</div>';
}
print '</div>';
}