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;