vmui: add retention and downsampling filters debug pages (#7238)

### Describe Your Changes

- add VMUI pages for filters debug
- add `config.json` file to the root of the application. The file
structure is as follows:
  ```
  {
    "license": {
      "type": "enterprise" or "opensource"
    }
  }
  ```
- refactor navigation configuration files. This refactor enables more
flexible customization of menu elements.

UI:

<details>
<summary>Renention filters debug</summary>

Empty page:

![1723474670](https://github.com/user-attachments/assets/3824bf64-dd22-410a-beb5-9599b8769acd)

Results:

![1723474597](https://github.com/user-attachments/assets/1bc074ba-b6a7-4127-8638-65cb32e04db8)

Example config:

![1723541836](https://github.com/user-attachments/assets/ccdb7f75-4e77-42c4-98be-4bfa7809a3b0)

</details>

<details>
<summary>Downsampling filters debug</summary>

Empty page:

![1723474663](https://github.com/user-attachments/assets/7bbd07bd-adce-440f-ba43-f4218e237280)

Results:

![1723474589](https://github.com/user-attachments/assets/b793ae08-b685-427d-81f1-1c7c532a244a)

Example config:

![1723541828](https://github.com/user-attachments/assets/d2ee4e37-8945-4c0f-a4ca-cff5fe3cfcd2)

</details>

### Checklist

The following checks are **mandatory**:

- [ ] My change adheres [VictoriaMetrics contributing
guidelines](https://docs.victoriametrics.com/contributing/).
This commit is contained in:
Yury Molodov 2024-10-15 14:52:00 +02:00 committed by GitHub
parent 6c9772b101
commit 0ff17c3ec4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 733 additions and 121 deletions

View file

@ -0,0 +1,5 @@
{
"license": {
"type": "opensource"
}
}

View file

@ -15,6 +15,8 @@ import WithTemplate from "./pages/WithTemplate";
import Relabel from "./pages/Relabel";
import ActiveQueries from "./pages/ActiveQueries";
import QueryAnalyzer from "./pages/QueryAnalyzer";
import DownsamplingFilters from "./pages/DownsamplingFilters";
import RetentionFilters from "./pages/RetentionFilters";
const App: FC = () => {
const [loadedTheme, setLoadedTheme] = useState(false);
@ -74,6 +76,14 @@ const App: FC = () => {
path={router.icons}
element={<PreviewIcons/>}
/>
<Route
path={router.downsamplingDebug}
element={<DownsamplingFilters/>}
/>
<Route
path={router.retentionDebug}
element={<RetentionFilters/>}
/>
</Route>
</Routes>
)}

View file

@ -0,0 +1,7 @@
export const getDownsamplingFiltersDebug = (server: string, flags: string, metrics: string): string => {
const params = [
`flags=${encodeURIComponent(flags)}`,
`metrics=${encodeURIComponent(metrics)}`
];
return `${server}/downsampling-filters-debug?${params.join("&")}`;
};

View file

@ -0,0 +1,7 @@
export const getRetentionFiltersDebug = (server: string, flags: string, metrics: string): string => {
const params = [
`flags=${encodeURIComponent(flags)}`,
`metrics=${encodeURIComponent(metrics)}`
];
return `${server}/retention-filters-debug?${params.join("&")}`;
};

View file

@ -1,81 +0,0 @@
import router, { routerOptions } from "../router";
export enum NavigationItemType {
internalLink,
externalLink,
}
export interface NavigationItem {
label?: string,
value?: string,
hide?: boolean
submenu?: NavigationItem[],
type?: NavigationItemType,
}
const explore = {
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.activeQueries].title,
value: router.activeQueries,
},
]
};
const tools = {
label: "Tools",
submenu: [
{
label: routerOptions[router.trace].title,
value: router.trace,
},
{
label: routerOptions[router.queryAnalyzer].title,
value: router.queryAnalyzer,
},
{
label: routerOptions[router.withTemplate].title,
value: router.withTemplate,
},
{
label: routerOptions[router.relabel].title,
value: router.relabel,
},
]
};
export const logsNavigation: NavigationItem[] = [
{
label: routerOptions[router.logs].title,
value: router.home,
},
];
export const anomalyNavigation: NavigationItem[] = [
{
label: routerOptions[router.anomaly].title,
value: router.home,
}
];
export const defaultNavigation: NavigationItem[] = [
{
label: routerOptions[router.home].title,
value: router.home,
},
explore,
tools,
];

View file

@ -0,0 +1,34 @@
import { useAppDispatch } from "../state/common/StateContext";
import { useEffect, useState } from "preact/compat";
import { ErrorTypes } from "../types";
const useFetchFlags = () => {
const dispatch = useAppDispatch();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ErrorTypes | string>("");
useEffect(() => {
const fetchAppConfig = async () => {
if (process.env.REACT_APP_TYPE) return;
setError("");
setIsLoading(true);
try {
const data = await fetch("./config.json");
const config = await data.json();
dispatch({ type: "SET_APP_CONFIG", payload: config || {} });
} catch (e) {
setIsLoading(false);
if (e instanceof Error) setError(`${e.name}: ${e.message}`);
}
};
fetchAppConfig();
}, []);
return { isLoading, error };
};
export default useFetchFlags;

View file

@ -1,16 +1,12 @@
import React, { FC, useMemo, useState } from "preact/compat";
import router, { routerOptions } from "../../../router";
import { getAppModeEnable } from "../../../utils/app-mode";
import React, { FC, useState } from "preact/compat";
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";
import classNames from "classnames";
import { anomalyNavigation, defaultNavigation, logsNavigation, NavigationItemType } from "../../../constants/navigation";
import { AppType } from "../../../types/appType";
import { useAppState } from "../../../state/common/StateContext";
import useNavigationMenu from "../../../router/useNavigationMenu";
import { NavigationItemType } from "../../../router/navigation";
interface HeaderNavProps {
color: string
@ -19,43 +15,14 @@ interface HeaderNavProps {
}
const HeaderNav: FC<HeaderNavProps> = ({ color, background, direction }) => {
const appModeEnable = getAppModeEnable();
const { dashboardsSettings } = useDashboardsState();
const { pathname } = useLocation();
const { serverUrl, flags } = useAppState();
const [activeMenu, setActiveMenu] = useState(pathname);
const menu = useMemo(() => {
switch (process.env.REACT_APP_TYPE) {
case AppType.logs:
return logsNavigation;
case AppType.anomaly:
return anomalyNavigation;
default:
return ([
...defaultNavigation,
{
label: routerOptions[router.dashboards].title,
value: router.dashboards,
hide: appModeEnable || !dashboardsSettings.length,
},
{
// see more https://docs.victoriametrics.com/cluster-victoriametrics/?highlight=vmalertproxyurl#vmalert
label: "Alerts",
value: `${serverUrl}/vmalert`,
type: NavigationItemType.externalLink,
hide: !Object.keys(flags).includes("vmalert.proxyURL"),
},
].filter(r => !r.hide));
}
}, [appModeEnable, dashboardsSettings]);
const menu = useNavigationMenu();
useEffect(() => {
setActiveMenu(pathname);
}, [pathname]);
return (
<nav
className={classNames({

View file

@ -1,7 +1,7 @@
import React, { FC } from "preact/compat";
import { NavLink } from "react-router-dom";
import classNames from "classnames";
import { NavigationItemType } from "../../../constants/navigation";
import { NavigationItemType } from "../../../router/navigation";
interface NavItemProps {
activeMenu: string,

View file

@ -6,7 +6,7 @@ import Popper from "../../../components/Main/Popper/Popper";
import NavItem from "./NavItem";
import { useEffect } from "react";
import useBoolean from "../../../hooks/useBoolean";
import { NavigationItem, NavigationItemType } from "../../../constants/navigation";
import { NavigationItem, NavigationItemType } from "../../../router/navigation";
interface NavItemProps {
activeMenu: string,

View file

@ -12,6 +12,7 @@ import useDeviceDetect from "../../hooks/useDeviceDetect";
import ControlsMainLayout from "./ControlsMainLayout";
import useFetchDefaultTimezone from "../../hooks/useFetchDefaultTimezone";
import useFetchFlags from "../../hooks/useFetchFlags";
import useFetchAppConfig from "../../hooks/useFetchAppConfig";
const MainLayout: FC = () => {
const appModeEnable = getAppModeEnable();
@ -21,6 +22,7 @@ const MainLayout: FC = () => {
useFetchDashboards();
useFetchDefaultTimezone();
useFetchAppConfig();
useFetchFlags();
const setDocumentTitle = () => {

View file

@ -0,0 +1,53 @@
import { useAppState } from "../../../state/common/StateContext";
import { useState } from "react";
import { ErrorTypes } from "../../../types";
import { useSearchParams } from "react-router-dom";
import { getDownsamplingFiltersDebug } from "../../../api/downsampling-filters-debug";
import { useCallback } from "preact/compat";
export const useDebugDownsamplingFilters = () => {
const { serverUrl } = useAppState();
const [searchParams, setSearchParams] = useSearchParams();
const [data, setData] = useState<Map<string, string[]>>(new Map());
const [loading, setLoading] = useState(false);
const [metricsError, setMetricsError] = useState<ErrorTypes | string>();
const [flagsError, setFlagsError] = useState<ErrorTypes | string>();
const [error, setError] = useState<ErrorTypes | string>();
const fetchData = useCallback(async (flags: string, metrics: string) => {
metrics ? setMetricsError("") : setMetricsError("metrics are required");
flags ? setFlagsError("") : setFlagsError("flags are required");
if (!metrics || !flags) return;
searchParams.set("flags", flags);
searchParams.set("metrics", metrics);
setSearchParams(searchParams);
const fetchUrl = getDownsamplingFiltersDebug(serverUrl, flags, metrics);
setLoading(true);
try {
const response = await fetch(fetchUrl);
const resp = await response.json();
setData(new Map(Object.entries(resp.result || {})));
setMetricsError(resp.error?.metrics || "");
setFlagsError(resp.error?.flags || "");
setError("");
} catch (e) {
if (e instanceof Error && e.name !== "AbortError") {
setError(`${e.name}: ${e.message}`);
}
}
setLoading(false);
}, [serverUrl]);
return {
data,
error: error,
metricsError: metricsError,
flagsError: flagsError,
loading,
applyFilters: fetchData
};
};

View file

@ -0,0 +1,137 @@
import React, { FC, useEffect } from "preact/compat";
import "./style.scss";
import TextField from "../../components/Main/TextField/TextField";
import { useCallback, useState } from "react";
import Button from "../../components/Main/Button/Button";
import { PlayIcon, WikiIcon } from "../../components/Main/Icons";
import { useDebugDownsamplingFilters } from "./hooks/useDebugDownsamplingFilters";
import Spinner from "../../components/Main/Spinner/Spinner";
import { useSearchParams } from "react-router-dom";
const example = {
flags: `-downsampling.period={env="dev"}:7d:5m,{env="dev"}:30d:30m
-downsampling.period=30d:1m
-downsampling.period=60d:5m
`,
metrics: `up
up{env="dev"}
up{env="prod"}`,
};
const DownsamplingFilters: FC = () => {
const [searchParams] = useSearchParams();
const { data, loading, error, metricsError, flagsError, applyFilters } = useDebugDownsamplingFilters();
const [metrics, setMetrics] = useState(searchParams.get("metrics") || "");
const [flags, setFlags] = useState(searchParams.get("flags") || "");
const handleMetricsChangeInput = useCallback((val: string) => {
setMetrics(val);
}, [setMetrics]);
const handleFlagsChangeInput = useCallback((val: string) => {
setFlags(val);
}, [setFlags]);
const handleApplyFilters = useCallback(() => {
applyFilters(flags, metrics);
}, [applyFilters, flags, metrics]);
const handleRunExample = useCallback(() => {
const { flags, metrics } = example;
setFlags(flags);
setMetrics(metrics);
applyFilters(flags, metrics);
searchParams.set("flags", flags);
searchParams.set("metrics", metrics);
}, [example, setFlags, setMetrics, searchParams]);
useEffect(() => {
if (flags && metrics) handleApplyFilters();
}, []);
const rows = [];
for (const [key, value] of data) {
rows.push(<tr className="vm-table__row">
<td className="vm-table-cell">{key}</td>
<td className="vm-table-cell">{value.join(" ")}</td>
</tr>);
}
return (
<section className="vm-downsampling-filters">
{loading && <Spinner/>}
<div className="vm-downsampling-filters-body vm-block">
<div className="vm-downsampling-filters-body__expr">
<div className="vm-retention-filters-body__title">
<p>Provide a list of flags for downsampling configuration. Note that
only <code>-downsampling.period</code> and <code>-dedup.minScrapeInterval</code> flags are supported</p>
</div>
<TextField
type="textarea"
label="Flags"
value={flags}
error={error || flagsError}
autofocus
onEnter={handleApplyFilters}
onChange={handleFlagsChangeInput}
placeholder={"-downsampling.period=30d:1m -downsampling.period=7d:5m -dedup.minScrapeInterval=30s"}
/>
</div>
<div className="vm-downsampling-filters-body__expr">
<div className="vm-retention-filters-body__title">
<p>Provide a list of metrics to check downsampling configuration.</p>
</div>
<TextField
type="textarea"
label="Metrics"
value={metrics}
error={error || metricsError}
onEnter={handleApplyFilters}
onChange={handleMetricsChangeInput}
placeholder={"up{env=\"dev\"}\nup{env=\"prod\"}\n"}
/>
</div>
<div className="vm-downsampling-filters-body__result">
<table className="vm-table">
<thead className="vm-table-header">
<tr>
<th className="vm-table-cell vm-table-cell_header">Metric</th>
<th className="vm-table-cell vm-table-cell_header">Applied downsampling rules</th>
</tr>
</thead>
<tbody className="vm-table-body">
{rows}
</tbody>
</table>
</div>
<div className="vm-downsampling-filters-body-top">
<a
className="vm-link vm-link_with-icon"
target="_blank"
href="https://docs.victoriametrics.com/#downsampling"
rel="help noreferrer"
>
<WikiIcon/>
Documentation
</a>
<Button
variant="text"
onClick={handleRunExample}
>
Try example
</Button>
<Button
variant="contained"
onClick={handleApplyFilters}
startIcon={<PlayIcon/>}
>
Apply
</Button>
</div>
</div>
</section>
);
};
export default DownsamplingFilters;

View file

@ -0,0 +1,46 @@
@use "src/styles/variables" as *;
.vm-downsampling-filters {
display: grid;
gap: $padding-medium;
&-body {
display: grid;
gap: $padding-global;
align-items: flex-start;
width: 100%;
&__title {
margin-bottom: $padding-medium;
}
&-top {
display: flex;
gap: $padding-small;
align-items: center;
justify-content: flex-end;
}
&__expr textarea {
min-height: 200px;
}
&__result textarea {
min-height: 60px;
}
code {
background-color: var(--color-hover-black);
border-radius: 6px;
font-size: 85%;
padding: .2em .4em;
}
textarea {
font-family: $font-family-monospace;
overflow: auto;
width: 100%;
height: 100%;
}
}
}

View file

@ -0,0 +1,53 @@
import { useAppState } from "../../../state/common/StateContext";
import { useState } from "react";
import { ErrorTypes } from "../../../types";
import { useSearchParams } from "react-router-dom";
import { getRetentionFiltersDebug } from "../../../api/retention-filters-debug";
import { useCallback } from "preact/compat";
export const useDebugRetentionFilters = () => {
const { serverUrl } = useAppState();
const [searchParams, setSearchParams] = useSearchParams();
const [data, setData] = useState<Map<string, string>>(new Map());
const [loading, setLoading] = useState(false);
const [metricsError, setMetricsError] = useState<ErrorTypes | string>();
const [flagsError, setFlagsError] = useState<ErrorTypes | string>();
const [error, setError] = useState<ErrorTypes | string>();
const fetchData = useCallback(async (flags: string, metrics: string) => {
metrics ? setMetricsError("") : setMetricsError("metrics are required");
flags ? setFlagsError("") : setFlagsError("flags are required");
if (!metrics || !flags) return;
searchParams.set("flags", flags);
searchParams.set("metrics", metrics);
setSearchParams(searchParams);
const fetchUrl = getRetentionFiltersDebug(serverUrl, flags, metrics);
setLoading(true);
try {
const response = await fetch(fetchUrl);
const resp = await response.json();
setData(new Map(Object.entries(resp.result || {})));
setMetricsError(resp.error?.metrics || "");
setFlagsError(resp.error?.flags || "");
setError("");
} catch (e) {
if (e instanceof Error && e.name !== "AbortError") {
setError(`${e.name}: ${e.message}`);
}
}
setLoading(false);
}, [serverUrl]);
return {
data,
error: error,
metricsError: metricsError,
flagsError: flagsError,
loading,
applyFilters: fetchData
};
};

View file

@ -0,0 +1,137 @@
import React, { FC, useEffect } from "preact/compat";
import "./style.scss";
import TextField from "../../components/Main/TextField/TextField";
import { useCallback, useState } from "react";
import Button from "../../components/Main/Button/Button";
import { PlayIcon, WikiIcon } from "../../components/Main/Icons";
import { useDebugRetentionFilters } from "./hooks/useDebugRetentionFilters";
import Spinner from "../../components/Main/Spinner/Spinner";
import { useSearchParams } from "react-router-dom";
const example = {
flags: `-retentionPeriod=1y
-retentionFilters={env!="prod"}:2w
`,
metrics: `up
up{env="dev"}
up{env="prod"}`,
};
const RetentionFilters: FC = () => {
const [searchParams] = useSearchParams();
const { data, loading, error, metricsError, flagsError, applyFilters } = useDebugRetentionFilters();
const [metrics, setMetrics] = useState(searchParams.get("metrics") || "");
const [flags, setFlags] = useState(searchParams.get("flags") || "");
const handleMetricsChangeInput = useCallback((val: string) => {
setMetrics(val);
}, [setMetrics]);
const handleFlagsChangeInput = useCallback((val: string) => {
setFlags(val);
}, [setFlags]);
const handleApplyFilters = useCallback(() => {
applyFilters(flags, metrics);
}, [applyFilters, flags, metrics]);
const handleRunExample = useCallback(() => {
const { flags, metrics } = example;
setFlags(flags);
setMetrics(metrics);
applyFilters(flags, metrics);
searchParams.set("flags", flags);
searchParams.set("metrics", metrics);
}, [example, setFlags, setMetrics, searchParams]);
useEffect(() => {
if (flags && metrics) handleApplyFilters();
}, []);
const rows = [];
for (const [key, value] of data) {
rows.push(<tr className="vm-table__row">
<td className="vm-table-cell">{key}</td>
<td className="vm-table-cell">{value}</td>
</tr>);
}
return (
<section className="vm-retention-filters">
{loading && <Spinner/>}
<div className="vm-retention-filters-body vm-block">
<div className="vm-retention-filters-body__expr">
<div className="vm-retention-filters-body__title">
<p>Provide a list of flags for retention configuration. Note that
only <code>-retentionPeriod</code> and <code>-retentionFilters</code> flags are
supported.</p>
</div>
<TextField
type="textarea"
label="Flags"
value={flags}
error={error || flagsError}
autofocus
onEnter={handleApplyFilters}
onChange={handleFlagsChangeInput}
placeholder={"-retentionPeriod=4w -retentionFilters=up{env=\"dev\"}:2w"}
/>
</div>
<div className="vm-retention-filters-body__expr">
<div className="vm-retention-filters-body__title">
<p>Provide a list of metrics to check retention configuration.</p>
</div>
<TextField
type="textarea"
label="Metrics"
value={metrics}
error={error || metricsError}
onEnter={handleApplyFilters}
onChange={handleMetricsChangeInput}
placeholder={"up{env=\"dev\"}\nup{env=\"prod\"}\n"}
/>
</div>
<div className="vm-retention-filters-body__result">
<table className="vm-table">
<thead className="vm-table-header">
<tr>
<th className="vm-table-cell vm-table-cell_header">Metric</th>
<th className="vm-table-cell vm-table-cell_header">Applied retention</th>
</tr>
</thead>
<tbody className="vm-table-body">
{rows}
</tbody>
</table>
</div>
<div className="vm-retention-filters-body-top">
<a
className="vm-link vm-link_with-icon"
target="_blank"
href="https://docs.victoriametrics.com/#retention-filters"
rel="help noreferrer"
>
<WikiIcon/>
Documentation
</a>
<Button
variant="text"
onClick={handleRunExample}
>
Try example
</Button>
<Button
variant="contained"
onClick={handleApplyFilters}
startIcon={<PlayIcon/>}
>
Apply
</Button>
</div>
</div>
</section>
);
};
export default RetentionFilters;

View file

@ -0,0 +1,46 @@
@use "src/styles/variables" as *;
.vm-retention-filters {
display: grid;
gap: $padding-medium;
&-body {
display: grid;
gap: $padding-global;
align-items: flex-start;
width: 100%;
&__title {
margin-bottom: $padding-medium;
}
&-top {
display: flex;
gap: $padding-small;
align-items: center;
justify-content: flex-end;
}
&__expr textarea {
min-height: 200px;
}
&__result textarea {
min-height: 60px;
}
code {
background-color: var(--color-hover-black);
border-radius: 6px;
font-size: 85%;
padding: .2em .4em;
}
textarea {
font-family: $font-family-monospace;
overflow: auto;
width: 100%;
height: 100%;
}
}
}

View file

@ -15,6 +15,8 @@ const router = {
icons: "/icons",
anomaly: "/anomaly",
query: "/query",
downsamplingDebug: "/downsampling-filters-debug",
retentionDebug: "/retention-filters-debug",
};
export interface RouterOptionsHeader {
@ -108,6 +110,14 @@ export const routerOptions: {[key: string]: RouterOptions} = {
[router.query]: {
title: "Query",
...routerOptionsDefault
},
[router.downsamplingDebug]: {
title: "Downsampling filters debug",
header: {}
},
[router.retentionDebug]: {
title: "Retention filters debug",
header: {}
}
};

View file

@ -0,0 +1,92 @@
import router, { routerOptions } from "./index";
export enum NavigationItemType {
internalLink,
externalLink,
}
export interface NavigationItem {
label?: string,
value?: string,
hide?: boolean
submenu?: NavigationItem[],
type?: NavigationItemType,
}
interface NavigationConfig {
serverUrl: string,
isEnterpriseLicense: boolean,
showPredefinedDashboards: boolean,
showAlertLink: boolean,
}
/**
* Special case for alert link
*/
const getAlertLink = (url: string, showAlertLink: boolean) => {
// see more https://docs.victoriametrics.com/cluster-victoriametrics/?highlight=vmalertproxyurl#vmalert
return {
label: "Alerts",
value: `${url}/vmalert`,
type: NavigationItemType.externalLink,
hide: !showAlertLink,
};
};
/**
* Submenu for Tools tab
*/
const getToolsNav = (isEnterpriseLicense: boolean) => [
{ value: router.trace },
{ value: router.queryAnalyzer },
{ value: router.withTemplate },
{ value: router.relabel },
{ value: router.downsamplingDebug, hide: !isEnterpriseLicense },
{ value: router.retentionDebug, hide: !isEnterpriseLicense },
];
/**
* Submenu for Explore tab
*/
const getExploreNav = () => [
{ value: router.metrics },
{ value: router.cardinality },
{ value: router.topQueries },
{ value: router.activeQueries },
];
/**
* Default navigation menu
*/
export const getDefaultNavigation = ({
serverUrl,
isEnterpriseLicense,
showPredefinedDashboards,
showAlertLink,
}: NavigationConfig): NavigationItem[] => [
{ value: router.home },
{ label: "Explore", submenu: getExploreNav() },
{ label: "Tools", submenu: getToolsNav(isEnterpriseLicense) },
{ value: router.dashboards, hide: !showPredefinedDashboards },
getAlertLink(serverUrl, showAlertLink),
];
/**
* VictoriaLogs navigation menu
*/
export const getLogsNavigation = (): NavigationItem[] => [
{
label: routerOptions[router.logs].title,
value: router.home,
},
];
/**
* vmanomaly navigation menu
*/
export const getAnomalyNavigation = (): NavigationItem[] => [
{
label: routerOptions[router.anomaly].title,
value: router.home,
},
];

View file

@ -0,0 +1,43 @@
import { getAppModeEnable } from "../utils/app-mode";
import { useDashboardsState } from "../state/dashboards/DashboardsStateContext";
import { useAppState } from "../state/common/StateContext";
import { useMemo } from "preact/compat";
import { AppType } from "../types/appType";
import { processNavigationItems } from "./utils";
import { getAnomalyNavigation, getDefaultNavigation, getLogsNavigation } from "./navigation";
const appType = process.env.REACT_APP_TYPE;
const useNavigationMenu = () => {
const appModeEnable = getAppModeEnable();
const { dashboardsSettings } = useDashboardsState();
const { serverUrl, flags, appConfig } = useAppState();
const isEnterpriseLicense = appConfig.license?.type === "enterprise";
const showAlertLink = Boolean(flags["vmalert.proxyURL"]);
const showPredefinedDashboards = Boolean(!appModeEnable && dashboardsSettings.length);
const navigationConfig = useMemo(() => ({
serverUrl,
isEnterpriseLicense,
showAlertLink,
showPredefinedDashboards
}), [serverUrl, isEnterpriseLicense, showAlertLink, showPredefinedDashboards]);
const menu = useMemo(() => {
switch (appType) {
case AppType.logs:
return getLogsNavigation();
case AppType.anomaly:
return getAnomalyNavigation();
default:
return getDefaultNavigation(navigationConfig);
}
}, [navigationConfig]);
return processNavigationItems(menu);
};
export default useNavigationMenu;

View file

@ -0,0 +1,30 @@
import { routerOptions } from "./index";
import { NavigationItem } from "./navigation";
const routePathToTitle = (path: string): string => {
try {
return path
.replace(/^\/+/, "") // Remove leading slashes
.replace(/-/g, " ") // Replace hyphens with spaces
.trim() // Trim whitespace from both ends
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize the first character
} catch (e) {
return path;
}
};
export const processNavigationItems = (items: NavigationItem[]): NavigationItem[] => {
return items.filter((item) => !item.hide).map((item) => {
const newItem: NavigationItem = { ...item };
if (newItem.value && !newItem.label) {
newItem.label = routerOptions[newItem.value]?.title || routePathToTitle(newItem.value);
}
if (newItem.submenu && newItem.submenu.length > 0) {
newItem.submenu = processNavigationItems(newItem.submenu);
}
return newItem;
});
};

View file

@ -1,7 +1,7 @@
import { getDefaultServer } from "../../utils/default-server-url";
import { getQueryStringValue } from "../../utils/query-string";
import { getFromStorage, saveToStorage } from "../../utils/storage";
import { Theme } from "../../types";
import { AppConfig, Theme } from "../../types";
import { isDarkTheme } from "../../utils/theme";
import { removeTrailingSlash } from "../../utils/url";
@ -11,6 +11,7 @@ export interface AppState {
theme: Theme;
isDarkTheme: boolean | null;
flags: Record<string, string | null>;
appConfig: AppConfig
}
export type Action =
@ -18,6 +19,7 @@ export type Action =
| { type: "SET_THEME", payload: Theme }
| { type: "SET_TENANT_ID", payload: string }
| { type: "SET_FLAGS", payload: Record<string, string | null> }
| { type: "SET_APP_CONFIG", payload: AppConfig }
| { type: "SET_DARK_THEME" }
const tenantId = getQueryStringValue("g0.tenantID", "") as string;
@ -28,6 +30,7 @@ export const initialState: AppState = {
theme: (getFromStorage("THEME") || Theme.system) as Theme,
isDarkTheme: null,
flags: {},
appConfig: {}
};
export function reducer(state: AppState, action: Action): AppState {
@ -58,6 +61,11 @@ export function reducer(state: AppState, action: Action): AppState {
...state,
flags: action.payload
};
case "SET_APP_CONFIG":
return {
...state,
appConfig: action.payload
};
default:
throw new Error();
}

View file

@ -165,3 +165,9 @@ export enum QueryContextType {
label = "label",
labelValue = "labelValue",
}
export interface AppConfig {
license?: {
type?: "enterprise" | "opensource";
}
}