vmui: improvements to the UI styles (#3704)

* feat: add dark theme

* update packages

* feat: add multilevel menu (#3678)

* fix: correct styles

* fix: update link to cardinality-explorer

* fix: remove unused scss variables

* docs/CHANGELOG.md: document the changes

Co-authored-by: Aliaksandr Valialkin <valyala@victoriametrics.com>
This commit is contained in:
Yury Molodov 2023-01-24 18:20:31 +01:00 committed by GitHub
parent 1a8875b417
commit 20ad848c5d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 1493 additions and 658 deletions

File diff suppressed because it is too large Load diff

View file

@ -17,15 +17,11 @@ const App: FC = () => {
const [loadingTheme, setLoadingTheme] = useState(true);
if (loadingTheme) return (
<>
<Spinner/>
<ThemeProvider setLoadingTheme={setLoadingTheme}/>;
</>
);
return <>
<HashRouter>
{loadingTheme && <Spinner/>}
<ThemeProvider setLoadingTheme={setLoadingTheme}/>
<HashRouter key={`${loadingTheme}`}>
<AppContextProvider>
<Routes>
<Route

View file

@ -3,11 +3,13 @@ import uPlot, { Options as uPlotOptions } from "uplot";
import useResize from "../../../hooks/useResize";
import { BarChartProps } from "./types";
import "./style.scss";
import { useAppState } from "../../../state/common/StateContext";
const BarChart: FC<BarChartProps> = ({
data,
container,
configs }) => {
const { darkTheme } = useAppState();
const uPlotRef = useRef<HTMLDivElement>(null);
const [uPlotInst, setUPlotInst] = useState<uPlot>();
@ -28,7 +30,7 @@ const BarChart: FC<BarChartProps> = ({
const u = new uPlot(options, data, uPlotRef.current);
setUPlotInst(u);
return u.destroy;
}, [uPlotRef.current, layoutSize]);
}, [uPlotRef.current, layoutSize, darkTheme]);
useEffect(() => updateChart(), [data]);

View file

@ -25,7 +25,6 @@ $chart-tooltip-y: -1 * ($padding-small + $chart-tooltip-half-icon);
pointer-events: none;
&_sticky {
background-color: $color-dove-gray;
pointer-events: auto;
z-index: 99;
}

View file

@ -21,6 +21,7 @@ import "./style.scss";
import classNames from "classnames";
import ChartTooltip, { ChartTooltipProps } from "../ChartTooltip/ChartTooltip";
import dayjs from "dayjs";
import { useAppState } from "../../../state/common/StateContext";
export interface LineChartProps {
metrics: MetricResult[];
@ -47,6 +48,8 @@ const LineChart: FC<LineChartProps> = ({
container,
height
}) => {
const { darkTheme } = useAppState();
const uPlotRef = useRef<HTMLDivElement>(null);
const [isPanning, setPanning] = useState(false);
const [xRange, setXRange] = useState({ min: period.start, max: period.end });
@ -222,7 +225,7 @@ const LineChart: FC<LineChartProps> = ({
setUPlotInst(u);
setXRange({ min: period.start, max: period.end });
return u.destroy;
}, [uPlotRef.current, series, layoutSize, height]);
}, [uPlotRef.current, series, layoutSize, height, darkTheme]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);

View file

@ -13,6 +13,7 @@ import { getAppModeEnable } from "../../../utils/app-mode";
import classNames from "classnames";
import Timezones from "./Timezones/Timezones";
import { useTimeDispatch, useTimeState } from "../../../state/time/TimeStateContext";
import ThemeControl from "../ThemeControl/ThemeControl";
const title = "Settings";
@ -82,6 +83,9 @@ const GlobalSettings: FC = () => {
onChange={setTimezone}
/>
</div>
<div className="vm-server-configurator__input">
<ThemeControl/>
</div>
<div className="vm-server-configurator__footer">
<Button
variant="outlined"

View file

@ -23,7 +23,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
background-color: rgba($color-black, 0.06);
background-color: $color-hover-black;
padding: calc($padding-small/2);
border-radius: $border-radius-small;
}

View file

@ -57,6 +57,7 @@ const GraphSettings: FC<GraphSettingsProps> = ({ yaxis, setYaxisLimits, toggleEn
</h3>
<Button
size="small"
variant="text"
startIcon={<CloseIcon/>}
onClick={handleClose}
/>

View file

@ -158,7 +158,7 @@ const StepConfigurator: FC = () => {
className="vm-link vm-link_colored"
href="https://docs.victoriametrics.com/keyConcepts.html#range-query"
target="_blank"
rel="noreferrer"
rel="help noreferrer"
>
Read more about Range query
</a>

View file

@ -29,7 +29,7 @@
padding: 0.2em 0.4em;
margin: 0 0.2em;
font-size: 85%;
background-color: rgba($color-black, 0.05);
background-color: $color-hover-black;
border-radius: 6px;
}
}

View file

@ -0,0 +1,44 @@
import React from "react";
import "./style.scss";
import classNames from "classnames";
import { useAppDispatch, useAppState } from "../../../state/common/StateContext";
const options = [
{ title: "Light", value: false },
{ title: "Dark", value: true }
];
const ThemeControl = () => {
const { darkTheme } = useAppState();
const dispatch = useAppDispatch();
const createHandlerClickItem = (value: boolean) => () => {
dispatch({ type: "SET_DARK_THEME", payload: value });
};
return (
<div className="vm-theme-control">
<div className="vm-server-configurator__title">
Theme preferences
</div>
<div className="vm-theme-control-options">
<div
className="vm-theme-control-options__highlight"
style={{ left: darkTheme ? "50%" : 0 }}
/>
{options.map(item => (
<div
className={classNames({
"vm-theme-control-options__item": true,
"vm-theme-control-options__item_active": item.value === darkTheme
})}
onClick={createHandlerClickItem(item.value)}
key={item.title}
>{item.title}</div>
))}
</div>
</div>
);
};
export default ThemeControl;

View file

@ -0,0 +1,44 @@
@use "src/styles/variables" as *;
.vm-theme-control {
&-options {
position: relative;
display: inline-flex;
border: $border-divider;
border-radius: $border-radius-medium;
overflow: hidden;
&__item {
position: relative;
padding: $padding-small $padding-global;
border-right: $border-divider;
color: $color-text;
cursor: pointer;
z-index: 2;
transition: color 200ms ease-out;
user-select: none;
&:last-child {
border: none;
}
&_active {
color: $color-primary-text;
}
&:hover {
box-shadow: $box-shadow-popper;
}
}
&__highlight {
position: absolute;
top: 0;
left: 0;
background-color: $color-primary;
height: 100%;
width: 50%;
transition: left 150ms ease-in;
}
}
}

View file

@ -13,11 +13,14 @@ import useResize from "../../../../hooks/useResize";
import DatePicker from "../../../Main/DatePicker/DatePicker";
import "./style.scss";
import useClickOutside from "../../../../hooks/useClickOutside";
import classNames from "classnames";
import { useAppState } from "../../../../state/common/StateContext";
export const TimeSelector: FC = () => {
const { darkTheme } = useAppState();
const wrapperRef = useRef<HTMLDivElement>(null);
const documentSize = useResize(document.body);
const displayFullDate = useMemo(() => documentSize.width > 1120, [documentSize]);
const displayFullDate = useMemo(() => documentSize.width > 1280, [documentSize]);
const [until, setUntil] = useState<string>();
const [from, setFrom] = useState<string>();
@ -120,7 +123,7 @@ export const TimeSelector: FC = () => {
return <>
<div ref={buttonRef}>
<Tooltip title="Time range controls">
<Tooltip title={displayFullDate ? "Time range controls" : dateTitle}>
<Button
className={appModeEnable ? "" : "vm-header-button"}
variant="contained"
@ -144,7 +147,12 @@ export const TimeSelector: FC = () => {
ref={wrapperRef}
>
<div className="vm-time-selector-left">
<div className="vm-time-selector-left-inputs">
<div
className={classNames({
"vm-time-selector-left-inputs": true,
"vm-time-selector-left-inputs_dark": darkTheme
})}
>
<div
className="vm-time-selector-left-inputs__date"
ref={fromRef}

View file

@ -18,6 +18,10 @@
align-items: flex-start;
justify-content: stretch;
&_dark &__date {
border-color: $color-text-disabled;
}
&__date {
display: grid;
grid-template-columns: 1fr 14px;
@ -70,7 +74,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
background-color: rgba($color-black, 0.06);
background-color: $color-hover-black;
padding: calc($padding-small/2);
border-radius: $border-radius-small;
}

View file

@ -39,7 +39,7 @@
code {
padding: 0.2em 0.4em;
font-size: 85%;
background-color: rgba($color-black, 0.05);
background-color: $color-hover-black;
border-radius: 6px;
}
}

View file

@ -1,7 +1,7 @@
import React, { FC } from "preact/compat";
import dayjs from "dayjs";
import "./style.scss";
import { LogoIcon } from "../../Main/Icons";
import { IssueIcon, LogoIcon, WikiIcon } from "../../Main/Icons";
const Footer: FC = () => {
const copyrightYears = `2019-${dayjs().format("YYYY")}`;
@ -11,18 +11,28 @@ const Footer: FC = () => {
className="vm-link vm-footer__website"
target="_blank"
href="https://victoriametrics.com/"
rel="noreferrer"
rel="me noreferrer"
>
<LogoIcon/>
victoriametrics.com
</a>
<a
className="vm-link"
className="vm-link vm-footer__link"
target="_blank"
href="https://docs.victoriametrics.com/#vmui"
rel="help noreferrer"
>
<WikiIcon/>
Documentation
</a>
<a
className="vm-link vm-footer__link"
target="_blank"
href="https://github.com/VictoriaMetrics/VictoriaMetrics/issues/new/choose"
rel="noreferrer"
>
create an issue
<IssueIcon/>
Create an issue
</a>
<div className="vm-footer__copyright">
&copy; {copyrightYears} VictoriaMetrics

View file

@ -5,10 +5,12 @@
align-items: center;
justify-content: center;
padding: $padding-medium;
gap: $padding-large;
gap: $padding-medium;
border-top: $border-divider;
color: $color-text-secondary;
background: $color-background-body;
&__link,
&__website {
display: grid;
grid-template-columns: 12px auto;
@ -17,6 +19,14 @@
gap: 6px;
}
&__website {
margin-right: $padding-global;
}
&__link {
grid-template-columns: 14px auto;
}
&__copyright {
text-align: right;
flex-grow: 1;

View file

@ -1,86 +1,58 @@
import React, { FC, useMemo, useState } from "preact/compat";
import React, { FC, useMemo } from "preact/compat";
import { ExecutionControls } from "../../Configurators/TimeRangeSettings/ExecutionControls/ExecutionControls";
import { setQueryStringWithoutPageReload } from "../../../utils/query-string";
import { TimeSelector } from "../../Configurators/TimeRangeSettings/TimeSelector/TimeSelector";
import GlobalSettings from "../../Configurators/GlobalSettings/GlobalSettings";
import { useLocation, useNavigate } from "react-router-dom";
import router, { RouterOptions, routerOptions } from "../../../router";
import { useEffect } from "react";
import ShortcutKeys from "../../Main/ShortcutKeys/ShortcutKeys";
import { getAppModeEnable, getAppModeParams } from "../../../utils/app-mode";
import CardinalityDatePicker from "../../Configurators/CardinalityDatePicker/CardinalityDatePicker";
import { LogoFullIcon } from "../../Main/Icons";
import { getCssVariable } from "../../../utils/theme";
import Tabs from "../../Main/Tabs/Tabs";
import "./style.scss";
import classNames from "classnames";
import { useDashboardsState } from "../../../state/dashboards/DashboardsStateContext";
import StepConfigurator from "../../Configurators/StepConfigurator/StepConfigurator";
import { useAppState } from "../../../state/common/StateContext";
import HeaderNav from "./HeaderNav/HeaderNav";
const Header: FC = () => {
const primaryColor = getCssVariable("color-primary");
const { darkTheme } = useAppState();
const appModeEnable = getAppModeEnable();
const { dashboardsSettings } = useDashboardsState();
const primaryColor = useMemo(() => {
const variable = darkTheme ? "color-background-block" : "color-primary";
return getCssVariable(variable);
}, [darkTheme]);
const { background, color } = useMemo(() => {
const { headerStyles: {
background = appModeEnable ? "#FFF" : primaryColor,
color = appModeEnable ? primaryColor : "#FFF",
} = {} } = getAppModeParams();
return { background, color };
}, [primaryColor]);
const navigate = useNavigate();
const { search, pathname } = useLocation();
const routes = useMemo(() => ([
{
label: routerOptions[router.home].title,
value: router.home,
},
{
label: routerOptions[router.metrics].title,
value: router.metrics,
},
{
label: routerOptions[router.cardinality].title,
value: router.cardinality,
},
{
label: routerOptions[router.topQueries].title,
value: router.topQueries,
},
{
label: routerOptions[router.trace].title,
value: router.trace,
},
{
label: routerOptions[router.dashboards].title,
value: router.dashboards,
hide: appModeEnable || !dashboardsSettings.length
}
]), [appModeEnable, dashboardsSettings]);
const [activeMenu, setActiveMenu] = useState(pathname);
const headerSetup = useMemo(() => {
return ((routerOptions[pathname] || {}) as RouterOptions).header || {};
}, [pathname]);
const onClickLogo = () => {
navigateHandler(router.home);
navigate({ pathname: router.home, search: search });
setQueryStringWithoutPageReload({});
window.location.reload();
};
const navigateHandler = (pathname: string) => {
navigate({ pathname, search: search });
};
useEffect(() => {
setActiveMenu(pathname);
}, [pathname]);
return <header
className={classNames({
"vm-header": true,
"vm-header_app": appModeEnable
"vm-header_app": appModeEnable,
"vm-header_dark": darkTheme
})}
style={{ background, color }}
>
@ -93,14 +65,10 @@ const Header: FC = () => {
<LogoFullIcon/>
</div>
)}
<div className="vm-header-nav">
<Tabs
isNavLink
activeItem={activeMenu}
items={routes.filter(r => !r.hide)}
<HeaderNav
color={color}
background={background}
/>
</div>
<div className="vm-header__settings">
{headerSetup?.stepControl && <StepConfigurator/>}
{headerSetup?.timeSelector && <TimeSelector/>}

View file

@ -0,0 +1,89 @@
import React, { FC, useMemo, useState } from "preact/compat";
import router, { routerOptions } from "../../../../router";
import { getAppModeEnable } from "../../../../utils/app-mode";
import { useLocation } from "react-router-dom";
import { useDashboardsState } from "../../../../state/dashboards/DashboardsStateContext";
import { useEffect } from "react";
import "./style.scss";
import NavItem from "./NavItem";
import NavSubItem from "./NavSubItem";
interface HeaderNavProps {
color: string
background: string
}
const HeaderNav: FC<HeaderNavProps> = ({ color, background }) => {
const appModeEnable = getAppModeEnable();
const { dashboardsSettings } = useDashboardsState();
const { pathname } = useLocation();
const [activeMenu, setActiveMenu] = useState(pathname);
const menu = useMemo(() => ([
{
label: routerOptions[router.home].title,
value: router.home,
},
{
label: "Explore",
submenu: [
{
label: routerOptions[router.metrics].title,
value: router.metrics,
},
{
label: routerOptions[router.cardinality].title,
value: router.cardinality,
},
{
label: routerOptions[router.topQueries].title,
value: router.topQueries,
},
]
},
{
label: routerOptions[router.trace].title,
value: router.trace,
},
{
label: routerOptions[router.dashboards].title,
value: router.dashboards,
hide: appModeEnable || !dashboardsSettings.length,
}
].filter(r => !r.hide)), [appModeEnable, dashboardsSettings]);
useEffect(() => {
setActiveMenu(pathname);
}, [pathname]);
return (
<nav className="vm-header-nav">
{menu.map(m => (
m.submenu
? (
<NavSubItem
key={m.label}
activeMenu={activeMenu}
label={m.label || ""}
submenu={m.submenu}
color={color}
background={background}
/>
)
: (
<NavItem
key={m.value}
activeMenu={activeMenu}
value={m.value}
label={m.label || ""}
color={color}
/>
)
))}
</nav>
);
};
export default HeaderNav;

View file

@ -0,0 +1,30 @@
import React, { FC } from "preact/compat";
import { NavLink } from "react-router-dom";
import classNames from "classnames";
interface NavItemProps {
activeMenu: string,
label: string,
value: string,
color?: string
}
const NavItem: FC<NavItemProps> = ({
activeMenu,
label,
value,
color
}) => (
<NavLink
className={classNames({
"vm-header-nav-item": true,
"vm-header-nav-item_active": activeMenu === value // || m.submenu?.find(m => m.value === activeMenu)
})}
style={{ color }}
to={value}
>
{label}
</NavLink>
);
export default NavItem;

View file

@ -0,0 +1,96 @@
import React, { FC, useRef, useState } from "preact/compat";
import { useLocation } from "react-router-dom";
import classNames from "classnames";
import { ArrowDropDownIcon } from "../../../Main/Icons";
import Popper from "../../../Main/Popper/Popper";
import NavItem from "./NavItem";
import { useEffect } from "react";
interface NavItemProps {
activeMenu: string,
label: string,
submenu: {label: string | undefined, value: string}[],
color?: string
background?: string
}
const NavSubItem: FC<NavItemProps> = ({
activeMenu,
label,
color,
background,
submenu
}) => {
const { pathname } = useLocation();
const [openSubmenu, setOpenSubmenu] = useState(false);
const [menuTimeout, setMenuTimeout] = useState<NodeJS.Timeout | null>(null);
const buttonRef = useRef<HTMLDivElement>(null);
const handleOpenSubmenu = () => {
setOpenSubmenu(true);
if (menuTimeout) clearTimeout(menuTimeout);
};
const handleCloseSubmenu = () => {
setOpenSubmenu(false);
};
const handleMouseLeave = () => {
if (menuTimeout) clearTimeout(menuTimeout);
const timeout = setTimeout(handleCloseSubmenu, 300);
setMenuTimeout(timeout);
};
const handleMouseEnterPopup = () => {
if (menuTimeout) clearTimeout(menuTimeout);
};
useEffect(() => {
handleCloseSubmenu();
}, [pathname]);
return (
<div
className={classNames({
"vm-header-nav-item": true,
"vm-header-nav-item_sub": true,
"vm-header-nav-item_open": openSubmenu,
"vm-header-nav-item_active": submenu.find(m => m.value === activeMenu)
})}
style={{ color }}
onMouseEnter={handleOpenSubmenu}
onMouseLeave={handleMouseLeave}
ref={buttonRef}
>
{label}
<ArrowDropDownIcon/>
<Popper
open={openSubmenu}
placement="bottom-left"
offset={{ top: 12, left: 0 }}
onClose={handleCloseSubmenu}
buttonRef={buttonRef}
>
<div
className="vm-header-nav-item-submenu"
style={{ background }}
onMouseLeave={handleMouseLeave}
onMouseEnter={handleMouseEnterPopup}
>
{submenu.map(sm => (
<NavItem
key={sm.value}
activeMenu={activeMenu}
value={sm.value}
label={sm.label || ""}
/>
))}
</div>
</Popper>
</div>
);
};
export default NavSubItem;

View file

@ -0,0 +1,63 @@
@use "src/styles/variables" as *;
.vm-header-nav {
display: flex;
align-items: center;
justify-content: flex-start;
gap: $padding-global;
font-size: $font-size-small;
font-weight: bold;
&-item {
position: relative;
padding: $padding-global $padding-small;
opacity: 0.5;
cursor: pointer;
transition: opacity 200ms ease-in;
text-transform: uppercase;
&_sub {
display: grid;
grid-template-columns: auto 14px;
align-items: center;
justify-content: center;
gap: 4px;
cursor: default;
}
&:hover {
opacity: 1;
}
&_active {
opacity: 1;
}
svg {
transform: rotate(0deg);
transition: transform 200ms ease-in;
}
&_open {
svg {
transform: rotate(180deg);
}
}
&-submenu {
display: grid;
white-space: nowrap;
padding: $padding-small;
color: $color-white;
border-radius: 2px;
opacity: 1;
transform-origin: top center;
font-size: $font-size-small;
font-weight: bold;
&-item {
cursor: pointer;
}
}
}
}

View file

@ -6,17 +6,18 @@
align-items: center;
justify-content: flex-start;
padding: $padding-small $padding-medium;
gap: $padding-large;
gap: 0 $padding-large;
z-index: 99;
&_app {
padding: $padding-small 0;
}
@media (max-width: 1200px) {
gap: $padding-global;
.vm-tabs {
gap: 0;
&_dark {
.vm-header-button,
button:before,
button {
background-color: $color-background-block;
}
}
@ -43,11 +44,6 @@
}
}
&-nav {
font-size: $font-size-small;
font-weight: 600;
}
&__settings {
display: flex;
align-items: center;

View file

@ -3,6 +3,7 @@ import { ReactNode } from "react";
import classNames from "classnames";
import { ErrorIcon, InfoIcon, SuccessIcon, WarningIcon } from "../Icons";
import "./style.scss";
import { useAppState } from "../../../state/common/StateContext";
interface AlertProps {
variant?: "success" | "error" | "info" | "warning"
@ -19,12 +20,14 @@ const icons = {
const Alert: FC<AlertProps> = ({
variant,
children }) => {
const { darkTheme } = useAppState();
return (
<div
className={classNames({
"vm-alert": true,
[`vm-alert_${variant}`]: variant
[`vm-alert_${variant}`]: variant,
"vm-alert_dark": darkTheme
})}
>
<div className="vm-alert__icon">{icons[variant || "info"]}</div>

View file

@ -11,7 +11,7 @@
border-radius: $border-radius-medium;
box-shadow: $box-shadow;
font-size: $font-size-medium;
font-weight: 500;
font-weight: normal;
color: $color-text;
line-height: 20px;
@ -75,4 +75,14 @@
background-color: $color-warning;
}
}
&_dark {
&:after {
opacity: 0.1;
}
}
&_dark &__content {
filter: none;
}
}

View file

@ -10,7 +10,7 @@ $button-radius: 6px;
padding: 6px 14px;
font-size: $font-size-small;
line-height: 15px;
font-weight: 500;
font-weight: normal;
min-height: 31px;
border-radius: $button-radius;
color: $color-white;
@ -21,7 +21,7 @@ $button-radius: 6px;
white-space: nowrap;
&:hover:after {
background-color: rgba($color-black, 0.05);
background-color: $color-hover-black;
}
&:before,

View file

@ -104,7 +104,7 @@
transition: color 200ms ease, background-color 300ms ease-in-out;
&:hover {
background-color: rgba($color-black, 0.05);
background-color: $color-hover-black;
}
&_empty {
@ -144,7 +144,7 @@
transition: color 200ms ease, background-color 300ms ease-in-out;
&:hover {
background-color: rgba($color-black, 0.05);
background-color: $color-hover-black;
}
&_selected {
@ -270,6 +270,10 @@
justify-content: space-between;
margin-top: $padding-global;
&_dark &__input {
border-color: $color-text-disabled;
}
span {
margin: 0 $padding-small;
}
@ -277,11 +281,13 @@
&__input {
width: 64px;
height: 32px;
border: 1px solid $color-alto;
border: $border-divider;
border-radius: $border-radius-small;
font-size: $font-size-medium;
padding: 2px $padding-small;
text-align: center;
background-color: transparent;
color: $color-text;
&:focus {
border-color: $color-primary;

View file

@ -2,6 +2,7 @@ import React, { FC, useEffect, useMemo, useRef, useState } from "preact/compat";
import { Dayjs } from "dayjs";
import { FormEvent, FocusEvent } from "react";
import classNames from "classnames";
import { useAppState } from "../../../../state/common/StateContext";
interface CalendarTimepickerProps {
selectDate: Dayjs
@ -13,6 +14,7 @@ enum TimeUnits { hour, minutes, seconds }
const TimePicker: FC<CalendarTimepickerProps>= ({ selectDate, onChangeTime, onClose }) => {
const { darkTheme } = useAppState();
const [activeField, setActiveField] = useState<TimeUnits>(TimeUnits.hour);
const [hours, setHours] = useState(selectDate.format("HH"));
@ -154,7 +156,12 @@ const TimePicker: FC<CalendarTimepickerProps>= ({ selectDate, onChangeTime, onCl
</div>
))}
</div>
<div className="vm-calendar-time-picker-fields">
<div
className={classNames({
"vm-calendar-time-picker-fields": true,
"vm-calendar-time-picker-fields_dark": darkTheme
})}
>
<input
className="vm-calendar-time-picker-fields__input"
value={hours}

View file

@ -344,3 +344,48 @@ export const TimelineIcon = () => (
></path>
</svg>
);
export const WikiIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M21 5C19.89 4.65 18.67 4.5 17.5 4.5C15.55 4.5 13.45 4.9 12 6C10.55 4.9 8.45 4.5 6.5 4.5C5.33 4.5 4.11 4.65 3 5C2.25 5.25 1.6 5.55 1 6V20.6C1 20.85 1.25 21.1 1.5 21.1C1.6 21.1 1.65 21.1 1.75 21.05C3.15 20.3 4.85 20 6.5 20C8.2 20 10.65 20.65 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5ZM21 18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5C10.65 18.65 8.2 18 6.5 18C5.3 18 4.1 18.15 3 18.5V7C4.1 6.65 5.3 6.5 6.5 6.5C8.2 6.5 10.65 7.15 12 8C13.35 7.15 15.8 6.5 17.5 6.5C18.7 6.5 19.9 6.65 21 7V18.5Z"
/>
<path
d="M17.5 10.5C18.38 10.5 19.23 10.59 20 10.76V9.24C19.21 9.09 18.36 9 17.5 9C15.8 9 14.26 9.29 13 9.83V11.49C14.13 10.85 15.7 10.5 17.5 10.5ZM13 12.49V14.15C14.13 13.51 15.7 13.16 17.5 13.16C18.38 13.16 19.23 13.25 20 13.42V11.9C19.21 11.75 18.36 11.66 17.5 11.66C15.8 11.66 14.26 11.96 13 12.49ZM17.5 14.33C15.8 14.33 14.26 14.62 13 15.16V16.82C14.13 16.18 15.7 15.83 17.5 15.83C18.38 15.83 19.23 15.92 20 16.09V14.57C19.21 14.41 18.36 14.33 17.5 14.33Z"
/>
<path
d="M6.5 10.5C5.62 10.5 4.77 10.59 4 10.76V9.24C4.79 9.09 5.64 9 6.5 9C8.2 9 9.74 9.29 11 9.83V11.49C9.87 10.85 8.3 10.5 6.5 10.5ZM11 12.49V14.15C9.87 13.51 8.3 13.16 6.5 13.16C5.62 13.16 4.77 13.25 4 13.42V11.9C4.79 11.75 5.64 11.66 6.5 11.66C8.2 11.66 9.74 11.96 11 12.49ZM6.5 14.33C8.2 14.33 9.74 14.62 11 15.16V16.82C9.87 16.18 8.3 15.83 6.5 15.83C5.62 15.83 4.77 15.92 4 16.09V14.57C4.79 14.41 5.64 14.33 6.5 14.33Z"
/>
</svg>
);
export const IssueIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 2C6.49 2 2 6.49 2 12s4.49 10 10 10 10-4.49 10-10S17.51 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3-8c0 1.66-1.34 3-3 3s-3-1.34-3-3 1.34-3 3-3 3 1.34 3 3z"
></path>
</svg>
);
export const QuestionIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM12 6C9.79 6 8 7.79 8 10H10C10 8.9 10.9 8 12 8C13.1 8 14 8.9 14 10C14 10.8792 13.4202 11.3236 12.7704 11.8217C11.9421 12.4566 11 13.1787 11 15H13C13 13.9046 13.711 13.2833 14.4408 12.6455C15.21 11.9733 16 11.2829 16 10C16 7.79 14.21 6 12 6ZM13 16V18H11V16H13Z"
/>
</svg>
);

View file

@ -11,7 +11,7 @@
&-track {
width: 100%;
height: 20px;
background-color: rgba($color-black, 0.05);
background-color: $color-hover-black;
border-radius: $border-radius-small;
&__thumb {

View file

@ -8,7 +8,7 @@ $padding-modal: 22px;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
@ -16,9 +16,11 @@ $padding-modal: 22px;
&-content {
padding: $padding-modal;
background: $color-white;
background: $color-background-block;
box-shadow: 0 0 24px rgba($color-black, 0.07);
border-radius: $border-radius-small;
max-height: 90vh;
overflow: auto;
&-header {
display: grid;

View file

@ -3,6 +3,7 @@ import classNames from "classnames";
import { ArrowDropDownIcon, CloseIcon } from "../Icons";
import { FormEvent, MouseEvent } from "react";
import Autocomplete from "../Autocomplete/Autocomplete";
import { useAppState } from "../../../state/common/StateContext";
import "./style.scss";
interface SelectProps {
@ -26,6 +27,7 @@ const Select: FC<SelectProps> = ({
autofocus,
onChange
}) => {
const { darkTheme } = useAppState();
const [search, setSearch] = useState("");
const autocompleteAnchorEl = useRef<HTMLDivElement>(null);
@ -106,7 +108,12 @@ const Select: FC<SelectProps> = ({
}, []);
return (
<div className="vm-select">
<div
className={classNames({
"vm-select": true,
"vm-select_dark": darkTheme
})}
>
<div
className="vm-select-input"
onClick={handleToggleList}

View file

@ -24,7 +24,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
background-color: rgba($color-black, 0.06);
background-color: $color-hover-black;
padding: 2px 2px 2px 6px;
border-radius: $border-radius-small;
font-size: $font-size;
@ -60,6 +60,8 @@
z-index: 2;
min-width: 100px;
flex-grow: 1;
background-color: transparent;
color: $color-text;
&:placeholder-shown {
width: auto;

View file

@ -35,7 +35,7 @@
line-height: 2;
color: $color-text;
text-align: center;
background-color: $color-white;
background-color: $color-background-body;
background-repeat: repeat-x;
border: $border-divider;
border-radius: 4px;

View file

@ -1,5 +1,7 @@
import React, { CSSProperties, FC } from "preact/compat";
import "./style.scss";
import classNames from "classnames";
import { getFromStorage } from "../../../utils/storage";
interface SpinnerProps {
containerStyles?: CSSProperties;
@ -8,7 +10,10 @@ interface SpinnerProps {
const Spinner: FC<SpinnerProps> = ({ containerStyles = {}, message }) => (
<div
className="vm-spinner"
className={classNames({
"vm-spinner": true,
"vm-spinner_dark": getFromStorage("DARK_THEME")
})}
style={containerStyles && {}}
>
<div className="half-circle-spinner">

View file

@ -15,6 +15,10 @@
z-index: 99;
animation: vm-fade 2s cubic-bezier(0.280, 0.840, 0.420, 1.1);
&_dark {
background-color: rgba($color-black, 0.2);
}
&__message {
margin-top: $padding-medium;
white-space: pre-line;

View file

@ -1,6 +1,7 @@
import React, { FC, KeyboardEvent, useEffect, useRef, HTMLInputTypeAttribute, ReactNode } from "react";
import classNames from "classnames";
import { useMemo } from "preact/compat";
import { useAppState } from "../../../state/common/StateContext";
import "./style.scss";
interface TextFieldProps {
@ -38,6 +39,7 @@ const TextField: FC<TextFieldProps> = ({
onFocus,
onBlur
}) => {
const { darkTheme } = useAppState();
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -81,6 +83,7 @@ const TextField: FC<TextFieldProps> = ({
className={classNames({
"vm-text-field": true,
"vm-text-field_textarea": type === "textarea",
"vm-text-field_dark": darkTheme
})}
data-replicated-value={value}
>

View file

@ -67,6 +67,8 @@
min-height: 34px;
resize: none;
overflow: hidden;
background-color: transparent;
color: $color-text;
&:focus {
border: 1px solid $color-primary;

View file

@ -2,6 +2,8 @@ import { FC, useEffect } from "preact/compat";
import { getContrastColor } from "../../../utils/color";
import { getCssVariable, setCssVariable } from "../../../utils/theme";
import { AppParams, getAppModeParams } from "../../../utils/app-mode";
import { getFromStorage } from "../../../utils/storage";
import { darkPalette, lightPalette } from "../../../constants/palette";
interface StyleVariablesProps {
setLoadingTheme: (val: boolean) => void
@ -27,13 +29,6 @@ export const ThemeProvider: FC<StyleVariablesProps> = ({ setLoadingTheme }) => {
setCssVariable("scrollbar-height", `${innerHeight - clientHeight}px`);
};
const setAppModePalette = () => {
colorVariables.forEach(variable => {
const colorFromAppMode = palette[variable as keyof AppParams["palette"]];
if (colorFromAppMode) setCssVariable(`color-${variable}`, colorFromAppMode);
});
};
const setContrastText = () => {
colorVariables.forEach(variable => {
const color = getCssVariable(`color-${variable}`);
@ -42,11 +37,34 @@ export const ThemeProvider: FC<StyleVariablesProps> = ({ setLoadingTheme }) => {
});
};
const setAppModePalette = () => {
colorVariables.forEach(variable => {
const colorFromAppMode = palette[variable as keyof AppParams["palette"]];
if (colorFromAppMode) setCssVariable(`color-${variable}`, colorFromAppMode);
});
setContrastText();
};
const setTheme = () => {
const darkTheme = getFromStorage("DARK_THEME");
const palette = darkTheme ? darkPalette : lightPalette;
Object.entries(palette).forEach(([variable, value]) => {
setCssVariable(variable, value);
});
setContrastText();
};
useEffect(() => {
setAppModePalette();
setScrollbarSize();
setContrastText();
setTheme();
setLoadingTheme(false);
window.addEventListener("storage", setTheme);
return () => {
window.removeEventListener("storage", setTheme);
};
}, []);
return null;

View file

@ -4,6 +4,7 @@ import Trace from "../Trace";
import { ArrowDownIcon } from "../../Main/Icons";
import "./style.scss";
import classNames from "classnames";
import { useAppState } from "../../../state/common/StateContext";
interface RecursiveProps {
trace: Trace;
@ -15,6 +16,7 @@ interface OpenLevels {
}
const NestedNav: FC<RecursiveProps> = ({ trace, totalMsec }) => {
const { darkTheme } = useAppState();
const [openLevels, setOpenLevels] = useState({} as OpenLevels);
const handleListClick = (level: number) => () => {
@ -26,7 +28,12 @@ const NestedNav: FC<RecursiveProps> = ({ trace, totalMsec }) => {
const progress = trace.duration / totalMsec * 100;
return (
<div className="vm-nested-nav">
<div
className={classNames({
"vm-nested-nav": true,
"vm-nested-nav_dark": darkTheme,
})}
>
<div
className="vm-nested-nav-header"
onClick={handleListClick(trace.idValue)}

View file

@ -5,6 +5,10 @@
border-radius: $border-radius-small;
background-color: rgba($color-tropical-blue, 0.4);
&_dark {
background-color: rgba($color-black, 0.1);
}
&-header {
display: grid;
grid-template-columns: auto 1fr;
@ -15,7 +19,7 @@
cursor: pointer;
&:hover {
background-color: rgba($color-black, 0.06);
background-color: $color-hover-black;
}
&__icon {

View file

@ -14,5 +14,6 @@
transform: translateY(-32px);
font-size: $font-size;
line-height: 1.4;
white-space: pre-wrap;
}
}

View file

@ -0,0 +1,37 @@
export const darkPalette = {
"color-primary": "#589DF6",
"color-secondary": "#316eca",
"color-error": "#e5534b",
"color-warning": "#c69026",
"color-info": "#539bf5",
"color-success": "#57ab5a",
"color-background-body": "#22272e",
"color-background-block": "#2d333b",
"color-background-tooltip": "rgba(22, 22, 22, 0.6)",
"color-text": "#cdd9e5",
"color-text-secondary": "#768390",
"color-text-disabled": "#636e7b",
"box-shadow": "rgba(0, 0, 0, 0.16) 1px 2px 6px",
"box-shadow-popper": "rgba(0, 0, 0, 0.2) 0px 2px 8px 0px",
"border-divider": "1px solid rgba(99, 110, 123, 0.5)",
"color-hover-black": "rgba(0, 0, 0, 0.12)"
};
export const lightPalette = {
"color-primary": "#3F51B5",
"color-secondary": "#E91E63",
"color-error": "#FD080E",
"color-warning": "#FF8308",
"color-info": "#03A9F4",
"color-success": "#4CAF50",
"color-background-body": "#FEFEFF",
"color-background-block": "#FFFFFF",
"color-background-tooltip": "rgba(97,97,97, 0.92)",
"color-text": "#110f0f",
"color-text-secondary": "#706F6F",
"color-text-disabled": "#A09F9F",
"box-shadow": "rgba(0, 0, 0, 0.08) 1px 2px 6px",
"box-shadow-popper": "rgba(0, 0, 0, 0.1) 0px 2px 8px 0px",
"border-divider": "1px solid rgba(0, 0, 0, 0.15)",
"color-hover-black": "rgba(0, 0, 0, 0.06)"
};

View file

@ -4,11 +4,12 @@ import { useFetchQueryOptions } from "../../../hooks/useFetchQueryOptions";
import { ErrorTypes } from "../../../types";
import { useQueryDispatch, useQueryState } from "../../../state/query/QueryStateContext";
import Switch from "../../../components/Main/Switch/Switch";
import { PlayIcon } from "../../../components/Main/Icons";
import { PlayIcon, QuestionIcon } from "../../../components/Main/Icons";
import Button from "../../../components/Main/Button/Button";
import TextField from "../../../components/Main/TextField/TextField";
import "./style.scss";
import { useMemo } from "preact/compat";
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
export interface CardinalityConfiguratorProps {
onSetHistory: (step: number) => void;
@ -92,20 +93,33 @@ const CardinalityConfigurator: FC<CardinalityConfiguratorProps> = ({
onChange={onFocusLabelChange}
/>
</div>
<div className="vm-cardinality-configurator-controls__item">
</div>
<div className="vm-cardinality-configurator-bottom__autocomplete">
<Switch
label={"Autocomplete"}
value={autocomplete}
onChange={onChangeAutocomplete}
/>
</div>
</div>
<div className="vm-cardinality-configurator-bottom">
<div className="vm-cardinality-configurator-bottom__info">
Analyzed <b>{totalSeries}</b> series with <b>{totalLabelValuePairs}</b> &quot;label=value&quot; pairs
at <b>{date}</b>{match && <span> for series selector <b>{match}</b></span>}.
Show top {topN} entries per table.
</div>
<a
className="vm-cardinality-configurator-bottom__docs"
href="https://victoriametrics.com/blog/cardinality-explorer/"
target="_blank"
rel="help noreferrer"
>
<Tooltip title="Example of using">
<Button
variant="text"
startIcon={<QuestionIcon/>}
/>
</Tooltip>
</a>
<Button
startIcon={<PlayIcon/>}
onClick={onRunQuery}

View file

@ -18,12 +18,18 @@
&-bottom {
display: grid;
grid-template-columns: 1fr auto;
grid-template-columns: 1fr auto auto;
align-items: flex-end;
gap: $padding-medium;
gap: $padding-small;
&__info {
font-size: $font-size;
}
&__docs {
display: flex;
align-items: center;
justify-content: center;
}
}
}

View file

@ -89,6 +89,7 @@ const TableSettings: FC<TableSettingsProps> = ({ data, defaultColumns = [], onCh
onClick={handleClose}
startIcon={<CloseIcon/>}
size="small"
variant="text"
/>
</div>
<div className="vm-table-settings-popper-list">

View file

@ -2,20 +2,25 @@
.vm-json-form {
display: grid;
grid-template-rows: auto calc(90vh - 78px - ($padding-medium*3)) auto;
grid-template-rows: auto calc(70vh - 78px - ($padding-medium*3)) auto;
gap: $padding-global;
width: 70vw;
max-width: 1000px;
max-height: 900px;
overflow: hidden;
&_one-field {
grid-template-rows: calc(90vh - 78px - ($padding-medium*3)) auto;
grid-template-rows: calc(70vh - 78px - ($padding-medium*3)) auto;
}
.vm-text-field_textarea {
}
textarea {
overflow: auto;
width: 100%;
height: 100%;
max-height: 900px;
}
&-footer {

View file

@ -151,7 +151,7 @@ const TracePage: FC = () => {
className="vm-link vm-link_colored"
href="https://docs.victoriametrics.com/#query-tracing"
target="_blank"
rel="noreferrer"
rel="help noreferrer"
>
https://docs.victoriametrics.com/#query-tracing
</a>

View file

@ -1,18 +1,22 @@
import { getDefaultServer } from "../../utils/default-server-url";
import { getQueryStringValue } from "../../utils/query-string";
import { getFromStorage, saveToStorage } from "../../utils/storage";
export interface AppState {
serverUrl: string;
tenantId: number;
darkTheme: boolean
}
export type Action =
| { type: "SET_SERVER", payload: string }
| { type: "SET_TENANT_ID", payload: number }
| { type: "SET_DARK_THEME", payload: boolean }
export const initialState: AppState = {
serverUrl: getDefaultServer(),
tenantId: Number(getQueryStringValue("g0.tenantID", 0)),
darkTheme: !!getFromStorage("DARK_THEME")
};
export function reducer(state: AppState, action: Action): AppState {
@ -27,6 +31,12 @@ export function reducer(state: AppState, action: Action): AppState {
...state,
tenantId: action.payload
};
case "SET_DARK_THEME":
saveToStorage("DARK_THEME", action.payload);
return {
...state,
darkTheme: action.payload
};
default:
throw new Error();
}

View file

@ -9,7 +9,7 @@
&:hover,
&_active {
background-color: rgba($color-black, 0.06);
background-color: $color-hover-black;
}
&_multiselect {

View file

@ -6,10 +6,11 @@
gap: $padding-small;
align-items: center;
justify-content: space-between;
background-color: $color-primary;
background-color: $color-background-block;
padding: $padding-small $padding-small $padding-small $padding-global;
border-radius: $border-radius-small $border-radius-small 0 0;
color: $color-white;
color: $color-text;
border-bottom: $border-divider;
&__title {
font-weight: bold;

View file

@ -12,7 +12,7 @@
transition: background-color 200ms ease;
&:hover:not(&_header) {
background-color: rgba($color-black, 0.05);
background-color: $color-hover-black;
}
&_header {
@ -43,7 +43,7 @@
cursor: pointer;
&:hover {
background-color: rgba($color-black, 0.05);
background-color: $color-hover-black;
}
}
@ -54,7 +54,8 @@
}
&_gray {
color: rgba($color-black, 0.4);
color: $color-text;
opacity: 0.4;
}
&_right {

View file

@ -9,6 +9,7 @@ html, body, #root {
background-repeat: no-repeat;
background-attachment: fixed;
margin: 0;
background-color: $color-background-body;
}
body {
@ -54,3 +55,24 @@ input[type=number]::-webkit-outer-spin-button {
svg {
width: 100%;
}
/* Works on Firefox */
* {
scrollbar-width: thin;
scrollbar-color: $color-text-disabled $color-background-block;
}
/* Works on Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: 12px;
}
*::-webkit-scrollbar-track {
background: $color-background-block;
}
*::-webkit-scrollbar-thumb {
background-color: $color-text-disabled;
border-radius: 20px;
border: 3px solid $color-background-block;
}

View file

@ -30,4 +30,17 @@
/* backgrounds */
--color-background-body: #FEFEFF;
--color-background-block: #FFFFFF;
--color-background-tooltip: rgba(97,97,97, 0.92);
/* text */
--color-text: #110f0f;
--color-text-secondary: #706F6F;
--color-text-disabled: #A09F9F;
/* box-shadow */
--box-shadow: rgba(0, 0, 0, 0.08) 1px 2px 6px;
--box-shadow-popper: rgba(0, 0, 0, 0.1) 0px 2px 8px 0px;
--border-divider: 1px solid rgba(0, 0, 0, 0.15);
--color-hover-black: rgba(0, 0, 0, 0.06);
}

View file

@ -13,14 +13,11 @@ $color-warning-text: var(--color-warning-text);
$color-info-text: var(--color-info-text);
$color-success-text: var(--color-success-text);
$color-text: #110f0f;
$color-text-secondary: rgba($color-text, 0.6);
$color-text-disabled: rgba($color-text, 0.4);
$color-text: var(--color-text);
$color-text-secondary: var(--color-text-secondary);
$color-text-disabled: var(--color-text-disabled);
$color-black: #110f0f;
$color-dove-gray: #616161;
$color-silver: #C4C4C4;
$color-alto: #D8D8D8;
$color-white: #ffffff;
$color-dodger-blue: #1A90FF;
@ -30,8 +27,7 @@ $color-tropical-blue: #C9E3F6;
/************* background *************/
$color-background-body: var(--color-background-body);
$color-background-block: var(--color-background-block);
$color-background-modal: rgba($color-black, 0.7);
$color-background-tooltip: rgba($color-dove-gray, 0.92);
$color-background-tooltip: var(--color-background-tooltip);
/************* padding *************/
@ -51,7 +47,7 @@ $font-size-small: 10px;
/************* border *************/
$border-divider: 1px solid rgba($color-black, 0.15);
$border-divider: var(--border-divider);
/************* border-radius *************/
@ -61,6 +57,7 @@ $border-radius-large: 16px;
/************* box-shadows *************/
$box-shadow: 1px 2px 12px rgba($color-black, 0.08);
$box-shadow-bottom: rgba($color-black, 0.04) 0px 3px 5px;
$box-shadow-popper: rgba($color-black, 0.1) 0px 2px 8px 0px;
$box-shadow: var(--box-shadow);
$box-shadow-popper: var(--box-shadow-popper);
$color-hover-black: var(--color-hover-black);

View file

@ -7,6 +7,7 @@ export type StorageKeys = "BASIC_AUTH_DATA"
| "SERIES_LIMITS"
| "TABLE_COMPACT"
| "TIMEZONE"
| "DARK_THEME"
export const saveToStorage = (key: StorageKeys, value: string | boolean | Record<string, unknown>): void => {
if (value) {
@ -15,6 +16,7 @@ export const saveToStorage = (key: StorageKeys, value: string | boolean | Record
} else {
removeFromStorage([key]);
}
window.dispatchEvent(new Event("storage"));
};
// TODO: make this aware of data type that is stored

View file

@ -4,6 +4,7 @@ import { getSecondsFromDuration, roundToMilliseconds } from "../time";
import { AxisRange } from "../../state/graph/reducer";
import { formatTicks, sizeAxis } from "./helpers";
import { TimeParams } from "../../types";
import { getCssVariable } from "../theme";
// see https://github.com/leeoniya/uPlot/tree/master/docs#axis--grid-opts
const timeValues = [
@ -22,10 +23,11 @@ export const getAxes = (series: Series[], unit?: string): Axis[] => Array.from(n
scale: a,
show: true,
size: sizeAxis,
stroke: getCssVariable("color-text"),
font: "10px Arial",
values: (u: uPlot, ticks: number[]) => formatTicks(u, ticks, unit)
};
if (!a) return { space: 80, values: timeValues };
if (!a) return { space: 80, values: timeValues, stroke: getCssVariable("color-text") };
if (!(Number(a) % 2)) return { ...axis, side: 1 };
return axis;
});

View file

@ -1,5 +1,6 @@
/* eslint-disable */
import uPlot from "uplot";
import {getCssVariable} from "../theme";
export const seriesBarsPlugin = (opts) => {
let pxRatio;
@ -88,7 +89,7 @@ export const seriesBarsPlugin = (opts) => {
u.ctx.save();
u.ctx.font = font;
u.ctx.fillStyle = "black";
u.ctx.fillStyle = getCssVariable("color-text");
uPlot.orient(u, sidx, (
series,

View file

@ -15,6 +15,8 @@ The following tip changes can be tested by building VictoriaMetrics components f
## tip
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add dark mode - it can be seleted via `settings` menu in the top right corner. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3704).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): improve visual appearance of the top menu. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3678).
* FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): reduce memory usage when sending stale markers for targets, which expose big number of metrics. See [this](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3668) and [this](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3675) issues.
* FEATURE: add `-internStringMaxLen` command-line flag, which can be used for fine-tuning RAM vs CPU usage in certain workloads. For example, if the stored time series contain long labels, then it may be useful reducing the `-internStringMaxLen` in order to reduce memory usage at the cost of increased CPU usage. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3692).