vmui: improve table view (#3377)

* vmui: add compact table view (#3365)

* feat: add compact table view

* fix: add overflow table

* fix: change table styles

* vmui: compact table view

* Update docs/CHANGELOG.md

Co-authored-by: Michal Kralik <michal.kralik@percona.com>
Co-authored-by: Aliaksandr Valialkin <valyala@victoriametrics.com>
This commit is contained in:
Yury Molodov 2022-11-25 02:33:07 +01:00 committed by GitHub
parent 399ed9a3b9
commit eb772aa50e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 208 additions and 126 deletions

View file

@ -1,4 +1,4 @@
import React, { FC, useMemo, useState } from "preact/compat";
import React, { FC, useEffect, useMemo, useRef, useState } from "preact/compat";
import { InstantMetricResult } from "../../../api/types";
import { InstantDataSeries } from "../../../types";
import { useSortedCategories } from "../../../hooks/useSortedCategories";
@ -8,6 +8,10 @@ import { ArrowDropDownIcon, CopyIcon } from "../../Main/Icons";
import Tooltip from "../../Main/Tooltip/Tooltip";
import Button from "../../Main/Button/Button";
import { useSnack } from "../../../contexts/Snackbar";
import { getNameForMetric } from "../../../utils/metric";
import { useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext";
import "./style.scss";
import useResize from "../../../hooks/useResize";
export interface GraphViewProps {
data: InstantMetricResult[];
@ -17,12 +21,21 @@ export interface GraphViewProps {
const TableView: FC<GraphViewProps> = ({ data, displayColumns }) => {
const { showInfoMessage } = useSnack();
const sortedColumns = useSortedCategories(data, displayColumns);
const { tableCompact } = useCustomPanelState();
const windowSize = useResize(document.body);
const tableRef = useRef<HTMLTableElement>(null);
const [tableTop, setTableTop] = useState(0);
const [headTop, setHeadTop] = useState(0);
const [orderBy, setOrderBy] = useState("");
const [orderDir, setOrderDir] = useState<"asc" | "desc">("asc");
const getCopyValue = (metric: {[p: string]: string}) => {
const sortedColumns = (tableCompact
? useSortedCategories([{ group: 0, metric: { "Data": "Data" } }], ["Data"])
: useSortedCategories(data, displayColumns)
);
const getCopyValue = (metric: { [p: string]: string }) => {
const { __name__, ...fields } = metric;
if (!__name__ && !Object.keys(fields).length) return "";
return `${__name__} ${JSON.stringify(fields)}`;
@ -30,20 +43,23 @@ const TableView: FC<GraphViewProps> = ({ data, displayColumns }) => {
const rows: InstantDataSeries[] = useMemo(() => {
const rows = data?.map(d => ({
metadata: sortedColumns.map(c => d.metric[c.key] || "-"),
metadata: sortedColumns.map(c => (tableCompact
? getNameForMetric(d, undefined, "=", true)
: (d.metric[c.key] || "-")
)),
value: d.value ? d.value[1] : "-",
copyValue: getCopyValue(d.metric)
}));
const orderByValue = orderBy === "Value";
const rowIndex = sortedColumns.findIndex(c => c.key === orderBy);
if (!orderByValue && rowIndex === -1) return rows;
return rows.sort((a,b) => {
return rows.sort((a, b) => {
const n1 = orderByValue ? Number(a.value) : a.metadata[rowIndex];
const n2 = orderByValue ? Number(b.value) : b.metadata[rowIndex];
const asc = orderDir === "asc" ? n1 < n2 : n1 > n2;
return asc ? -1 : 1;
});
}, [sortedColumns, data, orderBy, orderDir]);
}, [sortedColumns, data, orderBy, orderDir, tableCompact]);
const hasCopyValue = useMemo(() => rows.some(r => r.copyValue), [rows]);
@ -65,93 +81,121 @@ const TableView: FC<GraphViewProps> = ({ data, displayColumns }) => {
copyHandler(copyValue);
};
const handleScroll = () => {
if (!tableRef.current) return;
const { top } = tableRef.current.getBoundingClientRect();
setHeadTop(top < 0 ? window.scrollY - tableTop : 0);
};
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [tableRef, tableTop, windowSize]);
useEffect(() => {
if (!tableRef.current) return;
const { top } = tableRef.current.getBoundingClientRect();
setTableTop(top + window.scrollY);
}, [tableRef, windowSize]);
if (!rows.length) return <Alert variant="warning">No data to show</Alert>;
return (
<table className="vm-table">
<thead className="vm-table-header">
<tr className="vm-table__row vm-table__row_header">
{sortedColumns.map((col, index) => (
<div className="vm-table-view">
<table
className="vm-table"
ref={tableRef}
>
<thead className="vm-table-header">
<tr
className="vm-table__row vm-table__row_header"
style={{ transform: `translateY(${headTop}px)` }}
>
{sortedColumns.map((col, index) => (
<td
className="vm-table-cell vm-table-cell_header vm-table-cell_sort"
key={index}
onClick={createSortHandler(col.key)}
>
<div className="vm-table-cell__content">
{col.key}
<div
className={classNames({
"vm-table__sort-icon": true,
"vm-table__sort-icon_active": orderBy === col.key,
"vm-table__sort-icon_desc": orderDir === "desc" && orderBy === col.key
})}
>
<ArrowDropDownIcon/>
</div>
</div>
</td>
))}
<td
className="vm-table-cell vm-table-cell_header vm-table-cell_sort"
key={index}
onClick={createSortHandler(col.key)}
className="vm-table-cell vm-table-cell_header vm-table-cell_right vm-table-cell_sort"
onClick={createSortHandler("Value")}
>
<div className="vm-table-cell__content">
{col.key}
<div
className={classNames({
"vm-table__sort-icon": true,
"vm-table__sort-icon_active": orderBy === col.key,
"vm-table__sort-icon_desc": orderDir === "desc" && orderBy === col.key
"vm-table__sort-icon_active": orderBy === "Value",
"vm-table__sort-icon_desc": orderDir === "desc"
})}
>
<ArrowDropDownIcon/>
</div>
Value
</div>
</td>
))}
<td
className="vm-table-cell vm-table-cell_header vm-table-cell_right vm-table-cell_sort"
onClick={createSortHandler("Value")}
>
<div className="vm-table-cell__content">
<div
className={classNames({
"vm-table__sort-icon": true,
"vm-table__sort-icon_active": orderBy === "Value",
"vm-table__sort-icon_desc": orderDir === "desc"
})}
>
<ArrowDropDownIcon/>
</div>
Value
</div>
</td>
{hasCopyValue && <td className="vm-table-cell vm-table-cell_header"/>}
</tr>
</thead>
<tbody className="vm-table-body">
{rows.map((row, index) => (
<tr
className="vm-table__row"
key={index}
>
{row.metadata.map((rowMeta, index2) => (
<td
className={classNames({
"vm-table-cell vm-table-cell_no-wrap": true,
"vm-table-cell_gray": rows[index - 1] && rows[index - 1].metadata[index2] === rowMeta
})}
key={index2}
>
{rowMeta}
</td>
))}
<td className="vm-table-cell vm-table-cell_right">
{row.value}
</td>
{hasCopyValue && (
<td className="vm-table-cell vm-table-cell_right">
{row.copyValue && (
<div className="vm-table-cell__content">
<Tooltip title="Copy row">
<Button
variant="text"
color="gray"
size="small"
startIcon={<CopyIcon/>}
onClick={createCopyHandler(row.copyValue)}
/>
</Tooltip>
</div>
)}
</td>
)}
{hasCopyValue && <td className="vm-table-cell vm-table-cell_header"/>}
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="vm-table-body">
{rows.map((row, index) => (
<tr
className="vm-table__row"
key={index}
>
{row.metadata.map((rowMeta, index2) => (
<td
className={classNames({
"vm-table-cell vm-table-cell_no-wrap": true,
"vm-table-cell_gray": rows[index - 1] && rows[index - 1].metadata[index2] === rowMeta
})}
key={index2}
>
{rowMeta}
</td>
))}
<td className="vm-table-cell vm-table-cell_right">
{row.value}
</td>
{hasCopyValue && (
<td className="vm-table-cell vm-table-cell_right">
{row.copyValue && (
<div className="vm-table-cell__content">
<Tooltip title="Copy row">
<Button
variant="text"
color="gray"
size="small"
startIcon={<CopyIcon/>}
onClick={createCopyHandler(row.copyValue)}
/>
</Tooltip>
</div>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
};

View file

@ -0,0 +1,11 @@
@use "src/styles/variables" as *;
.vm-table-view {
margin-top: -$padding-medium;
max-width: 100%;
overflow: auto;
table {
margin-top: 0;
}
}

View file

@ -2,13 +2,16 @@ import React, { FC, useEffect, useState, useRef, useMemo } from "preact/compat";
import { useSortedCategories } from "../../../../hooks/useSortedCategories";
import { InstantMetricResult } from "../../../../api/types";
import Button from "../../../../components/Main/Button/Button";
import { CloseIcon, SettingsIcon } from "../../../../components/Main/Icons";
import { CloseIcon, RestartIcon, SettingsIcon } from "../../../../components/Main/Icons";
import Popper from "../../../../components/Main/Popper/Popper";
import "./style.scss";
import Checkbox from "../../../../components/Main/Checkbox/Checkbox";
import Tooltip from "../../../../components/Main/Tooltip/Tooltip";
import { useCustomPanelDispatch, useCustomPanelState } from "../../../../state/customPanel/CustomPanelStateContext";
import Switch from "../../../../components/Main/Switch/Switch";
import { arrayEquals } from "../../../../utils/array";
const title = "Display columns";
const title = "Table settings";
interface TableSettingsProps {
data: InstantMetricResult[];
@ -16,32 +19,33 @@ interface TableSettingsProps {
onChange: (arr: string[]) => void
}
const TableSettings: FC<TableSettingsProps> = ({ data, defaultColumns, onChange }) => {
const buttonRef = useRef<HTMLDivElement>(null);
const [openSettings, setOpenSettings] = useState(false);
const TableSettings: FC<TableSettingsProps> = ({ data, defaultColumns = [], onChange }) => {
const { tableCompact } = useCustomPanelState();
const customPanelDispatch = useCustomPanelDispatch();
const columns = useSortedCategories(data);
const buttonRef = useRef<HTMLDivElement>(null);
const [openSettings, setOpenSettings] = useState(false);
const disabledButton = useMemo(() => !columns.length, [columns]);
const [checkedColumns, setCheckedColumns] = useState(columns.map(col => col.key));
const handleChange = (key: string) => {
setCheckedColumns(prev => checkedColumns.includes(key) ? prev.filter(col => col !== key) : [...prev, key]);
onChange(defaultColumns.includes(key) ? defaultColumns.filter(col => col !== key) : [...defaultColumns, key]);
};
const handleClose = () => {
setOpenSettings(false);
setCheckedColumns(defaultColumns || columns.map(col => col.key));
};
const handleReset = () => {
setOpenSettings(false);
const value = columns.map(col => col.key);
setCheckedColumns(value);
onChange(value);
const toggleTableCompact = () => {
customPanelDispatch({ type: "TOGGLE_TABLE_COMPACT" });
};
const handleApply = () => {
const handleResetColumns = () => {
setOpenSettings(false);
onChange(checkedColumns);
onChange(columns.map(col => col.key));
};
const createHandlerChange = (key: string) => () => {
@ -53,7 +57,9 @@ const TableSettings: FC<TableSettingsProps> = ({ data, defaultColumns, onChange
};
useEffect(() => {
setCheckedColumns(columns.map(col => col.key));
const values = columns.map(col => col.key);
if (arrayEquals(values, defaultColumns)) return;
onChange(values);
}, [columns]);
return (
@ -86,36 +92,39 @@ const TableSettings: FC<TableSettingsProps> = ({ data, defaultColumns, onChange
/>
</div>
<div className="vm-table-settings-popper-list">
<Switch
label={"Compact view"}
value={tableCompact}
onChange={toggleTableCompact}
/>
</div>
<div className="vm-table-settings-popper-list">
<div className="vm-table-settings-popper-list-header">
<h3 className="vm-table-settings-popper-list-header__title">Display columns</h3>
<Tooltip title="Reset to default">
<Button
color="primary"
variant="text"
size="small"
onClick={handleResetColumns}
startIcon={<RestartIcon/>}
/>
</Tooltip>
</div>
{columns.map(col => (
<div
className="vm-table-settings-popper-list__item"
key={col.key}
>
<Checkbox
checked={checkedColumns.includes(col.key)}
checked={defaultColumns.includes(col.key)}
onChange={createHandlerChange(col.key)}
label={col.key}
disabled={tableCompact}
/>
</div>
))}
</div>
<div className="vm-table-settings-popper__footer">
<Button
color="error"
variant="outlined"
size="small"
onClick={handleReset}
>
Reset
</Button>
<Button
variant="contained"
size="small"
onClick={handleApply}
>
apply
</Button>
</div>
</div>
</Popper>
</div>

View file

@ -2,27 +2,31 @@
.vm-table-settings-popper {
display: grid;
min-width: 250px;
&-list {
display: grid;
gap: $padding-small;
padding: $padding-global $padding-global $padding-medium;
padding: $padding-global;
max-height: 350px;
overflow: auto;
border-bottom: $border-divider;
&-header {
display: grid;
align-items: center;
justify-content: space-between;
grid-template-columns: 1fr auto;
min-height: 25px;
&__title {
font-weight: bold;
}
}
&__item {
font-size: $font-size;
text-transform: capitalize;
}
}
&__footer {
min-width: 200px;
display: inline-grid;
grid-template-columns: 1fr 1fr;
gap: $padding-small;
align-items: center;
justify-content: center;
padding: 0 $padding-global $padding-global;
}
}

View file

@ -2,6 +2,7 @@
.vm-custom-panel {
display: grid;
grid-template-columns: 100%;
align-items: flex-start;
gap: $padding-medium;
height: 100%;

View file

@ -1,6 +1,6 @@
import { DisplayType, displayTypeTabs } from "../../pages/CustomPanel/DisplayTypeSwitch";
import { getFromStorage, saveToStorage } from "../../utils/storage";
import { getQueryStringValue } from "../../utils/query-string";
import { getFromStorage, saveToStorage } from "../../utils/storage";
import { SeriesLimits } from "../../types";
import { DEFAULT_MAX_SERIES } from "../../constants/graph";
@ -9,6 +9,7 @@ export interface CustomPanelState {
nocache: boolean;
isTracingEnabled: boolean;
seriesLimits: SeriesLimits
tableCompact: boolean;
}
export type CustomPanelAction =
@ -16,6 +17,7 @@ export type CustomPanelAction =
| { type: "SET_SERIES_LIMITS", payload: SeriesLimits }
| { type: "TOGGLE_NO_CACHE"}
| { type: "TOGGLE_QUERY_TRACING" }
| { type: "TOGGLE_TABLE_COMPACT" }
const queryTab = getQueryStringValue("g0.tab", 0) as string;
const displayType = displayTypeTabs.find(t => t.prometheusCode === +queryTab || t.value === queryTab);
@ -25,7 +27,8 @@ export const initialCustomPanelState: CustomPanelState = {
displayType: (displayType?.value || "chart") as DisplayType,
nocache: false,
isTracingEnabled: false,
seriesLimits: limitsStorage ? JSON.parse(getFromStorage("SERIES_LIMITS") as string) : DEFAULT_MAX_SERIES
seriesLimits: limitsStorage ? JSON.parse(getFromStorage("SERIES_LIMITS") as string) : DEFAULT_MAX_SERIES,
tableCompact: getFromStorage("TABLE_COMPACT") as boolean || false,
};
export function reducer(state: CustomPanelState, action: CustomPanelAction): CustomPanelState {
@ -52,6 +55,12 @@ export function reducer(state: CustomPanelState, action: CustomPanelAction): Cus
...state,
nocache: !state.nocache
};
case "TOGGLE_TABLE_COMPACT":
saveToStorage("TABLE_COMPACT", !state.tableCompact);
return {
...state,
tableCompact: !state.tableCompact
};
default:
throw new Error();
}

View file

@ -1,6 +1,6 @@
import { MetricBase } from "../api/types";
export const getNameForMetric = (result: MetricBase, alias?: string): string => {
export const getNameForMetric = (result: MetricBase, alias?: string, connector = ": ", quoteValue = false): string => {
const { __name__, ...freeFormFields } = result.metric;
const name = alias || __name__ || "";
@ -8,5 +8,7 @@ export const getNameForMetric = (result: MetricBase, alias?: string): string =>
return name || `Result ${result.group}`; // a bit better than just {} for case of aggregation functions
}
return `${name} {${Object.entries(freeFormFields).map(e => `${e[0]}: ${e[1]}`).join(", ")}}`;
return `${name} {${Object.entries(freeFormFields).map(e =>
`${e[0]}${connector}${(quoteValue ? `"${e[1]}"` : e[1])}`
).join(", ")}}`;
};

View file

@ -5,6 +5,7 @@ export type StorageKeys = "BASIC_AUTH_DATA"
| "NO_CACHE"
| "QUERY_TRACING"
| "SERIES_LIMITS"
| "TABLE_COMPACT"
export const saveToStorage = (key: StorageKeys, value: string | boolean | Record<string, unknown>): void => {
if (value) {

View file

@ -25,6 +25,7 @@ The following tip changes can be tested by building VictoriaMetrics components f
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): reduce JS bundle size from 200Kb to 100Kb. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3298).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add the ability to hide results of a particular query by clicking the `eye` icon. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3359).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add copy button to row on Table view. The button copies row in MetricQL format. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/2815).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add compact table view. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3241).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add the ability to "stick" a tooltip on the chart by clicking on a data point. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3321) and [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3376)
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add the ability to set up series custom limits. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3297).
* FEATURE: [vmalert](https://docs.victoriametrics.com/vmalert.html): add default alert list for vmalert's metrics. See [alerts-vmalert.yml](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/master/deployment/docker/alerts-vmalert.yml).