diff --git a/app/vmui/packages/vmui/src/components/Configurators/LogsSettings/LogParsingSwitches.tsx b/app/vmui/packages/vmui/src/components/Configurators/LogsSettings/LogParsingSwitches.tsx index 50fa2f24ce..5d33181d3c 100644 --- a/app/vmui/packages/vmui/src/components/Configurators/LogsSettings/LogParsingSwitches.tsx +++ b/app/vmui/packages/vmui/src/components/Configurators/LogsSettings/LogParsingSwitches.tsx @@ -26,24 +26,22 @@ const LogParsingSwitches: FC = () => { <>
- Toggle this switch to enable or disable the Markdown formatting for log entries. - Enabling this will parse log texts to Markdown. + Parses log text and renders Markdown formatting.
- Toggle this switch to enable or disable ANSI escape sequence parsing for log entries. - Enabling this will interpret ANSI codes to render colored log output. + Renders ANSI escape codes as colored text.
diff --git a/app/vmui/packages/vmui/src/components/Views/GroupView/GroupLogsConfigurators/GroupLogsConfigurators.tsx b/app/vmui/packages/vmui/src/components/Views/GroupView/GroupLogsConfigurators/GroupLogsConfigurators.tsx index 77b69725e1..fc19f28943 100644 --- a/app/vmui/packages/vmui/src/components/Views/GroupView/GroupLogsConfigurators/GroupLogsConfigurators.tsx +++ b/app/vmui/packages/vmui/src/components/Views/GroupView/GroupLogsConfigurators/GroupLogsConfigurators.tsx @@ -40,7 +40,8 @@ const GroupLogsConfigurators: FC = ({ logs }) => { const displayFieldsString = searchParams.get(DISPLAY_FIELDS) || ""; const displayFields = displayFieldsString ? displayFieldsString.split(",") : [LOGS_DISPLAY_FIELDS]; - const [disabledHovers, handleSetDisabledHovers] = useLocalStorageBoolean("LOGS_DISABLED_HOVERS"); + const [disabledHovers, setDisabledHovers] = useLocalStorageBoolean("LOGS_DISABLED_HOVERS"); + const [disabledLevelDetection, setDisabledLevelDetection] = useLocalStorageBoolean("LOGS_DISABLED_LEVEL_DETECTION"); const isGroupChanged = groupBy !== LOGS_GROUP_BY; const isDisplayFieldsChanged = displayFields.length !== 1 || displayFields[0] !== LOGS_DISPLAY_FIELDS; @@ -168,6 +169,17 @@ const GroupLogsConfigurators: FC = ({ logs }) => { +
+ setDisabledLevelDetection(!checked)} + label="Detect log level" + /> + + Displays a log level label next to each log entry. + +
+
@@ -177,7 +189,7 @@ const GroupLogsConfigurators: FC = ({ logs }) => { label="Single-line message" /> - Displays message in a single line and truncates it with an ellipsis if it exceeds the available space. + Truncates long messages to a single line with an ellipsis.
@@ -195,11 +207,11 @@ const GroupLogsConfigurators: FC = ({ logs }) => {
- Disable row highlighting on hover to improve performance with large datasets. + Disables row highlighting on hover to improve performance with large datasets.
diff --git a/app/vmui/packages/vmui/src/components/Views/GroupView/GroupLogsConfigurators/style.scss b/app/vmui/packages/vmui/src/components/Views/GroupView/GroupLogsConfigurators/style.scss index 25ae93ead9..88546d3910 100644 --- a/app/vmui/packages/vmui/src/components/Views/GroupView/GroupLogsConfigurators/style.scss +++ b/app/vmui/packages/vmui/src/components/Views/GroupView/GroupLogsConfigurators/style.scss @@ -4,7 +4,7 @@ display: grid; gap: calc($padding-global * 2); padding: $padding-global 0; - max-width: 600px; + width: min(90vw, 600px); &-item { display: grid; diff --git a/app/vmui/packages/vmui/src/components/Views/GroupView/GroupLogsItem.tsx b/app/vmui/packages/vmui/src/components/Views/GroupView/GroupLogsItem.tsx index 85dc723b3f..475223d8e9 100644 --- a/app/vmui/packages/vmui/src/components/Views/GroupView/GroupLogsItem.tsx +++ b/app/vmui/packages/vmui/src/components/Views/GroupView/GroupLogsItem.tsx @@ -1,4 +1,4 @@ -import { FC, memo, useMemo, useCallback, useEffect, useState, ReactNode } from "preact/compat"; +import { FC, memo, ReactNode, useCallback, useEffect, useMemo, useState } from "preact/compat"; import { Logs } from "../../../api/types"; import "./style.scss"; import useBoolean from "../../../hooks/useBoolean"; @@ -19,6 +19,8 @@ import StreamContextButton from "../../../pages/StreamContext/StreamContextButto import { useAppState } from "../../../state/common/StateContext"; import { formatDateWithNanoseconds } from "../../../utils/time"; import useDeviceDetect from "../../../hooks/useDeviceDetect"; +import { getLogLevel } from "../../../utils/logLevel"; +import { LOG_LEVEL_COLORS } from "../../../constants/logLevel"; interface Props { log: Logs; @@ -29,7 +31,14 @@ interface Props { onItemClick?: (log: Logs) => void; } -const GroupLogsItem: FC = ({ log, displayFields = [], isContextView, hideGroupButton, className, onItemClick }) => { +const GroupLogsItem: FC = ({ + log, + displayFields = [], + isContextView, + hideGroupButton, + className, + onItemClick +}) => { const { isDarkTheme } = useAppState(); const { isMobile } = useDeviceDetect(); @@ -45,6 +54,16 @@ const GroupLogsItem: FC = ({ log, displayFields = [], isContextView, hide const { timezone } = useTimeState(); const noWrapLines = searchParams.get(LOGS_URL_PARAMS.NO_WRAP_LINES) === "true"; + const [disabledLevelDetection] = useLocalStorageBoolean("LOGS_DISABLED_LEVEL_DETECTION"); + + const logLevel = useMemo(() => { + if (disabledLevelDetection) return null; + const level = getLogLevel(log); + return { + label: level, + color: LOG_LEVEL_COLORS[level], + }; + }, [log, disabledLevelDetection, isDarkTheme]); const formattedTime = useMemo(() => { if (!log._time) return ""; @@ -145,6 +164,19 @@ const GroupLogsItem: FC = ({ log, displayFields = [], isContextView, hide > {formattedTime || "timestamp missing"} + {logLevel && ( + +
+ {logLevel.label} +
+
+ )}
): Logs => fields as Logs; +const logLevels = Object.keys(LOG_LEVEL_COLORS) as LogLevel[]; + +describe("logLevel utils", () => { + describe("getLogLevel", () => { + it("returns unknown for empty log", () => { + expect(getLogLevel(log({}))).toBe(LOG_LEVEL_UNKNOWN); + }); + + it("returns unknown for unrecognized field and value", () => { + expect(getLogLevel(log({ foo: "bar" }))).toBe(LOG_LEVEL_UNKNOWN); + }); + + it("returns unknown for numeric value", () => { + expect(getLogLevel(log({ level: 30 }))).toBe(LOG_LEVEL_UNKNOWN); + }); + + it("returns unknown for null value", () => { + expect(getLogLevel(log({ level: null }))).toBe(LOG_LEVEL_UNKNOWN); + }); + + it.each(LOG_LEVEL_FIELDS)("detects level from field \"%s\"", (field) => { + expect(getLogLevel(log({ [field]: "error" }))).toBe("error"); + }); + + it("prefers 'level' over 'severity'", () => { + expect(getLogLevel(log({ level: "debug", severity: "error" }))).toBe("debug"); + }); + + it("prefers 'severity' over 'status'", () => { + expect(getLogLevel(log({ severity: "warn", status: "error" }))).toBe("warn"); + }); + + it("skips unknown value and continues checking next fields", () => { + expect(getLogLevel(log({ level: "custom", severity: "error" }))).toBe("error"); + }); + + it("normalizes aliases", () => { + expect(getLogLevel(log({ level: "verbose" }))).toBe("debug"); + expect(getLogLevel(log({ level: "information" }))).toBe("info"); + expect(getLogLevel(log({ level: "informational" }))).toBe("info"); + expect(getLogLevel(log({ level: "warning" }))).toBe("warn"); + expect(getLogLevel(log({ level: "err" }))).toBe("error"); + expect(getLogLevel(log({ level: "severe" }))).toBe("error"); + expect(getLogLevel(log({ level: "critical" }))).toBe("fatal"); + expect(getLogLevel(log({ level: "crit" }))).toBe("fatal"); + expect(getLogLevel(log({ level: "alert" }))).toBe("fatal"); + expect(getLogLevel(log({ level: "emergency" }))).toBe("fatal"); + expect(getLogLevel(log({ level: "emerg" }))).toBe("fatal"); + expect(getLogLevel(log({ level: "panic" }))).toBe("fatal"); + }); + + it("handles case-insensitive values", () => { + expect(getLogLevel(log({ level: "INFO" }))).toBe("info"); + expect(getLogLevel(log({ level: "Error" }))).toBe("error"); + expect(getLogLevel(log({ level: "Warn" }))).toBe("warn"); + }); + + it("trims whitespace from value", () => { + expect(getLogLevel(log({ level: " info " }))).toBe("info"); + }); + }); + + describe("getLogLevelColor", () => { + it("returns neutral light color for unknown level", () => { + expect(getLogLevelColor(log({}))).toBe(LOG_LEVEL_COLORS[LOG_LEVEL_UNKNOWN]); + }); + + it("returns neutral dark color for unknown level", () => { + expect(getLogLevelColor(log({}))).toBe(LOG_LEVEL_COLORS[LOG_LEVEL_UNKNOWN]); + }); + + it.each(logLevels.filter((level) => level !== LOG_LEVEL_UNKNOWN))( + "returns correct light color for level \"%s\"", + (level) => { + expect(getLogLevelColor(log({ level }))).toBe(LOG_LEVEL_COLORS[level]); + }, + ); + + it.each(logLevels.filter((level) => level !== LOG_LEVEL_UNKNOWN))( + "returns correct dark color for level \"%s\"", + (level) => { + expect(getLogLevelColor(log({ level }))).toBe(LOG_LEVEL_COLORS[level]); + }, + ); + + it("returns normalized level color", () => { + expect(getLogLevelColor(log({ level: "warning" }))).toBe( + LOG_LEVEL_COLORS.warn, + ); + expect(getLogLevelColor(log({ level: "critical" }))).toBe( + LOG_LEVEL_COLORS.fatal, + ); + expect(getLogLevelColor(log({ level: "information" }))).toBe( + LOG_LEVEL_COLORS.info, + ); + }); + }); +}); diff --git a/app/vmui/packages/vmui/src/utils/logLevel.ts b/app/vmui/packages/vmui/src/utils/logLevel.ts new file mode 100644 index 0000000000..8fabc92aeb --- /dev/null +++ b/app/vmui/packages/vmui/src/utils/logLevel.ts @@ -0,0 +1,56 @@ +import { LOG_LEVEL_COLORS, LOG_LEVEL_FIELDS, LOG_LEVEL_UNKNOWN } from "../constants/logLevel"; +import type { Logs } from "../api/types"; + +type LogLevel = keyof typeof LOG_LEVEL_COLORS; +type LogLevelColor = (typeof LOG_LEVEL_COLORS)[LogLevel]; + +const LEVEL_NORMALIZE: Record = { + trace: "trace", + + debug: "debug", + verbose: "debug", + + info: "info", + information: "info", + informational: "info", + notice: "info", + + warn: "warn", + warning: "warn", + + error: "error", + err: "error", + severe: "error", + + fatal: "fatal", + critical: "fatal", + crit: "fatal", + alert: "fatal", + emergency: "fatal", + emerg: "fatal", + panic: "fatal", +}; + +const normalizeLogLevel = (value: unknown): LogLevel | null => { + if (typeof value !== "string") { + return null; + } + + return LEVEL_NORMALIZE[value.trim().toLowerCase()] ?? null; +}; + +export const getLogLevel = (log: Logs): LogLevel => { + for (const field of LOG_LEVEL_FIELDS) { + const level = normalizeLogLevel(log[field]); + + if (level) { + return level; + } + } + + return LOG_LEVEL_UNKNOWN; +}; + +export const getLogLevelColor = (log: Logs): LogLevelColor => { + return LOG_LEVEL_COLORS[getLogLevel(log)]; +}; diff --git a/app/vmui/packages/vmui/src/utils/storage/constants.ts b/app/vmui/packages/vmui/src/utils/storage/constants.ts index 7afa4d65d0..4da00fd95d 100644 --- a/app/vmui/packages/vmui/src/utils/storage/constants.ts +++ b/app/vmui/packages/vmui/src/utils/storage/constants.ts @@ -16,6 +16,7 @@ export const ALL_STORAGE_KEYS = [ "LOGS_TABLE_DRAWER", "LOGS_FILTER_SIDEBAR_WIDTH", "LOGS_FILTER_SIDEBAR_HIDDEN", + "LOGS_DISABLED_LEVEL_DETECTION" ] as const; export const TABLE_KEYS = { diff --git a/docs/victorialogs/CHANGELOG.md b/docs/victorialogs/CHANGELOG.md index aad272b68d..1c548c53f6 100644 --- a/docs/victorialogs/CHANGELOG.md +++ b/docs/victorialogs/CHANGELOG.md @@ -26,6 +26,7 @@ according to the following docs: * FEATURE: [alerts](https://github.com/VictoriaMetrics/VictoriaLogs/blob/master/deployment/docker/rules): add new alerting rules `PersistentQueueRunsOutOfSpaceIn12Hours` and `PersistentQueueRunsOutOfSpaceIn4Hours` for `vlagent` persistent queue capacity. These alerts help users to take proactive actions before `vlagent` starts dropping logs due to insufficient persistent queue space. See [#10193](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10193) * FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): remove the `Date format` setting and always display timestamps with nanosecond precision. See [#1161](https://github.com/VictoriaMetrics/VictoriaLogs/issues/1161). * FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): rename field `Query time` to `Hits query` on the Hits chart panel. The change makes it clear duration of which query is displayed. +* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): add log level detection for displayed log entries. This feature can be toggled in the Group View settings. See [#1245](https://github.com/VictoriaMetrics/VictoriaLogs/issues/1245). * BUGFIX: [vlagent](https://docs.victoriametrics.com/victorialogs/vlagent/): hide sensitive values passed via `-remoteWrite.proxyURL` in `/metrics`, `/flags`, and startup logs. Previously these values could be exposed in plain text. See [#1320](https://github.com/VictoriaMetrics/VictoriaLogs/pull/1320). * BUGFIX: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): sanitize markdown URLs in logs rendered with `markdown parsing` enabled, allowing only `http`, `https`, `mailto`, and `tel` schemes for active links and images. See [#1313](https://github.com/VictoriaMetrics/VictoriaLogs/pull/1313).