From 9fb68953a5fcd7180542b34e73ac43470ca5b239 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 12 Oct 2025 21:21:05 -0700 Subject: [PATCH] Initial work --- src/components/services/widget/block.jsx | 24 +- src/components/services/widget/container.jsx | 16 +- .../services/widget/highlight-context.jsx | 3 + src/utils/highlights.js | 240 ++++++++++++++++++ 4 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 src/components/services/widget/highlight-context.jsx create mode 100644 src/utils/highlights.js diff --git a/src/components/services/widget/block.jsx b/src/components/services/widget/block.jsx index 720140cce..eb52aecc0 100644 --- a/src/components/services/widget/block.jsx +++ b/src/components/services/widget/block.jsx @@ -1,16 +1,38 @@ import classNames from "classnames"; import { useTranslation } from "next-i18next"; +import { useContext, useMemo } from "react"; -export default function Block({ value, label }) { +import { evaluateHighlight, getHighlightClass } from "utils/highlights"; + +import { BlockHighlightContext } from "./highlight-context"; + +export default function Block({ value, valueRaw, label, field }) { const { t } = useTranslation(); + const highlightConfig = useContext(BlockHighlightContext); + + const highlight = useMemo(() => { + 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]); + + 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..6be5cdb7d 100644 --- a/src/components/services/widget/container.jsx +++ b/src/components/services/widget/container.jsx @@ -1,7 +1,9 @@ -import { useContext } from "react"; +import { useContext, useMemo } from "react"; import { SettingsContext } from "utils/contexts/settings"; +import { buildHighlightConfig } from "utils/highlights"; import Error from "./error"; +import { BlockHighlightContext } from "./highlight-context"; const ALIASED_WIDGETS = { pialert: "netalertx", @@ -11,6 +13,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), + [settings?.blockHighlights, service?.widget?.highlight], + ); + if (error) { if (settings.hideErrors || service.widget.hide_errors) { return null; @@ -51,6 +58,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/highlights.js b/src/utils/highlights.js new file mode 100644 index 000000000..2a2d56a77 --- /dev/null +++ b/src/utils/highlights.js @@ -0,0 +1,240 @@ +const DEFAULT_LEVEL_CLASSES = { + 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", +}; + +export const buildHighlightConfig = (globalConfig, widgetConfig) => { + const levels = { + ...DEFAULT_LEVEL_CLASSES, + ...(globalConfig?.levels || {}), + ...(widgetConfig?.levels || {}), + }; + + const fields = widgetConfig?.fields || {}; + + const hasLevels = Object.values(levels).some(Boolean); + const hasFields = Object.keys(fields).length > 0; + + if (!hasLevels && !hasFields) return null; + + return { levels, fields }; +}; + +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 numericMatch = trimmed.match(/[-+]?\d+(?:[\d.,]*\d)?/); + if (!numericMatch) return undefined; + + const numeric = numericMatch[0]; + const separators = numeric.match(/[.,]/g) ?? []; + const uniqueSeparators = [...new Set(separators)]; + + if (uniqueSeparators.length === 0) { + const parsed = Number(numeric); + return Number.isNaN(parsed) ? undefined : parsed; + } + + 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); + return Number.isNaN(parsed) ? undefined : parsed; + } + + const canonical = numeric.replace(/\./g, ""); + const parsed = Number(canonical); + 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; + } + + const canonical = numeric.replace(/,/g, ""); + const parsed = Number(canonical); + 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;