vmui/logs: improve UI functionality (#6688)

* add a toggle button to the "Group" tab that allows users to expand or collapse all groups at once
* introduce the ability to select a key for grouping logs within the "Group" tab
* display the number of entries within each log group.
* move the Markdown toggle to the general settings panel in the upper left corner.

(cherry picked from commit e06a19d85f)
This commit is contained in:
Yury Molodov 2024-08-02 15:48:36 +02:00 committed by hagen1778
parent 092ea42ba8
commit 00b108ca04
No known key found for this signature in database
GPG Key ID: 3BF75F3741CA9640
17 changed files with 361 additions and 95 deletions

View File

@ -13,6 +13,7 @@ import ThemeControl from "../ThemeControl/ThemeControl";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import useBoolean from "../../../hooks/useBoolean";
import { AppType } from "../../../types/appType";
import SwitchMarkdownParsing from "../LogsSettings/MarkdownParsing/SwitchMarkdownParsing";
const title = "Settings";
@ -60,6 +61,10 @@ const GlobalSettings: FC = () => {
onClose={handleClose}
/>
},
{
show: isLogsApp,
component: <SwitchMarkdownParsing/>
},
{
show: true,
component: <Timezones ref={timezoneSettingRef}/>

View File

@ -4,7 +4,7 @@
display: flex;
flex-direction: column;
align-items: center;
gap: $padding-medium;
gap: $padding-large;
width: 600px;
padding-bottom: $padding-medium;
@ -39,6 +39,13 @@
margin-bottom: $padding-global;
}
&__info {
padding-top: $padding-small;
font-size: $font-size-small;
color: $color-text-secondary;
line-height: 130%;
}
&-url {
display: flex;
align-items: flex-start;

View File

@ -0,0 +1,35 @@
import React, { FC } from "preact/compat";
import Switch from "../../../Main/Switch/Switch";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
import { useLogsDispatch, useLogsState } from "../../../../state/logsPanel/LogsStateContext";
const SwitchMarkdownParsing: FC = () => {
const { isMobile } = useDeviceDetect();
const { markdownParsing } = useLogsState();
const dispatch = useLogsDispatch();
const handleChangeMarkdownParsing = (val: boolean) => {
dispatch({ type: "SET_MARKDOWN_PARSING", payload: val });
};
return (
<div>
<div className="vm-server-configurator__title">
Markdown Parsing for Logs
</div>
<Switch
label={markdownParsing ? "Disable markdown parsing" : "Enable markdown parsing"}
value={markdownParsing}
onChange={handleChangeMarkdownParsing}
fullWidth={isMobile}
/>
<div className="vm-server-configurator__info">
Toggle this switch to enable or disable the Markdown formatting for log entries.
Enabling this will parse log texts to Markdown.
</div>
</div>
);
};
export default SwitchMarkdownParsing;

View File

@ -520,3 +520,25 @@ export const DownloadIcon = () => (
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"></path>
</svg>
);
export const ExpandIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 5.83 15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15z"
></path>
</svg>
);
export const CollapseIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M7.41 18.59 8.83 20 12 16.83 15.17 20l1.41-1.41L12 14zm9.18-13.18L15.17 4 12 7.17 8.83 4 7.41 5.41 12 10z"
></path>
</svg>
);

View File

@ -6,11 +6,14 @@ import Tooltip from "../Main/Tooltip/Tooltip";
import Button from "../Main/Button/Button";
import { useEffect } from "preact/compat";
type OrderDir = "asc" | "desc"
interface TableProps<T> {
rows: T[];
columns: { title?: string, key: keyof Partial<T>, className?: string }[];
defaultOrderBy: keyof T;
copyToClipboard?: keyof T;
defaultOrderDir?: OrderDir;
// TODO: Remove when pagination is implemented on the backend.
paginationOffset: {
startIndex: number;
@ -18,9 +21,9 @@ interface TableProps<T> {
}
}
const Table = <T extends object>({ rows, columns, defaultOrderBy, copyToClipboard, paginationOffset }: TableProps<T>) => {
const Table = <T extends object>({ rows, columns, defaultOrderBy, defaultOrderDir, copyToClipboard, paginationOffset }: TableProps<T>) => {
const [orderBy, setOrderBy] = useState<keyof T>(defaultOrderBy);
const [orderDir, setOrderDir] = useState<"asc" | "desc">("desc");
const [orderDir, setOrderDir] = useState<OrderDir>(defaultOrderDir || "desc");
const [copied, setCopied] = useState<number | null>(null);
// const sortedList = useMemo(() => stableSort(rows as [], getComparator(orderDir, orderBy)),

View File

@ -1,7 +1,7 @@
import React, { FC, useState } from "preact/compat";
import Trace from "./Trace";
import Button from "../Main/Button/Button";
import { ArrowDownIcon, CodeIcon, DeleteIcon, DownloadIcon } from "../Main/Icons";
import { CodeIcon, CollapseIcon, DeleteIcon, DownloadIcon, ExpandIcon } from "../Main/Icons";
import "./style.scss";
import NestedNav from "./NestedNav/NestedNav";
import Alert from "../Main/Alert/Alert";
@ -89,13 +89,7 @@ const TracingsView: FC<TraceViewProps> = ({ traces, jsonEditor = false, onDelete
<Tooltip title={expandedTraces.includes(trace.idValue) ? "Collapse All" : "Expand All"}>
<Button
variant="text"
startIcon={(
<div
className={classNames({
"vm-tracings-view-trace-header__expand-icon": true,
"vm-tracings-view-trace-header__expand-icon_open": expandedTraces.includes(trace.idValue) })}
><ArrowDownIcon/></div>
)}
startIcon={expandedTraces.includes(trace.idValue) ? <CollapseIcon/> : <ExpandIcon/> }
onClick={handleExpandAll(trace)}
ariaLabel={expandedTraces.includes(trace.idValue) ? "Collapse All" : "Expand All"}
/>

View File

@ -3,10 +3,11 @@ import { TimeStateProvider } from "../state/time/TimeStateContext";
import { QueryStateProvider } from "../state/query/QueryStateContext";
import { CustomPanelStateProvider } from "../state/customPanel/CustomPanelStateContext";
import { GraphStateProvider } from "../state/graph/GraphStateContext";
import { DashboardsStateProvider } from "../state/dashboards/DashboardsStateContext";
import { LogsStateProvider } from "../state/logsPanel/LogsStateContext";
import { SnackbarProvider } from "./Snackbar";
import { combineComponents } from "../utils/combine-components";
import { DashboardsStateProvider } from "../state/dashboards/DashboardsStateContext";
const providers = [
AppStateProvider,
@ -15,7 +16,8 @@ const providers = [
CustomPanelStateProvider,
GraphStateProvider,
SnackbarProvider,
DashboardsStateProvider
DashboardsStateProvider,
LogsStateProvider
];
export default combineComponents(...providers);

View File

@ -31,7 +31,6 @@ const ExploreLogs: FC = () => {
const { logs, isLoading, error, fetchLogs } = useFetchLogs(serverUrl, query, limit);
const { fetchLogHits, ...dataLogHits } = useFetchLogHits(serverUrl, query);
const [queryError, setQueryError] = useState<ErrorTypes | string>("");
const [markdownParsing, setMarkdownParsing] = useState(getFromStorage("LOGS_MARKDOWN") === "true");
const getPeriod = useCallback(() => {
const relativeTimeOpts = relativeTimeOptions.find(d => d.id === relativeTime);
@ -65,11 +64,6 @@ const ExploreLogs: FC = () => {
saveToStorage("LOGS_LIMIT", `${limit}`);
};
const handleChangeMarkdownParsing = (val: boolean) => {
saveToStorage("LOGS_MARKDOWN", `${val}`);
setMarkdownParsing(val);
};
useEffect(() => {
if (query) handleRunQuery();
}, [periodState]);
@ -84,11 +78,9 @@ const ExploreLogs: FC = () => {
query={query}
error={queryError}
limit={limit}
markdownParsing={markdownParsing}
onChange={setQuery}
onChangeLimit={handleChangeLimit}
onRun={handleRunQuery}
onChangeMarkdownParsing={handleChangeMarkdownParsing}
/>
{isLoading && <Spinner message={"Loading logs..."}/>}
{error && <Alert variant="error">{error}</Alert>}
@ -100,10 +92,7 @@ const ExploreLogs: FC = () => {
isLoading={isLoading ? false : dataLogHits.isLoading}
/>
)}
<ExploreLogsBody
data={logs}
markdownParsing={markdownParsing}
/>
<ExploreLogsBody data={logs}/>
</div>
);
};

View File

@ -1,4 +1,4 @@
import React, { FC, useState, useMemo } from "preact/compat";
import React, { FC, useState, useMemo, useRef } from "preact/compat";
import JsonView from "../../../components/Views/JsonView/JsonView";
import { CodeIcon, ListIcon, TableIcon } from "../../../components/Main/Icons";
import Tabs from "../../../components/Main/Tabs/Tabs";
@ -19,7 +19,6 @@ import { marked } from "marked";
export interface ExploreLogBodyProps {
data: Logs[];
markdownParsing: boolean;
}
enum DisplayType {
@ -34,10 +33,11 @@ const tabs = [
{ label: "JSON", value: DisplayType.json, icon: <CodeIcon/> },
];
const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data, markdownParsing }) => {
const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data }) => {
const { isMobile } = useDeviceDetect();
const { timezone } = useTimeState();
const { setSearchParamsFromKeys } = useSearchParamsFromObject();
const groupSettingsRef = useRef<HTMLDivElement>(null);
const [activeTab, setActiveTab] = useStateSearchParams(DisplayType.group, "view");
const [displayColumns, setDisplayColumns] = useState<string[]>([]);
@ -100,6 +100,12 @@ const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data, markdownParsing }) =>
/>
</div>
)}
{activeTab === DisplayType.group && (
<div
className="vm-explore-logs-body-header__settings"
ref={groupSettingsRef}
/>
)}
</div>
<div
@ -123,7 +129,7 @@ const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data, markdownParsing }) =>
<GroupLogs
logs={logs}
columns={columns}
markdownParsing={markdownParsing}
settingsRef={groupSettingsRef}
/>
)}
{activeTab === DisplayType.json && (

View File

@ -48,7 +48,8 @@ const TableLogs: FC<TableLogsProps> = ({ logs, displayColumns, tableCompact, col
<Table
rows={logs}
columns={filteredColumns}
defaultOrderBy={"_vmui_time"}
defaultOrderBy={"_time"}
defaultOrderDir={"desc"}
copyToClipboard={"_vmui_data"}
paginationOffset={{ startIndex: 0, endIndex: Infinity }}
/>

View File

@ -6,28 +6,23 @@ import useDeviceDetect from "../../../hooks/useDeviceDetect";
import Button from "../../../components/Main/Button/Button";
import QueryEditor from "../../../components/Configurators/QueryEditor/QueryEditor";
import TextField from "../../../components/Main/TextField/TextField";
import Switch from "../../../components/Main/Switch/Switch";
export interface ExploreLogHeaderProps {
query: string;
limit: number;
error?: string;
markdownParsing: boolean;
onChange: (val: string) => void;
onChangeLimit: (val: number) => void;
onRun: () => void;
onChangeMarkdownParsing: (val: boolean) => void;
}
const ExploreLogsHeader: FC<ExploreLogHeaderProps> = ({
query,
limit,
error,
markdownParsing,
onChange,
onChangeLimit,
onRun,
onChangeMarkdownParsing,
}) => {
const { isMobile } = useDeviceDetect();
@ -78,14 +73,7 @@ const ExploreLogsHeader: FC<ExploreLogHeaderProps> = ({
/>
</div>
<div className="vm-explore-logs-header-bottom">
<div className="vm-explore-logs-header-bottom-contols">
<Switch
label={"Markdown parsing"}
value={markdownParsing}
onChange={onChangeMarkdownParsing}
fullWidth={isMobile}
/>
</div>
<div className="vm-explore-logs-header-bottom-contols"></div>
<div className="vm-explore-logs-header-bottom-helpful">
<a
className="vm-link vm-link_with-icon"

View File

@ -1,4 +1,4 @@
import React, { FC, useEffect, useMemo } from "preact/compat";
import React, { FC, useCallback, useEffect, useMemo, useRef } from "preact/compat";
import { MouseEvent, useState } from "react";
import "./style.scss";
import { Logs } from "../../../api/types";
@ -9,89 +9,213 @@ import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
import GroupLogsItem from "./GroupLogsItem";
import { useAppState } from "../../../state/common/StateContext";
import classNames from "classnames";
import Button from "../../../components/Main/Button/Button";
import { CollapseIcon, ExpandIcon, StorageIcon } from "../../../components/Main/Icons";
import Popper from "../../../components/Main/Popper/Popper";
import TextField from "../../../components/Main/TextField/TextField";
import useBoolean from "../../../hooks/useBoolean";
import useStateSearchParams from "../../../hooks/useStateSearchParams";
import { useSearchParams } from "react-router-dom";
const WITHOUT_GROUPING = "No Grouping";
interface TableLogsProps {
logs: Logs[];
columns: string[];
markdownParsing: boolean;
settingsRef: React.Ref<HTMLDivElement>;
}
const GroupLogs: FC<TableLogsProps> = ({ logs, markdownParsing }) => {
const GroupLogs: FC<TableLogsProps> = ({ logs, settingsRef }) => {
const { isDarkTheme } = useAppState();
const copyToClipboard = useCopyToClipboard();
const [searchParams, setSearchParams] = useSearchParams();
const [expandGroups, setExpandGroups] = useState<boolean[]>([]);
const [groupBy, setGroupBy] = useStateSearchParams("_stream", "groupBy");
const [copied, setCopied] = useState<string | null>(null);
const [searchKey, setSearchKey] = useState("");
const optionsButtonRef = useRef<HTMLDivElement>(null);
const {
value: openOptions,
toggle: toggleOpenOptions,
setFalse: handleCloseOptions,
} = useBoolean(false);
const expandAll = useMemo(() => expandGroups.every(Boolean), [expandGroups]);
const logsKeys = useMemo(() => {
const excludeKeys = ["_msg", "_time", "_vmui_time", "_vmui_data", "_vmui_markdown"];
const uniqKeys = Array.from(new Set(logs.map(l => Object.keys(l)).flat()));
const keys = [WITHOUT_GROUPING, ...uniqKeys.filter(k => !excludeKeys.includes(k))];
if (!searchKey) return keys;
try {
const regexp = new RegExp(searchKey, "i");
const found = keys.filter((item) => regexp.test(item));
return found.sort((a,b) => (a.match(regexp)?.index || 0) - (b.match(regexp)?.index || 0));
} catch (e) {
return [];
}
}, [logs, searchKey]);
const groupData = useMemo(() => {
return groupByMultipleKeys(logs, ["_stream"]).map((item) => {
const streamValue = item.values[0]?._stream || "";
const pairs = streamValue.slice(1, -1).match(/(?:[^\\,]+|\\,)+?(?=,|$)/g) || [streamValue];
return groupByMultipleKeys(logs, [groupBy]).map((item) => {
const streamValue = item.values[0]?.[groupBy] || "";
const pairs = /^{.+}$/.test(streamValue)
? streamValue.slice(1, -1).match(/(\\.|[^,])+/g) || [streamValue]
: [streamValue];
return {
...item,
pairs: pairs.filter(Boolean),
};
});
}, [logs]);
}, [logs, groupBy]);
const handleClickByPair = (pair: string) => async (e: MouseEvent<HTMLDivElement>) => {
const handleClickByPair = (value: string) => async (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
const isCopied = await copyToClipboard(`${pair.replace(/=/, ": ")}`);
const isKeyValue = /(.+)?=(".+")/.test(value);
const copyValue = isKeyValue ? `${value.replace(/=/, ": ")}` : `${groupBy}: "${value}"`;
const isCopied = await copyToClipboard(copyValue);
if (isCopied) {
setCopied(pair);
setCopied(value);
}
};
const handleSelectGroupBy = (key: string) => () => {
setGroupBy(key);
searchParams.set("groupBy", key);
setSearchParams(searchParams);
handleCloseOptions();
};
const handleToggleExpandAll = useCallback(() => {
setExpandGroups(new Array(groupData.length).fill(!expandAll));
}, [expandAll]);
const handleChangeExpand = (i: number) => (value: boolean) => {
setExpandGroups((prev) => {
const newExpandGroups = [...prev];
newExpandGroups[i] = value;
return newExpandGroups;
});
};
useEffect(() => {
if (copied === null) return;
const timeout = setTimeout(() => setCopied(null), 2000);
return () => clearTimeout(timeout);
}, [copied]);
useEffect(() => {
setExpandGroups(new Array(groupData.length).fill(true));
}, [groupData]);
return (
<div className="vm-group-logs">
{groupData.map((item) => (
<div
className="vm-group-logs-section"
key={item.keys.join("")}
>
<Accordion
defaultExpanded={true}
title={(
<div className="vm-group-logs-section-keys">
<span className="vm-group-logs-section-keys__title">Group by _stream:</span>
{item.pairs.map((pair) => (
<Tooltip
title={copied === pair ? "Copied" : "Copy to clipboard"}
key={`${item.keys.join("")}_${pair}`}
placement={"top-center"}
>
<div
className={classNames({
"vm-group-logs-section-keys__pair": true,
"vm-group-logs-section-keys__pair_dark": isDarkTheme
})}
onClick={handleClickByPair(pair)}
<>
<div className="vm-group-logs">
{groupData.map((item, i) => (
<div
className="vm-group-logs-section"
key={item.keys.join("")}
>
<Accordion
key={String(expandGroups[i])}
defaultExpanded={expandGroups[i]}
onChange={handleChangeExpand(i)}
title={groupBy !== WITHOUT_GROUPING && (
<div className="vm-group-logs-section-keys">
<span className="vm-group-logs-section-keys__title">Group by <code>{groupBy}</code>:</span>
{item.pairs.map((pair) => (
<Tooltip
title={copied === pair ? "Copied" : "Copy to clipboard"}
key={`${item.keys.join("")}_${pair}`}
placement={"top-center"}
>
{pair}
</div>
</Tooltip>
<div
className={classNames({
"vm-group-logs-section-keys__pair": true,
"vm-group-logs-section-keys__pair_dark": isDarkTheme
})}
onClick={handleClickByPair(pair)}
>
{pair}
</div>
</Tooltip>
))}
<span className="vm-group-logs-section-keys__count">{item.values.length} entries</span>
</div>
)}
>
<div className="vm-group-logs-section-rows">
{item.values.map((value) => (
<GroupLogsItem
key={`${value._msg}${value._time}`}
log={value}
/>
))}
</div>
)}
>
<div className="vm-group-logs-section-rows">
{item.values.map((value) => (
<GroupLogsItem
key={`${value._msg}${value._time}`}
log={value}
markdownParsing={markdownParsing}
/>
))}
</Accordion>
</div>
))}
</div>
{settingsRef.current && React.createPortal((
<div className="vm-group-logs-header">
<Tooltip title={expandAll ? "Collapse All" : "Expand All"}>
<Button
variant="text"
startIcon={expandAll ? <CollapseIcon/> : <ExpandIcon/> }
onClick={handleToggleExpandAll}
ariaLabel={expandAll ? "Collapse All" : "Expand All"}
/>
</Tooltip>
<Tooltip title={"Group by"}>
<div ref={optionsButtonRef}>
<Button
variant="text"
startIcon={<StorageIcon/> }
onClick={toggleOpenOptions}
ariaLabel={"Group by"}
/>
</div>
</Accordion>
</Tooltip>
{
<Popper
open={openOptions}
placement="bottom-right"
onClose={handleCloseOptions}
buttonRef={optionsButtonRef}
>
<div className="vm-list vm-group-logs-header-keys">
<div className="vm-group-logs-header-keys__search">
<TextField
label="Search key"
value={searchKey}
onChange={setSearchKey}
type="search"
/>
</div>
{logsKeys.map(id => (
<div
className={classNames({
"vm-list-item": true,
"vm-list-item_active": id === groupBy
})}
key={id}
onClick={handleSelectGroupBy(id)}
>
{id}
</div>
))}
</div>
</Popper>
}
</div>
))}
</div>
), settingsRef.current)}
</>
);
};

View File

@ -7,19 +7,21 @@ import Tooltip from "../../../components/Main/Tooltip/Tooltip";
import { ArrowDownIcon, CopyIcon } from "../../../components/Main/Icons";
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
import classNames from "classnames";
import { useLogsState } from "../../../state/logsPanel/LogsStateContext";
interface Props {
log: Logs;
markdownParsing: boolean;
}
const GroupLogsItem: FC<Props> = ({ log, markdownParsing }) => {
const GroupLogsItem: FC<Props> = ({ log }) => {
const {
value: isOpenFields,
toggle: toggleOpenFields,
} = useBoolean(false);
const excludeKeys = ["_stream", "_msg", "_time", "_vmui_time", "_vmui_data", "_vmui_markdown"];
const { markdownParsing } = useLogsState();
const excludeKeys = ["_msg", "_vmui_time", "_vmui_data", "_vmui_markdown"];
const fields = Object.entries(log).filter(([key]) => !excludeKeys.includes(key));
const hasFields = fields.length > 0;

View File

@ -3,6 +3,22 @@
.vm-group-logs {
margin-top: calc(-1 * $padding-medium);
&-header {
display: flex;
align-items: center;
justify-content: flex-end;
gap: $padding-global;
&-keys {
max-height: 300px;
overflow: auto;
&__search {
padding: $padding-small;
}
}
}
&-section {
&-keys {
display: flex;
@ -14,6 +30,24 @@
&__title {
font-weight: bold;
code {
font-family: monospace;
&:before {
content: "\"";
}
&:after {
content: "\"";
}
}
}
&__count {
flex-grow: 1;
text-align: right;
font-size: $font-size-small;
color: $color-text-secondary;
padding-right: calc($padding-large * 3);
}
&__pair {

View File

@ -0,0 +1,24 @@
import React, { createContext, FC, useContext, useMemo, useReducer } from "preact/compat";
import { LogsAction, LogsState, initialLogsState, reducer } from "./reducer";
import { Dispatch } from "react";
type LogsStateContextType = { state: LogsState, dispatch: Dispatch<LogsAction> };
export const LogsStateContext = createContext<LogsStateContextType>({} as LogsStateContextType);
export const useLogsState = (): LogsState => useContext(LogsStateContext).state;
export const useLogsDispatch = (): Dispatch<LogsAction> => useContext(LogsStateContext).dispatch;
export const LogsStateProvider: FC = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialLogsState);
const contextValue = useMemo(() => {
return { state, dispatch };
}, [state, dispatch]);
return <LogsStateContext.Provider value={contextValue}>
{children}
</LogsStateContext.Provider>;
};

View File

@ -0,0 +1,26 @@
import { getFromStorage, saveToStorage } from "../../utils/storage";
export interface LogsState {
markdownParsing: boolean;
}
export type LogsAction =
| { type: "SET_MARKDOWN_PARSING", payload: boolean }
export const initialLogsState: LogsState = {
markdownParsing: getFromStorage("LOGS_MARKDOWN") === "true",
};
export function reducer(state: LogsState, action: LogsAction): LogsState {
switch (action.type) {
case "SET_MARKDOWN_PARSING":
saveToStorage("LOGS_MARKDOWN", `${ action.payload}`);
return {
...state,
markdownParsing: action.payload
};
default:
throw new Error();
}
}

View File

@ -17,6 +17,10 @@ according to [these docs](https://docs.victoriametrics.com/victorialogs/quicksta
## tip
* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): add fields for setting AccountID and ProjectID. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6631).
* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): add a toggle button to the "Group" tab that allows users to expand or collapse all groups at once.
* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): introduce the ability to select a key for grouping logs within the "Group" tab.
* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): display the number of entries within each log group.
* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): move the Markdown toggle to the general settings panel in the upper left corner.
## [v0.28.0](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v0.28.0-victorialogs)