vmui: improvement the theme (#3731)

* feat: add detect the system theme

* fix: change logic fetch tenants

* feat: add docs and info to cardinality page

---------

Co-authored-by: Aliaksandr Valialkin <valyala@victoriametrics.com>
This commit is contained in:
Yury Molodov 2023-01-31 21:54:59 +01:00 committed by GitHub
parent 080a3e2396
commit dcc5616126
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 427 additions and 201 deletions

View file

@ -8,56 +8,57 @@ import DashboardsLayout from "./pages/PredefinedPanels";
import CardinalityPanel from "./pages/CardinalityPanel"; import CardinalityPanel from "./pages/CardinalityPanel";
import TopQueries from "./pages/TopQueries"; import TopQueries from "./pages/TopQueries";
import ThemeProvider from "./components/Main/ThemeProvider/ThemeProvider"; import ThemeProvider from "./components/Main/ThemeProvider/ThemeProvider";
import Spinner from "./components/Main/Spinner/Spinner";
import TracePage from "./pages/TracePage"; import TracePage from "./pages/TracePage";
import ExploreMetrics from "./pages/ExploreMetrics"; import ExploreMetrics from "./pages/ExploreMetrics";
import PreviewIcons from "./components/Main/Icons/PreviewIcons"; import PreviewIcons from "./components/Main/Icons/PreviewIcons";
const App: FC = () => { const App: FC = () => {
const [loadingTheme, setLoadingTheme] = useState(true); const [loadedTheme, setLoadedTheme] = useState(false);
return <> return <>
{loadingTheme && <Spinner/>} <HashRouter>
<ThemeProvider setLoadingTheme={setLoadingTheme}/>
<HashRouter key={`${loadingTheme}`}>
<AppContextProvider> <AppContextProvider>
<Routes> <>
<Route <ThemeProvider onLoaded={setLoadedTheme}/>
path={"/"} {loadedTheme && (
element={<Layout/>} <Routes>
> <Route
<Route path={"/"}
path={router.home} element={<Layout/>}
element={<CustomPanel/>} >
/> <Route
<Route path={router.home}
path={router.metrics} element={<CustomPanel/>}
element={<ExploreMetrics/>} />
/> <Route
<Route path={router.metrics}
path={router.cardinality} element={<ExploreMetrics/>}
element={<CardinalityPanel/>} />
/> <Route
<Route path={router.cardinality}
path={router.topQueries} element={<CardinalityPanel/>}
element={<TopQueries/>} />
/> <Route
<Route path={router.topQueries}
path={router.trace} element={<TopQueries/>}
element={<TracePage/>} />
/> <Route
<Route path={router.trace}
path={router.dashboards} element={<TracePage/>}
element={<DashboardsLayout/>} />
/> <Route
<Route path={router.dashboards}
path={router.icons} element={<DashboardsLayout/>}
element={<PreviewIcons/>} />
/> <Route
</Route> path={router.icons}
</Routes> element={<PreviewIcons/>}
/>
</Route>
</Routes>
)}
</>
</AppContextProvider> </AppContextProvider>
</HashRouter> </HashRouter>
</>; </>;

View file

@ -9,7 +9,7 @@ const BarChart: FC<BarChartProps> = ({
data, data,
container, container,
configs }) => { configs }) => {
const { darkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const uPlotRef = useRef<HTMLDivElement>(null); const uPlotRef = useRef<HTMLDivElement>(null);
const [uPlotInst, setUPlotInst] = useState<uPlot>(); const [uPlotInst, setUPlotInst] = useState<uPlot>();
@ -30,7 +30,7 @@ const BarChart: FC<BarChartProps> = ({
const u = new uPlot(options, data, uPlotRef.current); const u = new uPlot(options, data, uPlotRef.current);
setUPlotInst(u); setUPlotInst(u);
return u.destroy; return u.destroy;
}, [uPlotRef.current, layoutSize, darkTheme]); }, [uPlotRef.current, layoutSize, isDarkTheme]);
useEffect(() => updateChart(), [data]); useEffect(() => updateChart(), [data]);

View file

@ -48,7 +48,7 @@ const LineChart: FC<LineChartProps> = ({
container, container,
height height
}) => { }) => {
const { darkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const uPlotRef = useRef<HTMLDivElement>(null); const uPlotRef = useRef<HTMLDivElement>(null);
const [isPanning, setPanning] = useState(false); const [isPanning, setPanning] = useState(false);
@ -225,7 +225,7 @@ const LineChart: FC<LineChartProps> = ({
setUPlotInst(u); setUPlotInst(u);
setXRange({ min: period.start, max: period.end }); setXRange({ min: period.start, max: period.end });
return u.destroy; return u.destroy;
}, [uPlotRef.current, series, layoutSize, height, darkTheme]); }, [uPlotRef.current, series, layoutSize, height, isDarkTheme]);
useEffect(() => { useEffect(() => {
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);

View file

@ -3,7 +3,6 @@ import { useAppDispatch, useAppState } from "../../../../state/common/StateConte
import { useTimeDispatch } from "../../../../state/time/TimeStateContext"; import { useTimeDispatch } from "../../../../state/time/TimeStateContext";
import { ArrowDownIcon, StorageIcons } from "../../../Main/Icons"; import { ArrowDownIcon, StorageIcons } from "../../../Main/Icons";
import Button from "../../../Main/Button/Button"; import Button from "../../../Main/Button/Button";
import { useFetchAccountIds } from "./hooks/useFetchAccountIds";
import "./style.scss"; import "./style.scss";
import { replaceTenantId } from "../../../../utils/default-server-url"; import { replaceTenantId } from "../../../../utils/default-server-url";
import classNames from "classnames"; import classNames from "classnames";
@ -11,13 +10,12 @@ import Popper from "../../../Main/Popper/Popper";
import { getAppModeEnable } from "../../../../utils/app-mode"; import { getAppModeEnable } from "../../../../utils/app-mode";
import Tooltip from "../../../Main/Tooltip/Tooltip"; import Tooltip from "../../../Main/Tooltip/Tooltip";
const TenantsConfiguration: FC = () => { const TenantsConfiguration: FC<{accountIds: string[]}> = ({ accountIds }) => {
const appModeEnable = getAppModeEnable(); const appModeEnable = getAppModeEnable();
const { tenantId: tenantIdState, serverUrl } = useAppState(); const { tenantId: tenantIdState, serverUrl } = useAppState();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const timeDispatch = useTimeDispatch(); const timeDispatch = useTimeDispatch();
const { accountIds } = useFetchAccountIds();
const [openOptions, setOpenOptions] = useState(false); const [openOptions, setOpenOptions] = useState(false);
const optionsButtonRef = useRef<HTMLDivElement>(null); const optionsButtonRef = useRef<HTMLDivElement>(null);
@ -46,7 +44,6 @@ const TenantsConfiguration: FC = () => {
if (serverUrl) { if (serverUrl) {
const updateServerUrl = replaceTenantId(serverUrl, tenant); const updateServerUrl = replaceTenantId(serverUrl, tenant);
if (updateServerUrl === serverUrl) return; if (updateServerUrl === serverUrl) return;
console.log("SET_SERVER", updateServerUrl);
dispatch({ type: "SET_SERVER", payload: updateServerUrl }); dispatch({ type: "SET_SERVER", payload: updateServerUrl });
timeDispatch({ type: "RUN_QUERY" }); timeDispatch({ type: "RUN_QUERY" });
} }

View file

@ -1,19 +1,16 @@
import React from "react"; import React from "react";
import "./style.scss"; import "./style.scss";
import classNames from "classnames";
import { useAppDispatch, useAppState } from "../../../state/common/StateContext"; import { useAppDispatch, useAppState } from "../../../state/common/StateContext";
import { Theme } from "../../../types";
import Toggle from "../../Main/Toggle/Toggle";
const options = [ const options = Object.values(Theme).map(value => ({ title: value, value }));
{ title: "Light", value: false },
{ title: "Dark", value: true }
];
const ThemeControl = () => { const ThemeControl = () => {
const { darkTheme } = useAppState(); const { theme } = useAppState();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const createHandlerClickItem = (value: boolean) => () => { const handleClickItem = (value: string) => {
dispatch({ type: "SET_DARK_THEME", payload: value }); dispatch({ type: "SET_THEME", payload: value as Theme });
}; };
return ( return (
@ -21,21 +18,12 @@ const ThemeControl = () => {
<div className="vm-server-configurator__title"> <div className="vm-server-configurator__title">
Theme preferences Theme preferences
</div> </div>
<div className="vm-theme-control-options"> <div className="vm-theme-control__toggle">
<div <Toggle
className="vm-theme-control-options__highlight" options={options}
style={{ left: darkTheme ? "50%" : 0 }} value={theme}
onChange={handleClickItem}
/> />
{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>
</div> </div>
); );

View file

@ -1,44 +1,10 @@
@use "src/styles/variables" as *; @use "src/styles/variables" as *;
.vm-theme-control { .vm-theme-control {
&-options {
position: relative; &__toggle {
display: inline-flex; display: inline-flex;
border: $border-divider; min-width: 300px;
border-radius: $border-radius-medium; text-transform: capitalize;
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

@ -17,7 +17,7 @@ import classNames from "classnames";
import { useAppState } from "../../../../state/common/StateContext"; import { useAppState } from "../../../../state/common/StateContext";
export const TimeSelector: FC = () => { export const TimeSelector: FC = () => {
const { darkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const documentSize = useResize(document.body); const documentSize = useResize(document.body);
const displayFullDate = useMemo(() => documentSize.width > 1280, [documentSize]); const displayFullDate = useMemo(() => documentSize.width > 1280, [documentSize]);
@ -150,7 +150,7 @@ export const TimeSelector: FC = () => {
<div <div
className={classNames({ className={classNames({
"vm-time-selector-left-inputs": true, "vm-time-selector-left-inputs": true,
"vm-time-selector-left-inputs_dark": darkTheme "vm-time-selector-left-inputs_dark": isDarkTheme
})} })}
> >
<div <div

View file

@ -16,15 +16,17 @@ import StepConfigurator from "../../Configurators/StepConfigurator/StepConfigura
import { useAppState } from "../../../state/common/StateContext"; import { useAppState } from "../../../state/common/StateContext";
import HeaderNav from "./HeaderNav/HeaderNav"; import HeaderNav from "./HeaderNav/HeaderNav";
import TenantsConfiguration from "../../Configurators/GlobalSettings/TenantsConfiguration/TenantsConfiguration"; import TenantsConfiguration from "../../Configurators/GlobalSettings/TenantsConfiguration/TenantsConfiguration";
import { useFetchAccountIds } from "../../Configurators/GlobalSettings/TenantsConfiguration/hooks/useFetchAccountIds";
const Header: FC = () => { const Header: FC = () => {
const { darkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const appModeEnable = getAppModeEnable(); const appModeEnable = getAppModeEnable();
const { accountIds } = useFetchAccountIds();
const primaryColor = useMemo(() => { const primaryColor = useMemo(() => {
const variable = darkTheme ? "color-background-block" : "color-primary"; const variable = isDarkTheme ? "color-background-block" : "color-primary";
return getCssVariable(variable); return getCssVariable(variable);
}, [darkTheme]); }, [isDarkTheme]);
const { background, color } = useMemo(() => { const { background, color } = useMemo(() => {
const { headerStyles: { const { headerStyles: {
@ -52,7 +54,7 @@ const Header: FC = () => {
className={classNames({ className={classNames({
"vm-header": true, "vm-header": true,
"vm-header_app": appModeEnable, "vm-header_app": appModeEnable,
"vm-header_dark": darkTheme "vm-header_dark": isDarkTheme
})} })}
style={{ background, color }} style={{ background, color }}
> >
@ -70,7 +72,7 @@ const Header: FC = () => {
background={background} background={background}
/> />
<div className="vm-header__settings"> <div className="vm-header__settings">
{headerSetup?.tenant && <TenantsConfiguration/>} {headerSetup?.tenant && <TenantsConfiguration accountIds={accountIds}/>}
{headerSetup?.stepControl && <StepConfigurator/>} {headerSetup?.stepControl && <StepConfigurator/>}
{headerSetup?.timeSelector && <TimeSelector/>} {headerSetup?.timeSelector && <TimeSelector/>}
{headerSetup?.cardinalityDatePicker && <CardinalityDatePicker/>} {headerSetup?.cardinalityDatePicker && <CardinalityDatePicker/>}

View file

@ -20,14 +20,14 @@ const icons = {
const Alert: FC<AlertProps> = ({ const Alert: FC<AlertProps> = ({
variant, variant,
children }) => { children }) => {
const { darkTheme } = useAppState(); const { isDarkTheme } = useAppState();
return ( return (
<div <div
className={classNames({ className={classNames({
"vm-alert": true, "vm-alert": true,
[`vm-alert_${variant}`]: variant, [`vm-alert_${variant}`]: variant,
"vm-alert_dark": darkTheme "vm-alert_dark": isDarkTheme
})} })}
> >
<div className="vm-alert__icon">{icons[variant || "info"]}</div> <div className="vm-alert__icon">{icons[variant || "info"]}</div>

View file

@ -14,7 +14,7 @@ enum TimeUnits { hour, minutes, seconds }
const TimePicker: FC<CalendarTimepickerProps>= ({ selectDate, onChangeTime, onClose }) => { const TimePicker: FC<CalendarTimepickerProps>= ({ selectDate, onChangeTime, onClose }) => {
const { darkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const [activeField, setActiveField] = useState<TimeUnits>(TimeUnits.hour); const [activeField, setActiveField] = useState<TimeUnits>(TimeUnits.hour);
const [hours, setHours] = useState(selectDate.format("HH")); const [hours, setHours] = useState(selectDate.format("HH"));
@ -159,7 +159,7 @@ const TimePicker: FC<CalendarTimepickerProps>= ({ selectDate, onChangeTime, onCl
<div <div
className={classNames({ className={classNames({
"vm-calendar-time-picker-fields": true, "vm-calendar-time-picker-fields": true,
"vm-calendar-time-picker-fields_dark": darkTheme "vm-calendar-time-picker-fields_dark": isDarkTheme
})} })}
> >
<input <input

View file

@ -27,7 +27,7 @@ const Select: FC<SelectProps> = ({
autofocus, autofocus,
onChange onChange
}) => { }) => {
const { darkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const autocompleteAnchorEl = useRef<HTMLDivElement>(null); const autocompleteAnchorEl = useRef<HTMLDivElement>(null);
@ -111,7 +111,7 @@ const Select: FC<SelectProps> = ({
<div <div
className={classNames({ className={classNames({
"vm-select": true, "vm-select": true,
"vm-select_dark": darkTheme "vm-select_dark": isDarkTheme
})} })}
> >
<div <div

View file

@ -1,27 +1,31 @@
import React, { CSSProperties, FC } from "preact/compat"; import React, { CSSProperties, FC } from "preact/compat";
import "./style.scss"; import "./style.scss";
import classNames from "classnames"; import classNames from "classnames";
import { getFromStorage } from "../../../utils/storage"; import { useAppState } from "../../../state/common/StateContext";
interface SpinnerProps { interface SpinnerProps {
containerStyles?: CSSProperties; containerStyles?: CSSProperties;
message?: string message?: string
} }
const Spinner: FC<SpinnerProps> = ({ containerStyles = {}, message }) => ( const Spinner: FC<SpinnerProps> = ({ containerStyles = {}, message }) => {
<div const { isDarkTheme } = useAppState();
className={classNames({
"vm-spinner": true, return (
"vm-spinner_dark": getFromStorage("DARK_THEME") <div
})} className={classNames({
style={containerStyles && {}} "vm-spinner": true,
> "vm-spinner_dark": isDarkTheme,
<div className="half-circle-spinner"> })}
<div className="circle circle-1"></div> style={containerStyles && {}}
<div className="circle circle-2"></div> >
<div className="half-circle-spinner">
<div className="circle circle-1"></div>
<div className="circle circle-2"></div>
</div>
{message && <div className="vm-spinner__message">{message}</div>}
</div> </div>
{message && <div className="vm-spinner__message">{message}</div>} );
</div> };
);
export default Spinner; export default Spinner;

View file

@ -39,7 +39,7 @@ const TextField: FC<TextFieldProps> = ({
onFocus, onFocus,
onBlur onBlur
}) => { }) => {
const { darkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -83,7 +83,7 @@ const TextField: FC<TextFieldProps> = ({
className={classNames({ className={classNames({
"vm-text-field": true, "vm-text-field": true,
"vm-text-field_textarea": type === "textarea", "vm-text-field_textarea": type === "textarea",
"vm-text-field_dark": darkTheme "vm-text-field_dark": isDarkTheme
})} })}
data-replicated-value={value} data-replicated-value={value}
> >

View file

@ -1,12 +1,15 @@
import { FC, useEffect } from "preact/compat"; import { FC, useEffect, useState } from "preact/compat";
import { getContrastColor } from "../../../utils/color"; import { getContrastColor } from "../../../utils/color";
import { getCssVariable, setCssVariable } from "../../../utils/theme"; import { getCssVariable, isSystemDark, setCssVariable } from "../../../utils/theme";
import { AppParams, getAppModeParams } from "../../../utils/app-mode"; import { AppParams, getAppModeParams } from "../../../utils/app-mode";
import { getFromStorage } from "../../../utils/storage"; import { getFromStorage } from "../../../utils/storage";
import { darkPalette, lightPalette } from "../../../constants/palette"; import { darkPalette, lightPalette } from "../../../constants/palette";
import { Theme } from "../../../types";
import { useAppDispatch, useAppState } from "../../../state/common/StateContext";
import useSystemTheme from "../../../hooks/useSystemTheme";
interface StyleVariablesProps { interface ThemeProviderProps {
setLoadingTheme: (val: boolean) => void onLoaded: (val: boolean) => void
} }
const colorVariables = [ const colorVariables = [
@ -18,9 +21,18 @@ const colorVariables = [
"success", "success",
]; ];
export const ThemeProvider: FC<StyleVariablesProps> = ({ setLoadingTheme }) => { export const ThemeProvider: FC<ThemeProviderProps> = ({ onLoaded }) => {
const { palette = {} } = getAppModeParams(); const { palette: paletteAppMode = {} } = getAppModeParams();
const { theme } = useAppState();
const isDarkTheme = useSystemTheme();
const dispatch = useAppDispatch();
const [palette, setPalette] = useState({
[Theme.dark]: darkPalette,
[Theme.light]: lightPalette,
[Theme.system]: isSystemDark() ? darkPalette : lightPalette
});
const setScrollbarSize = () => { const setScrollbarSize = () => {
const { innerWidth, innerHeight } = window; const { innerWidth, innerHeight } = window;
@ -30,16 +42,21 @@ export const ThemeProvider: FC<StyleVariablesProps> = ({ setLoadingTheme }) => {
}; };
const setContrastText = () => { const setContrastText = () => {
colorVariables.forEach(variable => { colorVariables.forEach((variable, i) => {
const color = getCssVariable(`color-${variable}`); const color = getCssVariable(`color-${variable}`);
const text = getContrastColor(color); const text = getContrastColor(color);
setCssVariable(`${variable}-text`, text); setCssVariable(`${variable}-text`, text);
if (i === colorVariables.length - 1) {
dispatch({ type: "SET_DARK_THEME" });
onLoaded(true);
}
}); });
}; };
const setAppModePalette = () => { const setAppModePalette = () => {
colorVariables.forEach(variable => { colorVariables.forEach(variable => {
const colorFromAppMode = palette[variable as keyof AppParams["palette"]]; const colorFromAppMode = paletteAppMode[variable as keyof AppParams["palette"]];
if (colorFromAppMode) setCssVariable(`color-${variable}`, colorFromAppMode); if (colorFromAppMode) setCssVariable(`color-${variable}`, colorFromAppMode);
}); });
@ -47,25 +64,33 @@ export const ThemeProvider: FC<StyleVariablesProps> = ({ setLoadingTheme }) => {
}; };
const setTheme = () => { const setTheme = () => {
const darkTheme = getFromStorage("DARK_THEME"); const theme = (getFromStorage("THEME") || Theme.system) as Theme;
const palette = darkTheme ? darkPalette : lightPalette; const result = palette[theme];
Object.entries(palette).forEach(([variable, value]) => { Object.entries(result).forEach(([variable, value]) => {
setCssVariable(variable, value); setCssVariable(variable, value);
}); });
setContrastText(); setContrastText();
}; };
const updatePalette = () => {
const newSystemPalette = isSystemDark() ? darkPalette : lightPalette;
if (palette[Theme.system] === newSystemPalette) {
setTheme();
return;
}
setPalette(prev => ({
...prev,
[Theme.system]: newSystemPalette
}));
};
useEffect(() => { useEffect(() => {
setAppModePalette(); setAppModePalette();
setScrollbarSize(); setScrollbarSize();
setTheme(); setTheme();
setLoadingTheme(false); }, [palette]);
window.addEventListener("storage", setTheme); useEffect(updatePalette, [theme, isDarkTheme]);
return () => {
window.removeEventListener("storage", setTheme);
};
}, []);
return null; return null;
}; };

View file

@ -0,0 +1,94 @@
import React, { FC, useEffect, useRef, useState } from "preact/compat";
import classNames from "classnames";
import { ReactNode } from "react";
import "./style.scss";
interface ToggleProps {
options: {value: string, title?: string, icon?: ReactNode}[]
value: string
onChange: (val: string) => void
label?: string
}
const Toggle: FC<ToggleProps> = ({ options, value, label, onChange }) => {
const activeRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({
width: "0px",
left: "0px",
borderRadius: "0px"
});
const createHandlerChange = (value: string) => () => {
onChange(value);
};
useEffect(() => {
if (!activeRef.current) {
setPosition({
width: "0px",
left: "0px",
borderRadius: "0px"
});
return;
}
const index = options.findIndex(o => o.value === value);
const { width: widthRect } = activeRef.current.getBoundingClientRect();
let width = widthRect;
let left = index * width;
let borderRadius = "0";
if (index === 0) borderRadius = "16px 0 0 16px";
if (index === options.length - 1) {
borderRadius = "10px";
left -= 1;
borderRadius = "0 16px 16px 0";
}
if (index !== 0 && (index !== options.length - 1)) {
width += 1;
left -= 1;
}
setPosition({ width: `${width}px`, left: `${left}px`, borderRadius });
}, [activeRef, value, options]);
return (
<div className="vm-toggles">
{label && (
<label className="vm-toggles__label">
{label}
</label>
)}
<div
className="vm-toggles-group"
style={{ gridTemplateColumns: `repeat(${options.length}, 1fr)` }}
>
{position.borderRadius && <div
className="vm-toggles-group__highlight"
style={position}
/>}
{options.map((option, i) => (
<div
className={classNames({
"vm-toggles-group-item": true,
"vm-toggles-group-item_first": i === 0,
"vm-toggles-group-item_active": option.value === value,
"vm-toggles-group-item_icon": option.icon && option.title
})}
onClick={createHandlerChange(option.value)}
key={option.value}
ref={option.value === value ? activeRef : null}
>
{option.icon}
{option.title}
</div>
))}
</div>
</div>
);
};
export default Toggle;

View file

@ -0,0 +1,81 @@
@use "src/styles/variables" as *;
.vm-toggles {
position: relative;
display: grid;
gap: 3px;
width: 100%;
&__label {
padding: 0 $padding-global;
color: $color-text-secondary;
font-size: $font-size-small;
line-height: 1;
}
&-group {
position: relative;
display: grid;
width: 100%;
align-items: center;
justify-content: center;
overflow: hidden;
&-item {
position: relative;
display: grid;
align-items: center;
justify-content: center;
padding: $padding-small;
border-right: $border-divider;
border-top: $border-divider;
border-bottom: $border-divider;
font-size: $font-size-small;
color: $color-text-secondary;
font-weight: bold;
cursor: pointer;
text-align: center;
transition: color 150ms ease-in;
z-index: 2;
user-select: none;
&_first {
border-radius: 16px 0 0 16px;
border-left: $border-divider
}
&:last-child {
border-radius: 0 16px 16px 0;
border-left: none;
}
&_icon {
grid-template-columns: 14px auto;
gap: 4px;
}
&:hover {
color: $color-primary;
}
&_active {
color: $color-primary;
border-color: transparent;
&:hover {
background-color: transparent;
}
}
}
&__highlight {
position: absolute;
top: 0;
height: 100%;
background-color: rgba($color-primary, 0.08);
border: 1px solid $color-primary;
transition: left 200ms cubic-bezier(0.280, 0.840, 0.420, 1), border-radius 200ms linear;
z-index: 1;
}
}
}

View file

@ -16,7 +16,7 @@ interface OpenLevels {
} }
const NestedNav: FC<RecursiveProps> = ({ trace, totalMsec }) => { const NestedNav: FC<RecursiveProps> = ({ trace, totalMsec }) => {
const { darkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const [openLevels, setOpenLevels] = useState({} as OpenLevels); const [openLevels, setOpenLevels] = useState({} as OpenLevels);
const handleListClick = (level: number) => () => { const handleListClick = (level: number) => () => {
@ -31,7 +31,7 @@ const NestedNav: FC<RecursiveProps> = ({ trace, totalMsec }) => {
<div <div
className={classNames({ className={classNames({
"vm-nested-nav": true, "vm-nested-nav": true,
"vm-nested-nav_dark": darkTheme, "vm-nested-nav_dark": isDarkTheme,
})} })}
> >
<div <div

View file

@ -7,7 +7,7 @@ export const darkPalette = {
"color-success": "#57ab5a", "color-success": "#57ab5a",
"color-background-body": "#22272e", "color-background-body": "#22272e",
"color-background-block": "#2d333b", "color-background-block": "#2d333b",
"color-background-tooltip": "rgba(22, 22, 22, 0.6)", "color-background-tooltip": "rgba(22, 22, 22, 0.8)",
"color-text": "#cdd9e5", "color-text": "#cdd9e5",
"color-text-secondary": "#768390", "color-text-secondary": "#768390",
"color-text-disabled": "#636e7b", "color-text-disabled": "#636e7b",

View file

@ -0,0 +1,20 @@
import { useEffect, useState } from "preact/compat";
import { isSystemDark } from "../utils/theme";
const useThemeDetector = () => {
const [isDarkTheme, setIsDarkTheme] = useState(isSystemDark());
const mqListener = ((e: MediaQueryListEvent) => {
setIsDarkTheme(e.matches);
});
useEffect(() => {
const darkThemeMq = window.matchMedia("(prefers-color-scheme: dark)");
darkThemeMq.addEventListener("change", mqListener);
return () => darkThemeMq.removeEventListener("change", mqListener);
}, []);
return isDarkTheme;
};
export default useThemeDetector;

View file

@ -1,14 +1,13 @@
import React, { FC } from "react"; import React, { FC, useMemo } from "react";
import QueryEditor from "../../../components/Configurators/QueryEditor/QueryEditor"; import QueryEditor from "../../../components/Configurators/QueryEditor/QueryEditor";
import { useFetchQueryOptions } from "../../../hooks/useFetchQueryOptions"; import { useFetchQueryOptions } from "../../../hooks/useFetchQueryOptions";
import { ErrorTypes } from "../../../types"; import { ErrorTypes } from "../../../types";
import { useQueryDispatch, useQueryState } from "../../../state/query/QueryStateContext"; import { useQueryDispatch, useQueryState } from "../../../state/query/QueryStateContext";
import Switch from "../../../components/Main/Switch/Switch"; import Switch from "../../../components/Main/Switch/Switch";
import { PlayIcon, QuestionIcon } from "../../../components/Main/Icons"; import { InfoIcon, PlayIcon, QuestionIcon, WikiIcon } from "../../../components/Main/Icons";
import Button from "../../../components/Main/Button/Button"; import Button from "../../../components/Main/Button/Button";
import TextField from "../../../components/Main/TextField/TextField"; import TextField from "../../../components/Main/TextField/TextField";
import "./style.scss"; import "./style.scss";
import { useMemo } from "preact/compat";
import Tooltip from "../../../components/Main/Tooltip/Tooltip"; import Tooltip from "../../../components/Main/Tooltip/Tooltip";
export interface CardinalityConfiguratorProps { export interface CardinalityConfiguratorProps {
@ -65,7 +64,7 @@ const CardinalityConfigurator: FC<CardinalityConfiguratorProps> = ({
<div className="vm-cardinality-configurator-controls"> <div className="vm-cardinality-configurator-controls">
<div className="vm-cardinality-configurator-controls__query"> <div className="vm-cardinality-configurator-controls__query">
<QueryEditor <QueryEditor
value={query || match || ""} value={query}
autocomplete={autocomplete} autocomplete={autocomplete}
options={queryOptions} options={queryOptions}
error={error} error={error}
@ -91,10 +90,22 @@ const CardinalityConfigurator: FC<CardinalityConfiguratorProps> = ({
type="text" type="text"
value={focusLabel || ""} value={focusLabel || ""}
onChange={onFocusLabelChange} onChange={onFocusLabelChange}
endIcon={(
<Tooltip
title={(
<div>
<p>To identify values with the highest number of series for the selected label.</p>
<p>Adds a table showing the series with the highest number of series.</p>
</div>
)}
>
<InfoIcon/>
</Tooltip>
)}
/> />
</div> </div>
</div> </div>
<div className="vm-cardinality-configurator-bottom__autocomplete"> <div className="vm-cardinality-configurator-additional">
<Switch <Switch
label={"Autocomplete"} label={"Autocomplete"}
value={autocomplete} value={autocomplete}
@ -108,17 +119,22 @@ const CardinalityConfigurator: FC<CardinalityConfiguratorProps> = ({
Show top {topN} entries per table. Show top {topN} entries per table.
</div> </div>
<a <a
className="vm-cardinality-configurator-bottom__docs" className="vm-link vm-link_with-icon"
href="https://victoriametrics.com/blog/cardinality-explorer/"
target="_blank" target="_blank"
href="https://docs.victoriametrics.com/#cardinality-explorer"
rel="help noreferrer" rel="help noreferrer"
> >
<Tooltip title="Example of using"> <WikiIcon/>
<Button Documentation
variant="text" </a>
startIcon={<QuestionIcon/>} <a
/> className="vm-link vm-link_with-icon"
</Tooltip> target="_blank"
href="https://victoriametrics.com/blog/cardinality-explorer/"
rel="help noreferrer"
>
<QuestionIcon/>
Example of using
</a> </a>
<Button <Button
startIcon={<PlayIcon/>} startIcon={<PlayIcon/>}

View file

@ -16,20 +16,23 @@
} }
} }
&-additional {
display: flex;
align-items: center;
}
&-bottom { &-bottom {
display: grid; display: flex;
grid-template-columns: 1fr auto auto; align-items: center;
align-items: flex-end; gap: $padding-global;
gap: $padding-small;
&__info { &__info {
flex-grow: 1;
font-size: $font-size; font-size: $font-size;
} }
&__docs { a {
display: flex; color: $color-text-secondary;
align-items: center;
justify-content: center;
} }
} }
} }

View file

@ -31,10 +31,6 @@ const Index: FC = () => {
cardinalityDispatch({ type: "RUN_QUERY" }); cardinalityDispatch({ type: "RUN_QUERY" });
}; };
const onSetQuery = (query: string) => {
setQuery(query);
};
const onSetHistory = (step: number) => { const onSetHistory = (step: number) => {
const newIndexHistory = queryHistoryIndex + step; const newIndexHistory = queryHistoryIndex + step;
if (newIndexHistory < 0 || newIndexHistory >= queryHistory.length) return; if (newIndexHistory < 0 || newIndexHistory >= queryHistory.length) return;
@ -86,7 +82,7 @@ const Index: FC = () => {
totalLabelValuePairs={tsdbStatusData.totalLabelValuePairs} totalLabelValuePairs={tsdbStatusData.totalLabelValuePairs}
focusLabel={focusLabel} focusLabel={focusLabel}
onRunQuery={onRunQuery} onRunQuery={onRunQuery}
onSetQuery={onSetQuery} onSetQuery={setQuery}
onSetHistory={onSetHistory} onSetHistory={onSetHistory}
onTopNChange={onTopNChange} onTopNChange={onTopNChange}
onFocusLabelChange={onFocusLabelChange} onFocusLabelChange={onFocusLabelChange}

View file

@ -8,16 +8,18 @@ const router = {
icons: "/icons" icons: "/icons"
}; };
export interface RouterOptionsHeader {
tenant?: boolean,
stepControl?: boolean,
timeSelector?: boolean,
executionControls?: boolean,
globalSettings?: boolean,
cardinalityDatePicker?: boolean
}
export interface RouterOptions { export interface RouterOptions {
title?: string, title?: string,
header: { header: RouterOptionsHeader
tenant?: boolean,
stepControl?: boolean,
timeSelector?: boolean,
executionControls?: boolean,
globalSettings?: boolean,
cardinalityDatePicker?: boolean
}
} }
const routerOptionsDefault = { const routerOptionsDefault = {

View file

@ -1,24 +1,29 @@
import { getDefaultServer } from "../../utils/default-server-url"; import { getDefaultServer } from "../../utils/default-server-url";
import { getQueryStringValue } from "../../utils/query-string"; import { getQueryStringValue } from "../../utils/query-string";
import { getFromStorage, saveToStorage } from "../../utils/storage"; import { getFromStorage, saveToStorage } from "../../utils/storage";
import { Theme } from "../../types";
import { isDarkTheme } from "../../utils/theme";
export interface AppState { export interface AppState {
serverUrl: string; serverUrl: string;
tenantId: string; tenantId: string;
darkTheme: boolean theme: Theme;
isDarkTheme: boolean | null;
} }
export type Action = export type Action =
| { type: "SET_SERVER", payload: string } | { type: "SET_SERVER", payload: string }
| { type: "SET_DARK_THEME", payload: boolean } | { type: "SET_THEME", payload: Theme }
| { type: "SET_TENANT_ID", payload: string } | { type: "SET_TENANT_ID", payload: string }
| { type: "SET_DARK_THEME" }
const tenantId = getQueryStringValue("g0.tenantID", "") as string; const tenantId = getQueryStringValue("g0.tenantID", "") as string;
export const initialState: AppState = { export const initialState: AppState = {
serverUrl: getDefaultServer(tenantId), serverUrl: getDefaultServer(tenantId),
tenantId, tenantId,
darkTheme: !!getFromStorage("DARK_THEME") theme: (getFromStorage("THEME") || Theme.system) as Theme,
isDarkTheme: null
}; };
export function reducer(state: AppState, action: Action): AppState { export function reducer(state: AppState, action: Action): AppState {
@ -33,11 +38,16 @@ export function reducer(state: AppState, action: Action): AppState {
...state, ...state,
tenantId: action.payload tenantId: action.payload
}; };
case "SET_DARK_THEME": case "SET_THEME":
saveToStorage("DARK_THEME", action.payload); saveToStorage("THEME", action.payload);
return { return {
...state, ...state,
darkTheme: action.payload theme: action.payload,
};
case "SET_DARK_THEME":
return {
...state,
isDarkTheme: isDarkTheme(state.theme)
}; };
default: default:
throw new Error(); throw new Error();

View file

@ -8,6 +8,14 @@
color: $color-primary; color: $color-primary;
} }
&_with-icon {
display: grid;
align-items: center;
justify-content: center;
gap: 6px;
grid-template-columns: 14px auto;
}
&:hover { &:hover {
color: $color-primary; color: $color-primary;
text-decoration: underline; text-decoration: underline;

View file

@ -119,3 +119,9 @@ export interface GraphSize {
isDefault?: boolean, isDefault?: boolean,
height: () => number height: () => number
} }
export enum Theme {
system = "system",
light = "light",
dark = "dark",
}

View file

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

View file

@ -1,3 +1,5 @@
import { Theme } from "../types";
export const getCssVariable = (variable: string) => { export const getCssVariable = (variable: string) => {
return getComputedStyle(document.documentElement).getPropertyValue(`--${variable}`); return getComputedStyle(document.documentElement).getPropertyValue(`--${variable}`);
}; };
@ -5,3 +7,8 @@ export const getCssVariable = (variable: string) => {
export const setCssVariable = (variable: string, value: string) => { export const setCssVariable = (variable: string, value: string) => {
document.documentElement.style.setProperty(`--${variable}`, value); document.documentElement.style.setProperty(`--${variable}`, value);
}; };
export const isSystemDark = () => window.matchMedia("(prefers-color-scheme: dark)").matches;
export const isDarkTheme = (theme: Theme) => (theme === Theme.system && isSystemDark()) || theme === Theme.dark;