diff --git a/docs/configs/services.md b/docs/configs/services.md index 29c135626..f816e646b 100644 --- a/docs/configs/services.md +++ b/docs/configs/services.md @@ -152,7 +152,7 @@ Widgets can tint their metric blocks automatically based on rules defined alongs value: "All good" ``` -Supported numeric operators for the `when` property are `gt`, `gte`, `lt`, `lte`, `eq`, `ne`, `between`, and `outside`. String rules support `equals`, `includes`, `startsWith`, `endsWith`, and `regex`. Each rule can be inverted with `negate: true`, and string rules may pass `caseSensitive: true` or custom regex `flags`. If you format values before passing them into ``, also pass the unformatted number or string via the `valueRaw` prop so the highlight engine can evaluate correctly. +Supported numeric operators for the `when` property are `gt`, `gte`, `lt`, `lte`, `eq`, `ne`, `between`, and `outside`. String rules support `equals`, `includes`, `startsWith`, `endsWith`, and `regex`. Each rule can be inverted with `negate: true`, and string rules may pass `caseSensitive: true` or custom regex `flags`. The highlight engine does its best to coerce formatted values, but you will get the most reliable results when you pass plain numbers or strings into ``. ## Descriptions diff --git a/docs/configs/settings.md b/docs/configs/settings.md index e1863b8d7..82f1cce7a 100644 --- a/docs/configs/settings.md +++ b/docs/configs/settings.md @@ -111,14 +111,14 @@ Supported colors are: `slate`, `gray`, `zinc`, `neutral`, `stone`, `amber`, `yel ## Block Highlight Levels -You can override the default Tailwind classes applied when a widget highlight rule resolves to the `good`, `warn`, or `danger` level. Only the `levels` map is read from `settings.yaml`; all highlight rules themselves are defined per widget in `services.yaml`. +You can override the default Tailwind classes applied when a widget highlight rule resolves to the `good`, `warn`, or `danger` level. ```yaml blockHighlights: levels: - good: "bg-emerald-400/30 text-emerald-900 dark:bg-emerald-900/30 dark:text-emerald-100" - warn: "bg-amber-300/30 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100" - danger: "bg-rose-400/30 text-rose-900 dark:bg-rose-900/30 dark:text-rose-100" + good: "bg-emerald-500/40 text-emerald-950 dark:bg-emerald-900/60 dark:text-emerald-400", + warn: "bg-amber-300/30 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200", + danger: "bg-rose-700/45 text-rose-200 dark:bg-rose-950/70 dark:text-rose-400", ``` Any unspecified level falls back to the built-in defaults. diff --git a/src/components/services/widget/block.jsx b/src/components/services/widget/block.jsx index eb52aecc0..2393d9452 100644 --- a/src/components/services/widget/block.jsx +++ b/src/components/services/widget/block.jsx @@ -6,7 +6,7 @@ import { evaluateHighlight, getHighlightClass } from "utils/highlights"; import { BlockHighlightContext } from "./highlight-context"; -export default function Block({ value, valueRaw, label, field }) { +export default function Block({ value, label, field }) { const { t } = useTranslation(); const highlightConfig = useContext(BlockHighlightContext); @@ -14,9 +14,8 @@ export default function Block({ value, valueRaw, label, field }) { if (!highlightConfig) return null; const fieldKey = field || label; if (!fieldKey) return null; - const candidateValue = valueRaw ?? value; - return evaluateHighlight(fieldKey, candidateValue, highlightConfig); - }, [field, label, value, valueRaw, highlightConfig]); + return evaluateHighlight(fieldKey, value, highlightConfig); + }, [field, label, value, highlightConfig]); const highlightClass = useMemo(() => { if (!highlight?.level) return undefined; diff --git a/src/utils/highlights.js b/src/utils/highlights.js index c20ef945c..b9f6bc5e4 100644 --- a/src/utils/highlights.js +++ b/src/utils/highlights.js @@ -13,8 +13,14 @@ const normalizeFieldKeys = (fields, widgetType) => { const trimmedKey = key.trim(); if (trimmedKey === "") return acc; - const targetKey = widgetType ? `${widgetType}.${trimmedKey}` : trimmedKey; - acc[targetKey] = value; + acc[trimmedKey] = value; + + if (widgetType && !trimmedKey.includes(".")) { + const namespacedKey = `${widgetType}.${trimmedKey}`; + if (!(namespacedKey in acc)) { + acc[namespacedKey] = value; + } + } return acc; }, {}); @@ -76,65 +82,59 @@ const parseNumericValue = (value) => { const trimmed = value.trim(); if (!trimmed) return undefined; - const numericMatch = trimmed.match(/[-+]?\d+(?:[\d.,]*\d)?/); - if (!numericMatch) return undefined; + const direct = Number(trimmed); + if (!Number.isNaN(direct)) return direct; - const numeric = numericMatch[0]; - const separators = numeric.match(/[.,]/g) ?? []; - const uniqueSeparators = [...new Set(separators)]; + const compact = trimmed.replace(/\s+/g, ""); + if (!compact || !/^[-+]?[0-9.,]+$/.test(compact)) return undefined; - if (uniqueSeparators.length === 0) { - const parsed = Number(numeric); - return Number.isNaN(parsed) ? undefined : parsed; - } + const commaCount = (compact.match(/,/g) || []).length; + const dotCount = (compact.match(/\./g) || []).length; - if (uniqueSeparators.length >= 2) { - const lastComma = numeric.lastIndexOf(","); - const lastDot = numeric.lastIndexOf("."); - const decimalSeparator = lastComma > lastDot ? "," : "."; - const thousandsSeparator = decimalSeparator === "." ? "," : "."; - - let canonical = numeric; - const thousandsPattern = thousandsSeparator === "." ? /\./g : /,/g; - canonical = canonical.replace(thousandsPattern, ""); - if (decimalSeparator === ",") { - canonical = canonical.replace(/,/g, "."); - } - - const parsed = Number(canonical); - return Number.isNaN(parsed) ? undefined : parsed; - } - - const separator = uniqueSeparators[0]; - const occurrences = separators.length; - - if (separator === ".") { - if (occurrences === 1) { - const parsed = Number(numeric); + if (commaCount && dotCount) { + const lastComma = compact.lastIndexOf(","); + const lastDot = compact.lastIndexOf("."); + if (lastComma > lastDot) { + const asDecimal = compact.replace(/\./g, "").replace(/,/g, "."); + const parsed = Number(asDecimal); return Number.isNaN(parsed) ? undefined : parsed; } - - const canonical = numeric.replace(/\./g, ""); - const parsed = Number(canonical); + const asThousands = compact.replace(/,/g, ""); + const parsed = Number(asThousands); return Number.isNaN(parsed) ? undefined : parsed; } - if (separator === ",") { - if (occurrences === 1) { - const decimalCandidate = Number(numeric.replace(/,/g, ".")); - const thousandsCandidate = Number(numeric.replace(/,/g, "")); - - const candidates = [thousandsCandidate, decimalCandidate].filter((candidate) => !Number.isNaN(candidate)); - if (candidates.length === 0) return undefined; - if (candidates.length === 1) return candidates[0]; - const uniqueCandidates = [...new Set(candidates)]; - return uniqueCandidates.length === 1 ? uniqueCandidates[0] : uniqueCandidates; + if (commaCount) { + const parts = compact.split(","); + if (commaCount === 1 && parts[1]?.length <= 2) { + const parsed = Number(compact.replace(",", ".")); + if (!Number.isNaN(parsed)) return parsed; } + const isGrouped = parts.length > 1 && parts.slice(1).every((part) => part.length === 3); + if (isGrouped) { + const parsed = Number(compact.replace(/,/g, "")); + if (!Number.isNaN(parsed)) return parsed; + } + return undefined; + } - const canonical = numeric.replace(/,/g, ""); - const parsed = Number(canonical); + if (dotCount) { + const parts = compact.split("."); + if (dotCount === 1 && parts[1]?.length <= 2) { + const parsed = Number(compact); + if (!Number.isNaN(parsed)) return parsed; + } + const isGrouped = parts.length > 1 && parts.slice(1).every((part) => part.length === 3); + if (isGrouped) { + const parsed = Number(compact.replace(/\./g, "")); + if (!Number.isNaN(parsed)) return parsed; + } + const parsed = Number(compact); return Number.isNaN(parsed) ? undefined : parsed; } + + const parsed = Number(compact); + return Number.isNaN(parsed) ? undefined : parsed; } if (typeof value === "object" && value !== null && "props" in value) {