vmui/logs: optimize memory consumption

This commit is contained in:
Yury Molodov 2024-11-12 18:26:54 +01:00
parent 371e193279
commit d14923171a
No known key found for this signature in database
GPG key ID: 79AD43149C47BDE7
15 changed files with 375 additions and 216 deletions

View file

@ -20,6 +20,7 @@ module.exports = {
"@typescript-eslint" "@typescript-eslint"
], ],
"rules": { "rules": {
"@typescript-eslint/no-unused-vars": ["warn", { "varsIgnorePattern": "^_" }],
"react/jsx-closing-bracket-location": [1, "line-aligned"], "react/jsx-closing-bracket-location": [1, "line-aligned"],
"react/jsx-max-props-per-line":[1, { "maximum": 1 }], "react/jsx-max-props-per-line":[1, { "maximum": 1 }],
"react/jsx-first-prop-new-line": [1, "multiline"], "react/jsx-first-prop-new-line": [1, "multiline"],

View file

@ -0,0 +1,105 @@
import React from "react";
import { ArrowDownIcon } from "../Icons";
import { useMemo } from "preact/compat";
import classNames from "classnames";
import "./style.scss";
interface PaginationProps {
currentPage: number;
totalItems: number;
itemsPerPage: number;
onPageChange: (page: number) => void;
maxVisiblePages?: number;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalItems,
itemsPerPage,
onPageChange,
maxVisiblePages = 10
}) => {
const totalPages = Math.ceil(totalItems / itemsPerPage);
const handlePageChange = (page: number) => {
if (page < 1 || page > totalPages) return;
onPageChange(page);
};
const pages = useMemo(() => {
const pages = [];
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
const startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
if (startPage > 1) {
pages.push(1);
if (startPage > 2) {
pages.push("...");
}
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
pages.push("...");
}
pages.push(totalPages);
}
}
return pages;
}, [totalPages, currentPage, maxVisiblePages]);
const handleClickNav = (stepPage: number) => () => {
handlePageChange(currentPage + stepPage);
};
const handleClickPage = (page: number | string) => () => {
if (typeof page === "number") {
handlePageChange(page);
}
};
if (pages.length <= 1) return null;
return (
<div className="vm-pagination">
<button
className="vm-pagination__page vm-pagination__arrow vm-pagination__arrow_prev"
onClick={handleClickNav(-1)}
disabled={currentPage === 1}
>
<ArrowDownIcon/>
</button>
{pages.map((page, index) => (
<button
key={index}
onClick={handleClickPage(page)}
className={classNames({
"vm-pagination__page": true,
"vm-pagination__page_active": currentPage === page,
"vm-pagination__page_disabled": page === "..."
})}
disabled={page === "..."}
>
{page}
</button>
))}
<button
className="vm-pagination__page vm-pagination__arrow vm-pagination__arrow_next"
onClick={handleClickNav(1)}
disabled={currentPage === totalPages}
>
<ArrowDownIcon/>
</button>
</div>
);
};
export default Pagination;

View file

@ -1,52 +0,0 @@
import React, { FC } from "preact/compat";
import Button from "../../Button/Button";
import { ArrowDownIcon } from "../../Icons";
import "./style.scss";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
import classNames from "classnames";
interface PaginationControlProps {
page: number;
length: number;
limit: number;
onChange: (page: number) => void;
}
const PaginationControl: FC<PaginationControlProps> = ({ page, length, limit, onChange }) => {
const { isMobile } = useDeviceDetect();
const handleChangePage = (step: number) => () => {
onChange(+page + step);
window.scrollTo(0, 0);
};
return (
<div
className={classNames({
"vm-pagination": true,
"vm-pagination_mobile": isMobile
})}
>
{page > 1 && (
<Button
variant={"text"}
onClick={handleChangePage(-1)}
startIcon={<div className="vm-pagination__icon vm-pagination__icon_prev"><ArrowDownIcon/></div>}
>
Previous
</Button>
)}
{length >= limit && (
<Button
variant={"text"}
onClick={handleChangePage(1)}
endIcon={<div className="vm-pagination__icon vm-pagination__icon_next"><ArrowDownIcon/></div>}
>
Next
</Button>
)}
</div>
);
};
export default PaginationControl;

View file

@ -1,24 +0,0 @@
@use "src/styles/variables" as *;
.vm-pagination {
position: sticky;
right: 0;
display: flex;
justify-content: flex-end;
gap: $padding-small;
padding: $padding-global 0 0;
&_mobile {
padding: $padding-global 0;
}
&__icon {
&_prev {
transform: rotate(90deg);
}
&_next {
transform: rotate(-90deg);
}
}
}

View file

@ -0,0 +1,64 @@
@use "../../../styles/variables" as *;
.vm-pagination {
position: sticky;
left: 0;
display: flex;
justify-content: center;
gap: $padding-small;
padding: $padding-global 0;
font-size: $font-size;
&_mobile {
padding: $padding-global 0;
}
&__page {
display: flex;
align-items: center;
justify-content: center;
height: 30px;
min-width: 30px;
padding: 0 $padding-small;
border-radius: $border-radius-small;
transition: background-color 0.3s;
border: 1px solid transparent;
cursor: pointer;
&_active {
background-color: $color-primary;
color: $color-primary-text;
}
&_disabled {
cursor: default;
pointer-events: none;
color: inherit;
}
&:hover {
background-color: $color-hover-black;
}
}
&__arrow {
svg {
max-width: $font-size;
max-height: $font-size;
}
&:disabled {
cursor: default;
pointer-events: none;
}
&_prev {
transform: rotate(90deg);
}
&_next {
transform: rotate(-90deg);
}
}
}

View file

@ -32,8 +32,7 @@ const Table = <T extends object>({ rows, columns, defaultOrderBy, defaultOrderDi
const sortedList = useMemo(() => { const sortedList = useMemo(() => {
const { startIndex, endIndex } = paginationOffset; const { startIndex, endIndex } = paginationOffset;
return stableSort(rows as [], getComparator(orderDir, orderBy)).slice(startIndex, endIndex); return stableSort(rows as [], getComparator(orderDir, orderBy)).slice(startIndex, endIndex);
}, }, [rows, orderBy, orderDir, paginationOffset]);
[rows, orderBy, orderDir, paginationOffset]);
const createSortHandler = (key: keyof T) => () => { const createSortHandler = (key: keyof T) => () => {
setOrderDir((prev) => prev === "asc" && orderBy === key ? "desc" : "asc"); setOrderDir((prev) => prev === "asc" && orderBy === key ? "desc" : "asc");

View file

@ -12,17 +12,7 @@ export interface JsonViewProps {
const JsonView: FC<JsonViewProps> = ({ data }) => { const JsonView: FC<JsonViewProps> = ({ data }) => {
const copyToClipboard = useCopyToClipboard(); const copyToClipboard = useCopyToClipboard();
const formattedJson = useMemo(() => { const formattedJson = useMemo(() => JSON.stringify(data, null, 2), [data]);
const space = " ";
const values = data.map(item => {
if (Object.keys(item).length === 1) {
return JSON.stringify(item);
} else {
return JSON.stringify(item, null, space.length);
}
}).join(",\n").replace(/^/gm, `${space}`);
return `[\n${values}\n]`;
}, [data]);
const handlerCopy = async () => { const handlerCopy = async () => {
await copyToClipboard(formattedJson, "Formatted JSON has been copied"); await copyToClipboard(formattedJson, "Formatted JSON has been copied");

View file

@ -54,7 +54,7 @@ const ExploreLogs: FC = () => {
fetchLogs(newPeriod).then((isSuccess) => { fetchLogs(newPeriod).then((isSuccess) => {
isSuccess && !hideChart && fetchLogHits(newPeriod); isSuccess && !hideChart && fetchLogHits(newPeriod);
}).catch(e => e); }).catch(e => e);
setSearchParamsFromKeys( { setSearchParamsFromKeys({
query, query,
"g0.range_input": duration, "g0.range_input": duration,
"g0.end_input": newPeriod.date, "g0.end_input": newPeriod.date,

View file

@ -1,22 +1,23 @@
import React, { FC, useState, useMemo, useRef } 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 { CodeIcon, ListIcon, TableIcon } from "../../../components/Main/Icons";
import Tabs from "../../../components/Main/Tabs/Tabs"; import Tabs from "../../../components/Main/Tabs/Tabs";
import "./style.scss"; import "./style.scss";
import classNames from "classnames"; import classNames from "classnames";
import useDeviceDetect from "../../../hooks/useDeviceDetect"; import useDeviceDetect from "../../../hooks/useDeviceDetect";
import { Logs } from "../../../api/types"; import { Logs } from "../../../api/types";
import dayjs from "dayjs";
import { useTimeState } from "../../../state/time/TimeStateContext";
import useStateSearchParams from "../../../hooks/useStateSearchParams"; import useStateSearchParams from "../../../hooks/useStateSearchParams";
import useSearchParamsFromObject from "../../../hooks/useSearchParamsFromObject"; import useSearchParamsFromObject from "../../../hooks/useSearchParamsFromObject";
import TableSettings from "../../../components/Table/TableSettings/TableSettings"; import TableSettings from "../../../components/Table/TableSettings/TableSettings";
import useBoolean from "../../../hooks/useBoolean"; import useBoolean from "../../../hooks/useBoolean";
import TableLogs from "./TableLogs"; import TableLogs from "./TableLogs";
import GroupLogs from "../GroupLogs/GroupLogs"; import GroupLogs from "../GroupLogs/GroupLogs";
import { DATE_TIME_FORMAT } from "../../../constants/date"; import JsonView from "../../../components/Views/JsonView/JsonView";
import { marked } from "marked";
import LineLoader from "../../../components/Main/LineLoader/LineLoader"; import LineLoader from "../../../components/Main/LineLoader/LineLoader";
import SelectLimit from "../../../components/Main/Pagination/SelectLimit/SelectLimit";
const MemoizedTableLogs = React.memo(TableLogs);
const MemoizedGroupLogs = React.memo(GroupLogs);
const MemoizedJsonView = React.memo(JsonView);
export interface ExploreLogBodyProps { export interface ExploreLogBodyProps {
data: Logs[]; data: Logs[];
@ -37,38 +38,35 @@ const tabs = [
const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data, isLoading }) => { const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data, isLoading }) => {
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
const { timezone } = useTimeState();
const { setSearchParamsFromKeys } = useSearchParamsFromObject(); const { setSearchParamsFromKeys } = useSearchParamsFromObject();
const groupSettingsRef = useRef<HTMLDivElement>(null); 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[]>([]);
const [rowsPerPage, setRowsPerPage] = useStateSearchParams(1000, "rows_per_page");
const { value: tableCompact, toggle: toggleTableCompact } = useBoolean(false); const { value: tableCompact, toggle: toggleTableCompact } = useBoolean(false);
const logs = useMemo(() => data.map((item) => ({
...item,
_vmui_time: item._time ? dayjs(item._time).tz().format(`${DATE_TIME_FORMAT}.SSS`) : "",
_vmui_data: JSON.stringify(item, null, 2),
_vmui_markdown: item._msg ? marked(item._msg.replace(/```/g, "\n```\n")) as string : ""
})) as Logs[], [data, timezone]);
const columns = useMemo(() => { const columns = useMemo(() => {
if (!logs?.length) return []; if (!data?.length) return [];
const hideColumns = ["_vmui_data", "_vmui_time", "_vmui_markdown"];
const keys = new Set<string>(); const keys = new Set<string>();
for (const item of logs) { for (const item of data) {
for (const key in item) { for (const key in item) {
keys.add(key); keys.add(key);
} }
} }
return Array.from(keys).filter((col) => !hideColumns.includes(col)); return Array.from(keys);
}, [logs]); }, [data]);
const handleChangeTab = (view: string) => { const handleChangeTab = (view: string) => {
setActiveTab(view as DisplayType); setActiveTab(view as DisplayType);
setSearchParamsFromKeys({ view }); setSearchParamsFromKeys({ view });
}; };
const handleSetRowsPerPage = (limit: number) => {
setRowsPerPage(limit);
setSearchParamsFromKeys({ rows_per_page: limit });
};
return ( return (
<div <div
className={classNames({ className={classNames({
@ -97,6 +95,10 @@ const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data, isLoading }) => {
</div> </div>
{activeTab === DisplayType.table && ( {activeTab === DisplayType.table && (
<div className="vm-explore-logs-body-header__settings"> <div className="vm-explore-logs-body-header__settings">
<SelectLimit
limit={rowsPerPage}
onChange={handleSetRowsPerPage}
/>
<TableSettings <TableSettings
columns={columns} columns={columns}
selectedColumns={displayColumns} selectedColumns={displayColumns}
@ -124,22 +126,22 @@ const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data, isLoading }) => {
{!!data.length && ( {!!data.length && (
<> <>
{activeTab === DisplayType.table && ( {activeTab === DisplayType.table && (
<TableLogs <MemoizedTableLogs
logs={logs} logs={data}
displayColumns={displayColumns} displayColumns={displayColumns}
tableCompact={tableCompact} tableCompact={tableCompact}
columns={columns} columns={columns}
rowsPerPage={Number(rowsPerPage)}
/> />
)} )}
{activeTab === DisplayType.group && ( {activeTab === DisplayType.group && (
<GroupLogs <MemoizedGroupLogs
logs={logs} logs={data}
columns={columns}
settingsRef={groupSettingsRef} settingsRef={groupSettingsRef}
/> />
)} )}
{activeTab === DisplayType.json && ( {activeTab === DisplayType.json && (
<JsonView data={data}/> <MemoizedJsonView data={data}/>
)} )}
</> </>
)} )}

View file

@ -1,58 +1,94 @@
import React, { FC, useMemo } from "preact/compat"; import React, { FC, useMemo, useRef, useState } from "preact/compat";
import "./style.scss"; import "./style.scss";
import Table from "../../../components/Table/Table"; import Table from "../../../components/Table/Table";
import { Logs } from "../../../api/types"; import { Logs } from "../../../api/types";
import Pagination from "../../../components/Main/Pagination/Pagination";
import { useEffect } from "react";
interface TableLogsProps { interface TableLogsProps {
logs: Logs[]; logs: Logs[];
displayColumns: string[]; displayColumns: string[];
tableCompact: boolean; tableCompact: boolean;
columns: string[]; columns: string[];
rowsPerPage: number;
} }
const TableLogs: FC<TableLogsProps> = ({ logs, displayColumns, tableCompact, columns }) => { const getColumnClass = (key: string) => {
const getColumnClass = (key: string) => { switch (key) {
switch (key) { case "_time":
case "_time": return "vm-table-cell_logs-time";
return "vm-table-cell_logs-time"; default:
case "_vmui_data": return "vm-table-cell_logs";
return "vm-table-cell_logs vm-table-cell_pre"; }
default: };
return "vm-table-cell_logs";
} const compactColumns = [{
}; key: "_vmui_data",
title: "Data",
className: "vm-table-cell_logs vm-table-cell_pre"
}];
const TableLogs: FC<TableLogsProps> = ({ logs, displayColumns, tableCompact, columns, rowsPerPage }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [page, setPage] = useState(1);
const rows = useMemo(() => {
return logs.map((log) => {
const _vmui_data = JSON.stringify(log, null, 2);
return { ...log, _vmui_data };
}) as Logs[];
}, [logs]);
const tableColumns = useMemo(() => { const tableColumns = useMemo(() => {
if (tableCompact) {
return [{
key: "_vmui_data",
title: "Data",
className: getColumnClass("_vmui_data")
}];
}
return columns.map((key) => ({ return columns.map((key) => ({
key: key as keyof Logs, key: key as keyof Logs,
title: key, title: key,
className: getColumnClass(key), className: getColumnClass(key),
})); }));
}, [tableCompact, columns]); }, [columns]);
const filteredColumns = useMemo(() => { const filteredColumns = useMemo(() => {
if (tableCompact) return tableColumns; if (tableCompact) return compactColumns;
if (!displayColumns?.length) return []; if (!displayColumns?.length) return [];
return tableColumns.filter(c => displayColumns.includes(c.key as string)); return tableColumns.filter(c => displayColumns.includes(c.key as string));
}, [tableColumns, displayColumns, tableCompact]); }, [tableColumns, displayColumns, tableCompact]);
const paginationOffset = useMemo(() => {
const startIndex = (page - 1) * rowsPerPage;
const endIndex = startIndex + rowsPerPage;
return { startIndex, endIndex };
}, [page, rowsPerPage]);
const handlePageChange = (newPage: number) => {
setPage(newPage);
if (containerRef.current) {
const y = containerRef.current.getBoundingClientRect().top + window.scrollY - 50;
window.scrollTo({ top: y });
}
};
useEffect(() => {
setPage(1);
}, [logs, rowsPerPage]);
return ( return (
<> <>
<Table <div ref={containerRef}>
rows={logs} <Table
columns={filteredColumns} rows={rows}
defaultOrderBy={"_time"} columns={filteredColumns}
defaultOrderDir={"desc"} defaultOrderBy={"_time"}
copyToClipboard={"_vmui_data"} defaultOrderDir={"desc"}
paginationOffset={{ startIndex: 0, endIndex: Infinity }} copyToClipboard={"_vmui_data"}
paginationOffset={paginationOffset}
/>
</div>
<Pagination
currentPage={page}
totalItems={rows.length}
itemsPerPage={rowsPerPage}
onPageChange={handlePageChange}
/> />
</> </>
); );

View file

@ -20,13 +20,12 @@ import { getStreamPairs } from "../../../utils/logs";
const WITHOUT_GROUPING = "No Grouping"; const WITHOUT_GROUPING = "No Grouping";
interface TableLogsProps { interface Props {
logs: Logs[]; logs: Logs[];
columns: string[];
settingsRef: React.RefObject<HTMLElement>; settingsRef: React.RefObject<HTMLElement>;
} }
const GroupLogs: FC<TableLogsProps> = ({ logs, settingsRef }) => { const GroupLogs: FC<Props> = ({ logs, settingsRef }) => {
const { isDarkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const copyToClipboard = useCopyToClipboard(); const copyToClipboard = useCopyToClipboard();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -46,19 +45,21 @@ const GroupLogs: FC<TableLogsProps> = ({ logs, settingsRef }) => {
const expandAll = useMemo(() => expandGroups.every(Boolean), [expandGroups]); const expandAll = useMemo(() => expandGroups.every(Boolean), [expandGroups]);
const logsKeys = useMemo(() => { const logsKeys = useMemo(() => {
const excludeKeys = ["_msg", "_time", "_vmui_time", "_vmui_data", "_vmui_markdown"]; const excludeKeys = ["_msg", "_time"];
const uniqKeys = Array.from(new Set(logs.map(l => Object.keys(l)).flat())); const uniqKeys = Array.from(new Set(logs.map(l => Object.keys(l)).flat()));
const keys = [WITHOUT_GROUPING, ...uniqKeys.filter(k => !excludeKeys.includes(k))]; return [WITHOUT_GROUPING, ...uniqKeys.filter(k => !excludeKeys.includes(k))];
}, [logs]);
if (!searchKey) return keys; const filteredLogsKeys = useMemo(() => {
if (!searchKey) return logsKeys;
try { try {
const regexp = new RegExp(searchKey, "i"); const regexp = new RegExp(searchKey, "i");
const found = keys.filter((item) => regexp.test(item)); return logsKeys.filter(item => regexp.test(item))
return found.sort((a,b) => (a.match(regexp)?.index || 0) - (b.match(regexp)?.index || 0)); .sort((a, b) => (a.match(regexp)?.index || 0) - (b.match(regexp)?.index || 0));
} catch (e) { } catch (e) {
return []; return [];
} }
}, [logs, searchKey]); }, [logsKeys, searchKey]);
const groupData = useMemo(() => { const groupData = useMemo(() => {
return groupByMultipleKeys(logs, [groupBy]).map((item) => { return groupByMultipleKeys(logs, [groupBy]).map((item) => {
@ -90,16 +91,15 @@ const GroupLogs: FC<TableLogsProps> = ({ logs, settingsRef }) => {
const handleToggleExpandAll = useCallback(() => { const handleToggleExpandAll = useCallback(() => {
setExpandGroups(new Array(groupData.length).fill(!expandAll)); setExpandGroups(new Array(groupData.length).fill(!expandAll));
}, [expandAll]); }, [expandAll, groupData.length]);
const handleChangeExpand = (i: number) => (value: boolean) => { const handleChangeExpand = useCallback((i: number) => (value: boolean) => {
setExpandGroups((prev) => { setExpandGroups((prev) => {
const newExpandGroups = [...prev]; const newExpandGroups = [...prev];
newExpandGroups[i] = value; newExpandGroups[i] = value;
return newExpandGroups; return newExpandGroups;
}); });
}, []);
};
useEffect(() => { useEffect(() => {
if (copied === null) return; if (copied === null) return;
@ -166,7 +166,7 @@ const GroupLogs: FC<TableLogsProps> = ({ logs, settingsRef }) => {
<Tooltip title={expandAll ? "Collapse All" : "Expand All"}> <Tooltip title={expandAll ? "Collapse All" : "Expand All"}>
<Button <Button
variant="text" variant="text"
startIcon={expandAll ? <CollapseIcon/> : <ExpandIcon/> } startIcon={expandAll ? <CollapseIcon/> : <ExpandIcon/>}
onClick={handleToggleExpandAll} onClick={handleToggleExpandAll}
ariaLabel={expandAll ? "Collapse All" : "Expand All"} ariaLabel={expandAll ? "Collapse All" : "Expand All"}
/> />
@ -175,7 +175,7 @@ const GroupLogs: FC<TableLogsProps> = ({ logs, settingsRef }) => {
<div ref={optionsButtonRef}> <div ref={optionsButtonRef}>
<Button <Button
variant="text" variant="text"
startIcon={<StorageIcon/> } startIcon={<StorageIcon/>}
onClick={toggleOpenOptions} onClick={toggleOpenOptions}
ariaLabel={"Group by"} ariaLabel={"Group by"}
/> />
@ -197,7 +197,7 @@ const GroupLogs: FC<TableLogsProps> = ({ logs, settingsRef }) => {
type="search" type="search"
/> />
</div> </div>
{logsKeys.map(id => ( {filteredLogsKeys.map(id => (
<div <div
className={classNames({ className={classNames({
"vm-list-item": true, "vm-list-item": true,

View file

@ -0,0 +1,54 @@
import React, { FC, memo, useCallback, useEffect, useState } from "preact/compat";
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
import Button from "../../../components/Main/Button/Button";
import { CopyIcon } from "../../../components/Main/Icons";
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
interface Props {
field: string;
value: string;
}
const GroupLogsFieldRow: FC<Props> = ({ field, value }) => {
const copyToClipboard = useCopyToClipboard();
const [copied, setCopied] = useState<boolean>(false);
const handleCopy = useCallback(async () => {
if (copied) return;
try {
await copyToClipboard(`${field}: "${value}"`);
setCopied(true);
} catch (e) {
console.error(e);
}
}, [copied, copyToClipboard]);
useEffect(() => {
if (copied === null) return;
const timeout = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(timeout);
}, [copied]);
return (
<tr className="vm-group-logs-row-fields-item">
<td className="vm-group-logs-row-fields-item-controls">
<div className="vm-group-logs-row-fields-item-controls__wrapper">
<Tooltip title={copied ? "Copied" : "Copy to clipboard"}>
<Button
variant="text"
color="gray"
size="small"
startIcon={<CopyIcon/>}
onClick={handleCopy}
ariaLabel="copy to clipboard"
/>
</Tooltip>
</div>
</td>
<td className="vm-group-logs-row-fields-item__key">{field}</td>
<td className="vm-group-logs-row-fields-item__value">{value}</td>
</tr>
);
};
export default memo(GroupLogsFieldRow);

View file

@ -1,13 +1,15 @@
import React, { FC, useEffect, useMemo, useState } from "preact/compat"; import React, { FC, memo, useMemo } from "preact/compat";
import { Logs } from "../../../api/types"; import { Logs } from "../../../api/types";
import "./style.scss"; import "./style.scss";
import useBoolean from "../../../hooks/useBoolean"; import useBoolean from "../../../hooks/useBoolean";
import Button from "../../../components/Main/Button/Button"; import { ArrowDownIcon } from "../../../components/Main/Icons";
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
import { ArrowDownIcon, CopyIcon } from "../../../components/Main/Icons";
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
import classNames from "classnames"; import classNames from "classnames";
import { useLogsState } from "../../../state/logsPanel/LogsStateContext"; import { useLogsState } from "../../../state/logsPanel/LogsStateContext";
import dayjs from "dayjs";
import { DATE_TIME_FORMAT } from "../../../constants/date";
import { useTimeState } from "../../../state/time/TimeStateContext";
import GroupLogsFieldRow from "./GroupLogsFieldRow";
import { marked } from "marked";
interface Props { interface Props {
log: Logs; log: Logs;
@ -20,40 +22,31 @@ const GroupLogsItem: FC<Props> = ({ log }) => {
} = useBoolean(false); } = useBoolean(false);
const { markdownParsing } = useLogsState(); const { markdownParsing } = useLogsState();
const { timezone } = useTimeState();
const excludeKeys = ["_msg", "_vmui_time", "_vmui_data", "_vmui_markdown"]; const formattedTime = useMemo(() => {
const fields = Object.entries(log).filter(([key]) => !excludeKeys.includes(key)); if (!log._time) return "";
return dayjs(log._time).tz().format(`${DATE_TIME_FORMAT}.SSS`);
}, [log._time, timezone]);
const formattedMarkdown = useMemo(() => {
if (!markdownParsing || !log._msg) return "";
return marked(log._msg.replace(/```/g, "\n```\n")) as string;
}, [log._msg, markdownParsing]);
const fields = useMemo(() => Object.entries(log).filter(([key]) => key !== "_msg"), [log]);
const hasFields = fields.length > 0; const hasFields = fields.length > 0;
const displayMessage = useMemo(() => { const displayMessage = useMemo(() => {
if (log._msg) return log._msg; if (log._msg) return log._msg;
if (!hasFields) return; if (!hasFields) return;
const dataObject = fields.reduce<{[key: string]: string}>((obj, [key, value]) => { const dataObject = fields.reduce<{ [key: string]: string }>((obj, [key, value]) => {
obj[key] = value; obj[key] = value;
return obj; return obj;
}, {}); }, {});
return JSON.stringify(dataObject); return JSON.stringify(dataObject);
}, [log, fields, hasFields]); }, [log, fields, hasFields]);
const copyToClipboard = useCopyToClipboard();
const [copied, setCopied] = useState<number | null>(null);
const createCopyHandler = (copyValue: string, rowIndex: number) => async () => {
if (copied === rowIndex) return;
try {
await copyToClipboard(copyValue);
setCopied(rowIndex);
} catch (e) {
console.error(e);
}
};
useEffect(() => {
if (copied === null) return;
const timeout = setTimeout(() => setCopied(null), 2000);
return () => clearTimeout(timeout);
}, [copied]);
return ( return (
<div className="vm-group-logs-row"> <div className="vm-group-logs-row">
<div <div
@ -74,10 +67,10 @@ const GroupLogsItem: FC<Props> = ({ log }) => {
<div <div
className={classNames({ className={classNames({
"vm-group-logs-row-content__time": true, "vm-group-logs-row-content__time": true,
"vm-group-logs-row-content__time_missing": !log._vmui_time "vm-group-logs-row-content__time_missing": !formattedTime
})} })}
> >
{log._vmui_time || "timestamp missing"} {formattedTime || "timestamp missing"}
</div> </div>
<div <div
className={classNames({ className={classNames({
@ -85,7 +78,7 @@ const GroupLogsItem: FC<Props> = ({ log }) => {
"vm-group-logs-row-content__msg_empty-msg": !log._msg, "vm-group-logs-row-content__msg_empty-msg": !log._msg,
"vm-group-logs-row-content__msg_missing": !displayMessage "vm-group-logs-row-content__msg_missing": !displayMessage
})} })}
dangerouslySetInnerHTML={markdownParsing && log._vmui_markdown ? { __html: log._vmui_markdown } : undefined} dangerouslySetInnerHTML={(markdownParsing && formattedMarkdown) ? { __html: formattedMarkdown } : undefined}
> >
{displayMessage || "-"} {displayMessage || "-"}
</div> </div>
@ -94,28 +87,12 @@ const GroupLogsItem: FC<Props> = ({ log }) => {
<div className="vm-group-logs-row-fields"> <div className="vm-group-logs-row-fields">
<table> <table>
<tbody> <tbody>
{fields.map(([key, value], i) => ( {fields.map(([key, value]) => (
<tr <GroupLogsFieldRow
key={key} key={key}
className="vm-group-logs-row-fields-item" field={key}
> value={value}
<td className="vm-group-logs-row-fields-item-controls"> />
<div className="vm-group-logs-row-fields-item-controls__wrapper">
<Tooltip title={copied === i ? "Copied" : "Copy to clipboard"}>
<Button
variant="text"
color="gray"
size="small"
startIcon={<CopyIcon/>}
onClick={createCopyHandler(`${key}: "${value}"`, i)}
ariaLabel="copy to clipboard"
/>
</Tooltip>
</div>
</td>
<td className="vm-group-logs-row-fields-item__key">{key}</td>
<td className="vm-group-logs-row-fields-item__value">{value}</td>
</tr>
))} ))}
</tbody> </tbody>
</table> </table>
@ -125,4 +102,4 @@ const GroupLogsItem: FC<Props> = ({ log }) => {
); );
}; };
export default GroupLogsItem; export default memo(GroupLogsItem);

View file

@ -9,7 +9,7 @@ export const useFetchLogs = (server: string, query: string, limit: number) => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [logs, setLogs] = useState<Logs[]>([]); const [logs, setLogs] = useState<Logs[]>([]);
const [isLoading, setIsLoading] = useState<{[key: number]: boolean;}>([]); const [isLoading, setIsLoading] = useState<{ [key: number]: boolean }>({});
const [error, setError] = useState<ErrorTypes | string>(); const [error, setError] = useState<ErrorTypes | string>();
const abortControllerRef = useRef(new AbortController()); const abortControllerRef = useRef(new AbortController());
@ -35,6 +35,7 @@ export const useFetchLogs = (server: string, query: string, limit: number) => {
try { try {
return JSON.parse(line); return JSON.parse(line);
} catch (e) { } catch (e) {
console.error("Failed to parse line to JSON", e);
return null; return null;
} }
}; };
@ -56,23 +57,25 @@ export const useFetchLogs = (server: string, query: string, limit: number) => {
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
setError(text); setError(text);
setLogs([]); setLogs([]);
setIsLoading(prev => ({ ...prev, [id]: false }));
return false; return false;
} }
const lines = text.split("\n").filter(line => line).slice(0, limit); const data = text.split("\n", limit).map(parseLineToJSON).filter(line => line) as Logs[];
const data = lines.map(parseLineToJSON).filter(line => line) as Logs[];
setLogs(data); setLogs(data);
setIsLoading(prev => ({ ...prev, [id]: false }));
return true; return true;
} catch (e) { } catch (e) {
setIsLoading(prev => ({ ...prev, [id]: false }));
if (e instanceof Error && e.name !== "AbortError") { if (e instanceof Error && e.name !== "AbortError") {
setError(String(e)); setError(String(e));
console.error(e); console.error(e);
setLogs([]); setLogs([]);
} }
return false; return false;
} finally {
setIsLoading(prev => {
// Remove the `id` key from `isLoading` when its value becomes `false`
const { [id]: _, ...rest } = prev;
return rest;
});
} }
}, [url, query, limit, searchParams]); }, [url, query, limit, searchParams]);

View file

@ -16,6 +16,10 @@ according to [these docs](https://docs.victoriametrics.com/victorialogs/quicksta
## tip ## tip
* FEATURE: add an ability to specify extra fields for logs ingested via [HTTP-based data ingestion protocols](https://docs.victoriametrics.com/victorialogs/data-ingestion/#http-apis). See `extra_fields` query arg and `VL-Extra-Fields` HTTP header in [these docs](https://docs.victoriametrics.com/victorialogs/data-ingestion/#http-parameters). * FEATURE: add an ability to specify extra fields for logs ingested via [HTTP-based data ingestion protocols](https://docs.victoriametrics.com/victorialogs/data-ingestion/#http-apis). See `extra_fields` query arg and `VL-Extra-Fields` HTTP header in [these docs](https://docs.victoriametrics.com/victorialogs/data-ingestion/#http-parameters).
* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): add frontend-only pagination for table view.
* FEATURE: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): improve memory consumption during data processing. This enhancement reduces the overall memory footprint, leading to better performance and stability.
* OPTIMIZATION: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): reduced memory usage across all tabs for improved performance and stability. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/7185).
## [v0.40.0](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v0.40.0-victorialogs) ## [v0.40.0](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v0.40.0-victorialogs)