mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2024-11-21 14:44:00 +00:00
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:
parent
3edbebd3ed
commit
e06a19d85f
17 changed files with 361 additions and 95 deletions
|
@ -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}/>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
|
@ -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>
|
||||||
|
);
|
||||||
|
|
|
@ -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)),
|
||||||
|
|
|
@ -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"}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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 }}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
26
app/vmui/packages/vmui/src/state/logsPanel/reducer.ts
Normal file
26
app/vmui/packages/vmui/src/state/logsPanel/reducer.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue