From 91d12c401ceab8ba36222d1e4507031626be5f27 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 13 Oct 2025 19:37:52 -0700 Subject: [PATCH] Feature: fields highlighting (#5868) --- docs/configs/services.md | 41 +++ docs/configs/settings.md | 14 + src/components/services/widget/block.jsx | 33 ++- src/components/services/widget/container.jsx | 17 +- .../services/widget/highlight-context.jsx | 3 + src/utils/config/service-helpers.js | 16 ++ src/utils/highlights.js | 257 ++++++++++++++++++ 7 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 src/components/services/widget/highlight-context.jsx create mode 100644 src/utils/highlights.js diff --git a/docs/configs/services.md b/docs/configs/services.md index 86970b92a..9ae24b15e 100644 --- a/docs/configs/services.md +++ b/docs/configs/services.md @@ -118,6 +118,47 @@ Each widget can optionally provide a list of which fields should be visible via key: apikeyapikeyapikeyapikeyapikey ``` +### Block Highlighting + +Widgets can tint their metric block text automatically based on rules defined alongside the service. Attach a `highlight` section to the widget configuration and map each block to one or more numeric or string rules using the field key (for example, `queued`, `lan_users`). + +```yaml +- Sonarr: + icon: sonarr.png + href: http://sonarr.host.or.ip + widget: + type: sonarr + url: http://sonarr.host.or.ip + key: ${SONARR_API_KEY} + highlight: + queued: + numeric: + - level: danger + when: gte + value: 20 + - level: warn + when: gte + value: 5 + - level: good + when: eq + value: 0 + status: + string: + - level: danger + when: regex + value: "(failed|import) pending" + - level: good + when: equals + value: "All good" + status_code: + string: + - level: warn + when: regex + value: "^5\\d{2}$" +``` + +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 Services may have descriptions, diff --git a/docs/configs/settings.md b/docs/configs/settings.md index c24793df3..8c0e2ff85 100644 --- a/docs/configs/settings.md +++ b/docs/configs/settings.md @@ -109,6 +109,20 @@ color: slate Supported colors are: `slate`, `gray`, `zinc`, `neutral`, `stone`, `amber`, `yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`, `red`, `white` +## Block Highlight Levels + +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-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. + ## Layout You can configure service and bookmarks sections to be either "column" or "row" based layouts, like so: diff --git a/src/components/services/widget/block.jsx b/src/components/services/widget/block.jsx index 720140cce..2ce5d441d 100644 --- a/src/components/services/widget/block.jsx +++ b/src/components/services/widget/block.jsx @@ -1,16 +1,47 @@ import classNames from "classnames"; import { useTranslation } from "next-i18next"; +import { useContext, useMemo } from "react"; -export default function Block({ value, label }) { +import { BlockHighlightContext } from "./highlight-context"; + +import { evaluateHighlight, getHighlightClass } from "utils/highlights"; + +export default function Block({ value, label, field }) { const { t } = useTranslation(); + const highlightConfig = useContext(BlockHighlightContext); + + const highlight = useMemo(() => { + if (!highlightConfig) return null; + const labels = Array.isArray(label) ? label : [label]; + const candidates = []; + if (typeof field === "string") candidates.push(field); + for (const candidateLabel of labels) { + if (typeof candidateLabel === "string") candidates.push(candidateLabel); + } + + for (const candidate of candidates) { + const result = evaluateHighlight(candidate, value, highlightConfig); + if (result) return result; + } + + return null; + }, [field, label, value, highlightConfig]); + + const highlightClass = useMemo(() => { + if (!highlight?.level) return undefined; + return getHighlightClass(highlight.level, highlightConfig); + }, [highlight, highlightConfig]); return (
{value === undefined || value === null ? "-" : value}
{t(label)}
diff --git a/src/components/services/widget/container.jsx b/src/components/services/widget/container.jsx index 6458e5601..e5962e445 100644 --- a/src/components/services/widget/container.jsx +++ b/src/components/services/widget/container.jsx @@ -1,7 +1,10 @@ -import { useContext } from "react"; +import { useContext, useMemo } from "react"; import { SettingsContext } from "utils/contexts/settings"; import Error from "./error"; +import { BlockHighlightContext } from "./highlight-context"; + +import { buildHighlightConfig } from "utils/highlights"; const ALIASED_WIDGETS = { pialert: "netalertx", @@ -11,6 +14,11 @@ const ALIASED_WIDGETS = { export default function Container({ error = false, children, service }) { const { settings } = useContext(SettingsContext); + const highlightConfig = useMemo( + () => buildHighlightConfig(settings?.blockHighlights, service?.widget?.highlight, service?.widget?.type), + [settings?.blockHighlights, service?.widget?.highlight, service?.widget?.type], + ); + if (error) { if (settings.hideErrors || service.widget.hide_errors) { return null; @@ -51,6 +59,11 @@ export default function Container({ error = false, children, service }) { }), ); } + const content =
{visibleChildren}
; - return
{visibleChildren}
; + if (!highlightConfig) { + return content; + } + + return {content}; } diff --git a/src/components/services/widget/highlight-context.jsx b/src/components/services/widget/highlight-context.jsx new file mode 100644 index 000000000..5bfce391c --- /dev/null +++ b/src/components/services/widget/highlight-context.jsx @@ -0,0 +1,3 @@ +import { createContext } from "react"; + +export const BlockHighlightContext = createContext(null); diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js index 1fcdb975d..c9b5f482c 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -254,6 +254,7 @@ export function cleanServiceGroups(groups) { // all widgets fields, hideErrors, + highlight, type, // azuredevops @@ -441,6 +442,21 @@ export function cleanServiceGroups(groups) { index, }; + if (highlight) { + let parsedHighlight = highlight; + if (typeof highlight === "string") { + try { + parsedHighlight = JSON.parse(highlight); + } catch (e) { + logger.error("Invalid highlight configuration detected in config for service '%s'", service.name); + parsedHighlight = null; + } + } + if (parsedHighlight && typeof parsedHighlight === "object") { + widget.highlight = parsedHighlight; + } + } + if (type === "azuredevops") { if (userEmail) widget.userEmail = userEmail; if (repositoryId) widget.repositoryId = repositoryId; diff --git a/src/utils/highlights.js b/src/utils/highlights.js new file mode 100644 index 000000000..b9f6bc5e4 --- /dev/null +++ b/src/utils/highlights.js @@ -0,0 +1,257 @@ +const DEFAULT_LEVEL_CLASSES = { + 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", +}; + +const normalizeFieldKeys = (fields, widgetType) => { + if (!fields || typeof fields !== "object") return {}; + + return Object.entries(fields).reduce((acc, [key, value]) => { + if (value === null || value === undefined) return acc; + if (typeof key !== "string") return acc; + const trimmedKey = key.trim(); + if (trimmedKey === "") return acc; + + acc[trimmedKey] = value; + + if (widgetType && !trimmedKey.includes(".")) { + const namespacedKey = `${widgetType}.${trimmedKey}`; + if (!(namespacedKey in acc)) { + acc[namespacedKey] = value; + } + } + + return acc; + }, {}); +}; + +export const buildHighlightConfig = (globalConfig, widgetConfig, widgetType) => { + const levels = { + ...DEFAULT_LEVEL_CLASSES, + ...(globalConfig?.levels || {}), + ...(widgetConfig?.levels || {}), + }; + + const { levels: _levels, ...fields } = widgetConfig || {}; + const normalizedFields = normalizeFieldKeys(fields, widgetType); + + const hasLevels = Object.values(levels).some(Boolean); + const hasFields = Object.keys(normalizedFields).length > 0; + + if (!hasLevels && !hasFields) return null; + + return { levels, fields: normalizedFields }; +}; + +const NUMERIC_OPERATORS = { + gt: (value, target) => value > target, + gte: (value, target) => value >= target, + lt: (value, target) => value < target, + lte: (value, target) => value <= target, + eq: (value, target) => value === target, + ne: (value, target) => value !== target, +}; + +const STRING_OPERATORS = { + equals: (value, target, caseSensitive) => + caseSensitive ? value === target : value.toLowerCase() === target.toLowerCase(), + includes: (value, target, caseSensitive) => + caseSensitive ? value.includes(target) : value.toLowerCase().includes(target.toLowerCase()), + startsWith: (value, target, caseSensitive) => + caseSensitive ? value.startsWith(target) : value.toLowerCase().startsWith(target.toLowerCase()), + endsWith: (value, target, caseSensitive) => + caseSensitive ? value.endsWith(target) : value.toLowerCase().endsWith(target.toLowerCase()), +}; + +const toNumber = (value) => { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const trimmed = value.trim(); + const candidate = Number(trimmed); + if (!Number.isNaN(candidate)) return candidate; + } + return undefined; +}; + +const parseNumericValue = (value) => { + if (value === null || value === undefined) return undefined; + if (typeof value === "number" && Number.isFinite(value)) return value; + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return undefined; + + const direct = Number(trimmed); + if (!Number.isNaN(direct)) return direct; + + const compact = trimmed.replace(/\s+/g, ""); + if (!compact || !/^[-+]?[0-9.,]+$/.test(compact)) return undefined; + + const commaCount = (compact.match(/,/g) || []).length; + const dotCount = (compact.match(/\./g) || []).length; + + 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 asThousands = compact.replace(/,/g, ""); + const parsed = Number(asThousands); + return Number.isNaN(parsed) ? undefined : parsed; + } + + 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; + } + + 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) { + return undefined; + } + + return undefined; +}; + +const evaluateNumericRule = (value, rule) => { + if (!rule || typeof rule !== "object") return false; + const operator = rule.when && NUMERIC_OPERATORS[rule.when]; + const numericValue = toNumber(rule.value); + if (operator && numericValue !== undefined) { + const passes = operator(value, numericValue); + return rule.negate ? !passes : passes; + } + + if (rule.when === "between") { + const min = toNumber(rule.min ?? rule.value?.min); + const max = toNumber(rule.max ?? rule.value?.max); + if (min === undefined && max === undefined) return false; + const lowerBound = min ?? Number.NEGATIVE_INFINITY; + const upperBound = max ?? Number.POSITIVE_INFINITY; + const passes = value >= lowerBound && value <= upperBound; + return rule.negate ? !passes : passes; + } + + if (rule.when === "outside") { + const min = toNumber(rule.min ?? rule.value?.min); + const max = toNumber(rule.max ?? rule.value?.max); + if (min === undefined && max === undefined) return false; + const passes = value < (min ?? Number.NEGATIVE_INFINITY) || value > (max ?? Number.POSITIVE_INFINITY); + return rule.negate ? !passes : passes; + } + + return false; +}; + +const evaluateStringRule = (value, rule) => { + if (!rule || typeof rule !== "object") return false; + if (rule.when === "regex" && typeof rule.value === "string") { + try { + const flags = rule.flags || (rule.caseSensitive ? "" : "i"); + const regex = new RegExp(rule.value, flags); + const passes = regex.test(value); + return rule.negate ? !passes : passes; + } catch (error) { + return false; + } + } + + const operator = rule.when && STRING_OPERATORS[rule.when]; + if (!operator || typeof rule.value !== "string") return false; + const passes = operator(value, rule.value, Boolean(rule.caseSensitive)); + return rule.negate ? !passes : passes; +}; + +const ensureArray = (value) => { + if (Array.isArray(value)) return value; + if (value === undefined || value === null) return []; + return [value]; +}; + +const findHighlightLevel = (ruleSet, numericValue, stringValue) => { + const { numeric, string } = ruleSet; + + if (numeric && numericValue !== undefined) { + const numericRules = ensureArray(numeric); + const numericCandidates = Array.isArray(numericValue) ? numericValue : [numericValue]; + for (const candidate of numericCandidates) { + for (const rule of numericRules) { + if (rule?.level && evaluateNumericRule(candidate, rule)) { + return { level: rule.level, source: "numeric", rule }; + } + } + } + } + + if (string && stringValue !== undefined) { + const stringRules = ensureArray(string); + for (const rule of stringRules) { + if (rule?.level && evaluateStringRule(stringValue, rule)) { + return { level: rule.level, source: "string", rule }; + } + } + } + + return null; +}; + +export const evaluateHighlight = (fieldKey, value, highlightConfig) => { + if (!highlightConfig || !fieldKey) return null; + const { fields } = highlightConfig; + if (!fields || typeof fields !== "object") return null; + + const ruleSet = fields[fieldKey]; + if (!ruleSet) return null; + + const numericValue = parseNumericValue(value); + let stringValue; + if (typeof value === "string") { + stringValue = value; + } else if (typeof value === "number" || typeof value === "bigint") { + stringValue = String(value); + } else if (typeof value === "boolean") { + stringValue = value ? "true" : "false"; + } + + const normalizedString = typeof stringValue === "string" ? stringValue.trim() : stringValue; + + return findHighlightLevel(ruleSet, numericValue, normalizedString); +}; + +export const getHighlightClass = (level, highlightConfig) => { + if (!level || !highlightConfig) return undefined; + return highlightConfig.levels?.[level]; +}; + +export const getDefaultHighlightLevels = () => DEFAULT_LEVEL_CLASSES;