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.
This commit is contained in:
Yury Molodov 2024-08-02 15:48:36 +02:00 committed by GitHub
parent 3edbebd3ed
commit e06a19d85f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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 useDeviceDetect from "../../../hooks/useDeviceDetect";
import useBoolean from "../../../hooks/useBoolean"; import useBoolean from "../../../hooks/useBoolean";
import { AppType } from "../../../types/appType"; import { AppType } from "../../../types/appType";
import SwitchMarkdownParsing from "../LogsSettings/MarkdownParsing/SwitchMarkdownParsing";
const title = "Settings"; const title = "Settings";
@ -60,6 +61,10 @@ const GlobalSettings: FC = () => {
onClose={handleClose} onClose={handleClose}
/> />
}, },
{
show: isLogsApp,
component: <SwitchMarkdownParsing/>
},
{ {
show: true, show: true,
component: <Timezones ref={timezoneSettingRef}/> component: <Timezones ref={timezoneSettingRef}/>

View file

@ -4,7 +4,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: $padding-medium; gap: $padding-large;
width: 600px; width: 600px;
padding-bottom: $padding-medium; padding-bottom: $padding-medium;
@ -39,6 +39,13 @@
margin-bottom: $padding-global; margin-bottom: $padding-global;
} }
&__info {
padding-top: $padding-small;
font-size: $font-size-small;
color: $color-text-secondary;
line-height: 130%;
}
&-url { &-url {
display: flex; display: flex;
align-items: flex-start; 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> <path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"></path>
</svg> </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 Button from "../Main/Button/Button";
import { useEffect } from "preact/compat"; import { useEffect } from "preact/compat";
type OrderDir = "asc" | "desc"
interface TableProps<T> { interface TableProps<T> {
rows: T[]; rows: T[];
columns: { title?: string, key: keyof Partial<T>, className?: string }[]; columns: { title?: string, key: keyof Partial<T>, className?: string }[];
defaultOrderBy: keyof T; defaultOrderBy: keyof T;
copyToClipboard?: keyof T; copyToClipboard?: keyof T;
defaultOrderDir?: OrderDir;
// TODO: Remove when pagination is implemented on the backend. // TODO: Remove when pagination is implemented on the backend.
paginationOffset: { paginationOffset: {
startIndex: number; 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 [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 [copied, setCopied] = useState<number | null>(null);
// const sortedList = useMemo(() => stableSort(rows as [], getComparator(orderDir, orderBy)), // const sortedList = useMemo(() => stableSort(rows as [], getComparator(orderDir, orderBy)),

View file

@ -1,7 +1,7 @@
import React, { FC, useState } from "preact/compat"; import React, { FC, useState } from "preact/compat";
import Trace from "./Trace"; import Trace from "./Trace";
import Button from "../Main/Button/Button"; 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 "./style.scss";
import NestedNav from "./NestedNav/NestedNav"; import NestedNav from "./NestedNav/NestedNav";
import Alert from "../Main/Alert/Alert"; 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"}> <Tooltip title={expandedTraces.includes(trace.idValue) ? "Collapse All" : "Expand All"}>
<Button <Button
variant="text" variant="text"
startIcon={( startIcon={expandedTraces.includes(trace.idValue) ? <CollapseIcon/> : <ExpandIcon/> }
<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>
)}
onClick={handleExpandAll(trace)} onClick={handleExpandAll(trace)}
ariaLabel={expandedTraces.includes(trace.idValue) ? "Collapse All" : "Expand All"} 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 { QueryStateProvider } from "../state/query/QueryStateContext";
import { CustomPanelStateProvider } from "../state/customPanel/CustomPanelStateContext"; import { CustomPanelStateProvider } from "../state/customPanel/CustomPanelStateContext";
import { GraphStateProvider } from "../state/graph/GraphStateContext"; import { GraphStateProvider } from "../state/graph/GraphStateContext";
import { DashboardsStateProvider } from "../state/dashboards/DashboardsStateContext";
import { LogsStateProvider } from "../state/logsPanel/LogsStateContext";
import { SnackbarProvider } from "./Snackbar"; import { SnackbarProvider } from "./Snackbar";
import { combineComponents } from "../utils/combine-components"; import { combineComponents } from "../utils/combine-components";
import { DashboardsStateProvider } from "../state/dashboards/DashboardsStateContext";
const providers = [ const providers = [
AppStateProvider, AppStateProvider,
@ -15,7 +16,8 @@ const providers = [
CustomPanelStateProvider, CustomPanelStateProvider,
GraphStateProvider, GraphStateProvider,
SnackbarProvider, SnackbarProvider,
DashboardsStateProvider DashboardsStateProvider,
LogsStateProvider
]; ];
export default combineComponents(...providers); export default combineComponents(...providers);

View file

@ -31,7 +31,6 @@ const ExploreLogs: FC = () => {
const { logs, isLoading, error, fetchLogs } = useFetchLogs(serverUrl, query, limit); const { logs, isLoading, error, fetchLogs } = useFetchLogs(serverUrl, query, limit);
const { fetchLogHits, ...dataLogHits } = useFetchLogHits(serverUrl, query); const { fetchLogHits, ...dataLogHits } = useFetchLogHits(serverUrl, query);
const [queryError, setQueryError] = useState<ErrorTypes | string>(""); const [queryError, setQueryError] = useState<ErrorTypes | string>("");
const [markdownParsing, setMarkdownParsing] = useState(getFromStorage("LOGS_MARKDOWN") === "true");
const getPeriod = useCallback(() => { const getPeriod = useCallback(() => {
const relativeTimeOpts = relativeTimeOptions.find(d => d.id === relativeTime); const relativeTimeOpts = relativeTimeOptions.find(d => d.id === relativeTime);
@ -65,11 +64,6 @@ const ExploreLogs: FC = () => {
saveToStorage("LOGS_LIMIT", `${limit}`); saveToStorage("LOGS_LIMIT", `${limit}`);
}; };
const handleChangeMarkdownParsing = (val: boolean) => {
saveToStorage("LOGS_MARKDOWN", `${val}`);
setMarkdownParsing(val);
};
useEffect(() => { useEffect(() => {
if (query) handleRunQuery(); if (query) handleRunQuery();
}, [periodState]); }, [periodState]);
@ -84,11 +78,9 @@ const ExploreLogs: FC = () => {
query={query} query={query}
error={queryError} error={queryError}
limit={limit} limit={limit}
markdownParsing={markdownParsing}
onChange={setQuery} onChange={setQuery}
onChangeLimit={handleChangeLimit} onChangeLimit={handleChangeLimit}
onRun={handleRunQuery} onRun={handleRunQuery}
onChangeMarkdownParsing={handleChangeMarkdownParsing}
/> />
{isLoading && <Spinner message={"Loading logs..."}/>} {isLoading && <Spinner message={"Loading logs..."}/>}
{error && <Alert variant="error">{error}</Alert>} {error && <Alert variant="error">{error}</Alert>}
@ -100,10 +92,7 @@ const ExploreLogs: FC = () => {
isLoading={isLoading ? false : dataLogHits.isLoading} isLoading={isLoading ? false : dataLogHits.isLoading}
/> />
)} )}
<ExploreLogsBody <ExploreLogsBody data={logs}/>
data={logs}
markdownParsing={markdownParsing}
/>
</div> </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 JsonView from "../../../components/Views/JsonView/JsonView";
import { CodeIcon, ListIcon, TableIcon } from "../../../components/Main/Icons"; import { CodeIcon, ListIcon, TableIcon } from "../../../components/Main/Icons";
import Tabs from "../../../components/Main/Tabs/Tabs"; import Tabs from "../../../components/Main/Tabs/Tabs";
@ -19,7 +19,6 @@ import { marked } from "marked";
export interface ExploreLogBodyProps { export interface ExploreLogBodyProps {
data: Logs[]; data: Logs[];
markdownParsing: boolean;
} }
enum DisplayType { enum DisplayType {
@ -34,10 +33,11 @@ const tabs = [
{ label: "JSON", value: DisplayType.json, icon: <CodeIcon/> }, { label: "JSON", value: DisplayType.json, icon: <CodeIcon/> },
]; ];
const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data, markdownParsing }) => { const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data }) => {
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
const { timezone } = useTimeState(); const { timezone } = useTimeState();
const { setSearchParamsFromKeys } = useSearchParamsFromObject(); const { setSearchParamsFromKeys } = useSearchParamsFromObject();
const groupSettingsRef = useRef<HTMLDivElement>(null);
const [activeTab, setActiveTab] = useStateSearchParams(DisplayType.group, "view"); const [activeTab, setActiveTab] = useStateSearchParams(DisplayType.group, "view");
const [displayColumns, setDisplayColumns] = useState<string[]>([]); const [displayColumns, setDisplayColumns] = useState<string[]>([]);
@ -100,6 +100,12 @@ const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data, markdownParsing }) =>
/> />
</div> </div>
)} )}
{activeTab === DisplayType.group && (
<div
className="vm-explore-logs-body-header__settings"
ref={groupSettingsRef}
/>
)}
</div> </div>
<div <div
@ -123,7 +129,7 @@ const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data, markdownParsing }) =>
<GroupLogs <GroupLogs
logs={logs} logs={logs}
columns={columns} columns={columns}
markdownParsing={markdownParsing} settingsRef={groupSettingsRef}
/> />
)} )}
{activeTab === DisplayType.json && ( {activeTab === DisplayType.json && (

View file

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

View file

@ -6,28 +6,23 @@ import useDeviceDetect from "../../../hooks/useDeviceDetect";
import Button from "../../../components/Main/Button/Button"; import Button from "../../../components/Main/Button/Button";
import QueryEditor from "../../../components/Configurators/QueryEditor/QueryEditor"; import QueryEditor from "../../../components/Configurators/QueryEditor/QueryEditor";
import TextField from "../../../components/Main/TextField/TextField"; import TextField from "../../../components/Main/TextField/TextField";
import Switch from "../../../components/Main/Switch/Switch";
export interface ExploreLogHeaderProps { export interface ExploreLogHeaderProps {
query: string; query: string;
limit: number; limit: number;
error?: string; error?: string;
markdownParsing: boolean;
onChange: (val: string) => void; onChange: (val: string) => void;
onChangeLimit: (val: number) => void; onChangeLimit: (val: number) => void;
onRun: () => void; onRun: () => void;
onChangeMarkdownParsing: (val: boolean) => void;
} }
const ExploreLogsHeader: FC<ExploreLogHeaderProps> = ({ const ExploreLogsHeader: FC<ExploreLogHeaderProps> = ({
query, query,
limit, limit,
error, error,
markdownParsing,
onChange, onChange,
onChangeLimit, onChangeLimit,
onRun, onRun,
onChangeMarkdownParsing,
}) => { }) => {
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
@ -78,14 +73,7 @@ const ExploreLogsHeader: FC<ExploreLogHeaderProps> = ({
/> />
</div> </div>
<div className="vm-explore-logs-header-bottom"> <div className="vm-explore-logs-header-bottom">
<div className="vm-explore-logs-header-bottom-contols"> <div className="vm-explore-logs-header-bottom-contols"></div>
<Switch
label={"Markdown parsing"}
value={markdownParsing}
onChange={onChangeMarkdownParsing}
fullWidth={isMobile}
/>
</div>
<div className="vm-explore-logs-header-bottom-helpful"> <div className="vm-explore-logs-header-bottom-helpful">
<a <a
className="vm-link vm-link_with-icon" 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 { MouseEvent, useState } from "react";
import "./style.scss"; import "./style.scss";
import { Logs } from "../../../api/types"; import { Logs } from "../../../api/types";
@ -9,89 +9,213 @@ import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
import GroupLogsItem from "./GroupLogsItem"; import GroupLogsItem from "./GroupLogsItem";
import { useAppState } from "../../../state/common/StateContext"; import { useAppState } from "../../../state/common/StateContext";
import classNames from "classnames"; 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 { interface TableLogsProps {
logs: Logs[]; logs: Logs[];
columns: string[]; columns: string[];
markdownParsing: boolean; settingsRef: React.Ref<HTMLDivElement>;
} }
const GroupLogs: FC<TableLogsProps> = ({ logs, markdownParsing }) => { const GroupLogs: FC<TableLogsProps> = ({ logs, settingsRef }) => {
const { isDarkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const copyToClipboard = useCopyToClipboard(); 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 [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(() => { const groupData = useMemo(() => {
return groupByMultipleKeys(logs, ["_stream"]).map((item) => { return groupByMultipleKeys(logs, [groupBy]).map((item) => {
const streamValue = item.values[0]?._stream || ""; const streamValue = item.values[0]?.[groupBy] || "";
const pairs = streamValue.slice(1, -1).match(/(?:[^\\,]+|\\,)+?(?=,|$)/g) || [streamValue]; const pairs = /^{.+}$/.test(streamValue)
? streamValue.slice(1, -1).match(/(\\.|[^,])+/g) || [streamValue]
: [streamValue];
return { return {
...item, ...item,
pairs: pairs.filter(Boolean), 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(); 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) { 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(() => { useEffect(() => {
if (copied === null) return; if (copied === null) return;
const timeout = setTimeout(() => setCopied(null), 2000); const timeout = setTimeout(() => setCopied(null), 2000);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [copied]); }, [copied]);
useEffect(() => {
setExpandGroups(new Array(groupData.length).fill(true));
}, [groupData]);
return ( return (
<div className="vm-group-logs"> <>
{groupData.map((item) => ( <div className="vm-group-logs">
<div {groupData.map((item, i) => (
className="vm-group-logs-section" <div
key={item.keys.join("")} className="vm-group-logs-section"
> key={item.keys.join("")}
<Accordion >
defaultExpanded={true} <Accordion
title={( key={String(expandGroups[i])}
<div className="vm-group-logs-section-keys"> defaultExpanded={expandGroups[i]}
<span className="vm-group-logs-section-keys__title">Group by _stream:</span> onChange={handleChangeExpand(i)}
{item.pairs.map((pair) => ( title={groupBy !== WITHOUT_GROUPING && (
<Tooltip <div className="vm-group-logs-section-keys">
title={copied === pair ? "Copied" : "Copy to clipboard"} <span className="vm-group-logs-section-keys__title">Group by <code>{groupBy}</code>:</span>
key={`${item.keys.join("")}_${pair}`} {item.pairs.map((pair) => (
placement={"top-center"} <Tooltip
> title={copied === pair ? "Copied" : "Copy to clipboard"}
<div key={`${item.keys.join("")}_${pair}`}
className={classNames({ placement={"top-center"}
"vm-group-logs-section-keys__pair": true,
"vm-group-logs-section-keys__pair_dark": isDarkTheme
})}
onClick={handleClickByPair(pair)}
> >
{pair} <div
</div> className={classNames({
</Tooltip> "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>
)} </Accordion>
> </div>
<div className="vm-group-logs-section-rows"> ))}
{item.values.map((value) => ( </div>
<GroupLogsItem
key={`${value._msg}${value._time}`}
log={value} {settingsRef.current && React.createPortal((
markdownParsing={markdownParsing} <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> </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)}
</div> </>
); );
}; };

View file

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

View file

@ -3,6 +3,22 @@
.vm-group-logs { .vm-group-logs {
margin-top: calc(-1 * $padding-medium); 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 { &-section {
&-keys { &-keys {
display: flex; display: flex;
@ -14,6 +30,24 @@
&__title { &__title {
font-weight: bold; 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 { &__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 ## 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 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) ## [v0.28.0](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v0.28.0-victorialogs)