mirror of
https://github.com/prometheus/prometheus.git
synced 2025-03-05 20:59:13 -08:00
Lots of more progress on the new Mantine UI
Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
parent
89ecb3a3f2
commit
2bb14c5787
2734
web/ui/mantine-ui/package-lock.json
generated
2734
web/ui/mantine-ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -18,17 +18,20 @@
|
|||
"@codemirror/view": "^6.24.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@mantine/code-highlight": "^7.5.3",
|
||||
"@mantine/core": "^7.5.3",
|
||||
"@mantine/dates": "^7.5.3",
|
||||
"@mantine/hooks": "^7.5.3",
|
||||
"@mantine/code-highlight": "^7.6.1",
|
||||
"@mantine/core": "^7.6.1",
|
||||
"@mantine/dates": "^7.6.1",
|
||||
"@mantine/hooks": "^7.6.1",
|
||||
"@mantine/notifications": "^7.6.1",
|
||||
"@prometheus-io/codemirror-promql": "^0.50.0-rc.1",
|
||||
"@reduxjs/toolkit": "^2.2.1",
|
||||
"@tabler/icons-react": "^2.47.0",
|
||||
"@tanstack/react-query": "^5.22.2",
|
||||
"@uiw/react-codemirror": "^4.21.22",
|
||||
"dayjs": "^1.11.10",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^9.1.0",
|
||||
"react-router-dom": "^6.22.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import "@mantine/core/styles.css";
|
||||
import "@mantine/code-highlight/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import "@mantine/dates/styles.css";
|
||||
import classes from "./App.module.css";
|
||||
import PrometheusLogo from "./images/prometheus-logo.svg";
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
AppShell,
|
||||
Box,
|
||||
Burger,
|
||||
Button,
|
||||
Group,
|
||||
|
@ -17,18 +21,19 @@ import {
|
|||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconAdjustments,
|
||||
IconBellFilled,
|
||||
IconChartAreaFilled,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconCloudDataConnection,
|
||||
IconDatabase,
|
||||
IconDatabaseSearch,
|
||||
IconFileAnalytics,
|
||||
IconFlag,
|
||||
IconHeartRateMonitor,
|
||||
IconHelp,
|
||||
IconInfoCircle,
|
||||
IconServerCog,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
BrowserRouter,
|
||||
|
@ -37,23 +42,24 @@ import {
|
|||
Route,
|
||||
Routes,
|
||||
} from "react-router-dom";
|
||||
import Graph from "./pages/graph";
|
||||
import Alerts from "./pages/alerts";
|
||||
import { IconTable } from "@tabler/icons-react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
// import { ReactQueryDevtools } from "react-query/devtools";
|
||||
import Rules from "./pages/rules";
|
||||
import Targets from "./pages/targets";
|
||||
import ServiceDiscovery from "./pages/service-discovery";
|
||||
import Status from "./pages/status";
|
||||
import TSDBStatus from "./pages/tsdb-status";
|
||||
import Flags from "./pages/flags";
|
||||
import Config from "./pages/config";
|
||||
import QueryPage from "./pages/query/QueryPage";
|
||||
import AlertsPage from "./pages/AlertsPage";
|
||||
import RulesPage from "./pages/RulesPage";
|
||||
import TargetsPage from "./pages/TargetsPage";
|
||||
import ServiceDiscoveryPage from "./pages/ServiceDiscoveryPage";
|
||||
import StatusPage from "./pages/StatusPage";
|
||||
import TSDBStatusPage from "./pages/TSDBStatusPage";
|
||||
import FlagsPage from "./pages/FlagsPage";
|
||||
import ConfigPage from "./pages/ConfigPage";
|
||||
import AgentPage from "./pages/AgentPage";
|
||||
import { Suspense, useContext } from "react";
|
||||
import ErrorBoundary from "./error-boundary";
|
||||
import { ThemeSelector } from "./theme-selector";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import { ThemeSelector } from "./ThemeSelector";
|
||||
import { SettingsContext } from "./settings";
|
||||
import Agent from "./pages/agent";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
|
@ -62,13 +68,13 @@ const monitoringStatusPages = [
|
|||
title: "Targets",
|
||||
path: "/targets",
|
||||
icon: <IconHeartRateMonitor style={{ width: rem(14), height: rem(14) }} />,
|
||||
element: <Targets />,
|
||||
element: <TargetsPage />,
|
||||
},
|
||||
{
|
||||
title: "Rules",
|
||||
path: "/rules",
|
||||
icon: <IconTable style={{ width: rem(14), height: rem(14) }} />,
|
||||
element: <Rules />,
|
||||
element: <RulesPage />,
|
||||
},
|
||||
{
|
||||
title: "Service discovery",
|
||||
|
@ -76,7 +82,7 @@ const monitoringStatusPages = [
|
|||
icon: (
|
||||
<IconCloudDataConnection style={{ width: rem(14), height: rem(14) }} />
|
||||
),
|
||||
element: <ServiceDiscovery />,
|
||||
element: <ServiceDiscoveryPage />,
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -85,25 +91,25 @@ const serverStatusPages = [
|
|||
title: "Runtime & build information",
|
||||
path: "/status",
|
||||
icon: <IconInfoCircle style={{ width: rem(14), height: rem(14) }} />,
|
||||
element: <Status />,
|
||||
element: <StatusPage />,
|
||||
},
|
||||
{
|
||||
title: "TSDB status",
|
||||
path: "/tsdb-status",
|
||||
icon: <IconDatabase style={{ width: rem(14), height: rem(14) }} />,
|
||||
element: <TSDBStatus />,
|
||||
element: <TSDBStatusPage />,
|
||||
},
|
||||
{
|
||||
title: "Command-line flags",
|
||||
path: "/flags",
|
||||
icon: <IconFlag style={{ width: rem(14), height: rem(14) }} />,
|
||||
element: <Flags />,
|
||||
element: <FlagsPage />,
|
||||
},
|
||||
{
|
||||
title: "Configuration",
|
||||
path: "/config",
|
||||
icon: <IconServerCog style={{ width: rem(14), height: rem(14) }} />,
|
||||
element: <Config />,
|
||||
element: <ConfigPage />,
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -126,6 +132,9 @@ const theme = createTheme({
|
|||
},
|
||||
});
|
||||
|
||||
const navLinkIconSize = 15;
|
||||
const navLinkXPadding = "md";
|
||||
|
||||
function App() {
|
||||
const [opened, { toggle }] = useDisclosure();
|
||||
const { agentMode } = useContext(SettingsContext);
|
||||
|
@ -134,17 +143,19 @@ function App() {
|
|||
<>
|
||||
<Button
|
||||
component={NavLink}
|
||||
to="/graph"
|
||||
to="/query"
|
||||
className={classes.link}
|
||||
leftSection={<IconChartAreaFilled size={14} />}
|
||||
leftSection={<IconDatabaseSearch size={navLinkIconSize} />}
|
||||
px={navLinkXPadding}
|
||||
>
|
||||
Graph
|
||||
Query
|
||||
</Button>
|
||||
<Button
|
||||
component={NavLink}
|
||||
to="/alerts"
|
||||
className={classes.link}
|
||||
leftSection={<IconBellFilled size={14} />}
|
||||
leftSection={<IconBellFilled size={navLinkIconSize} />}
|
||||
px={navLinkXPadding}
|
||||
>
|
||||
Alerts
|
||||
</Button>
|
||||
|
@ -162,9 +173,10 @@ function App() {
|
|||
to={p.path}
|
||||
className={classes.link}
|
||||
leftSection={p.icon}
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
rightSection={<IconChevronDown size={navLinkIconSize} />}
|
||||
px={navLinkXPadding}
|
||||
>
|
||||
Status <IconChevronRight size={14} /> {p.title}
|
||||
Status <IconChevronRight size={navLinkIconSize} /> {p.title}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
}
|
||||
|
@ -178,11 +190,12 @@ function App() {
|
|||
component={NavLink}
|
||||
to="/"
|
||||
className={classes.link}
|
||||
leftSection={<IconFileAnalytics size={14} />}
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
leftSection={<IconFileAnalytics size={navLinkIconSize} />}
|
||||
rightSection={<IconChevronDown size={navLinkIconSize} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
px={navLinkXPadding}
|
||||
>
|
||||
Status
|
||||
</Button>
|
||||
|
@ -219,21 +232,24 @@ function App() {
|
|||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<Button
|
||||
{/* <Button
|
||||
component="a"
|
||||
href="https://prometheus.io/docs/prometheus/latest/getting_started/"
|
||||
className={classes.link}
|
||||
leftSection={<IconHelp size={14} />}
|
||||
leftSection={<IconHelp size={navLinkIconSize} />}
|
||||
target="_blank"
|
||||
px={navLinkXPadding}
|
||||
>
|
||||
Help
|
||||
</Button>
|
||||
</Button> */}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<MantineProvider defaultColorScheme="auto" theme={theme}>
|
||||
<Notifications position="top-right" />
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppShell
|
||||
header={{ height: 56 }}
|
||||
|
@ -246,14 +262,38 @@ function App() {
|
|||
>
|
||||
<AppShell.Header bg="rgb(65, 73, 81)" c="#fff">
|
||||
<Group h="100%" px="md">
|
||||
<Group style={{ flex: 1 }}>
|
||||
<Group gap={10}>
|
||||
<Group style={{ flex: 1 }} justify="space-between">
|
||||
<Group gap={10} w={150}>
|
||||
<img src={PrometheusLogo} height={30} />
|
||||
<Text fz={20}>Prometheus{agentMode && " Agent"}</Text>
|
||||
</Group>
|
||||
<Group ml="lg" gap={12} visibleFrom="sm">
|
||||
<Group gap={12} visibleFrom="sm">
|
||||
{navLinks}
|
||||
</Group>
|
||||
<Group w={180} justify="flex-end">
|
||||
{<ThemeSelector />}
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
// variant=""
|
||||
color="gray"
|
||||
aria-label="Settings"
|
||||
size="md"
|
||||
>
|
||||
<IconSettings size={navLinkIconSize} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
component={NavLink}
|
||||
to="/"
|
||||
leftSection={<IconAdjustments />}
|
||||
>
|
||||
Settings
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
<Burger
|
||||
opened={opened}
|
||||
|
@ -262,7 +302,6 @@ function App() {
|
|||
size="sm"
|
||||
color="gray.2"
|
||||
/>
|
||||
{<ThemeSelector />}
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
|
@ -273,20 +312,30 @@ function App() {
|
|||
<AppShell.Main>
|
||||
<ErrorBoundary key={location.pathname}>
|
||||
<Suspense
|
||||
fallback={Array.from(Array(10), (_, i) => (
|
||||
<Skeleton key={i} height={40} mb={15} width={1000} />
|
||||
))}
|
||||
fallback={
|
||||
<Box mt="lg">
|
||||
{Array.from(Array(10), (_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
height={40}
|
||||
mb={15}
|
||||
width={1000}
|
||||
mx="auto"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Navigate to={agentMode ? "/agent" : "/graph"} />
|
||||
<Navigate to={agentMode ? "/agent" : "/query"} />
|
||||
}
|
||||
/>
|
||||
<Route path="/graph" element={<Graph />} />
|
||||
<Route path="/agent" element={<Agent />} />
|
||||
<Route path="/alerts" element={<Alerts />} />
|
||||
<Route path="/query" element={<QueryPage />} />
|
||||
<Route path="/agent" element={<AgentPage />} />
|
||||
<Route path="/alerts" element={<AlertsPage />} />
|
||||
{allStatusPages.map((p) => (
|
||||
<Route key={p.path} path={p.path} element={p.element} />
|
||||
))}
|
||||
|
|
|
@ -32,6 +32,9 @@ class ErrorBoundary extends Component<Props, State> {
|
|||
color="red"
|
||||
title="Error querying page data"
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
maw={500}
|
||||
mx="auto"
|
||||
mt="lg"
|
||||
>
|
||||
<strong>Error:</strong> {this.state.error.message}
|
||||
</Alert>
|
|
@ -1,20 +1,10 @@
|
|||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Group,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconClockPause,
|
||||
IconClockPlay,
|
||||
} from "@tabler/icons-react";
|
||||
import { Badge, Card, Group, useComputedColorScheme } from "@mantine/core";
|
||||
import { IconClockPause, IconClockPlay } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
import { formatDuration } from "./lib/time-format";
|
||||
import { formatDuration } from "./lib/formatTime";
|
||||
import codeboxClasses from "./codebox.module.css";
|
||||
import badgeClasses from "./badge.module.css";
|
||||
import { Rule } from "./api/response-types/rules";
|
||||
import badgeClasses from "./Badge.module.css";
|
||||
import { Rule } from "./api/responseTypes/rules";
|
||||
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
|
||||
import { syntaxHighlighting } from "@codemirror/language";
|
||||
import {
|
64
web/ui/mantine-ui/src/ThemeSelector.tsx
Normal file
64
web/ui/mantine-ui/src/ThemeSelector.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
useMantineColorScheme,
|
||||
SegmentedControl,
|
||||
rem,
|
||||
MantineColorScheme,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconMoonFilled,
|
||||
IconSunFilled,
|
||||
IconUserFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
|
||||
export const ThemeSelector: FC = () => {
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const iconProps = {
|
||||
style: { width: rem(20), height: rem(20), display: "block" },
|
||||
stroke: 1.5,
|
||||
};
|
||||
|
||||
return (
|
||||
<SegmentedControl
|
||||
color="gray.7"
|
||||
size="xs"
|
||||
// styles={{ root: { backgroundColor: "var(--mantine-color-gray-7)" } }}
|
||||
styles={{
|
||||
root: {
|
||||
padding: 3,
|
||||
backgroundColor: "var(--mantine-color-gray-6)",
|
||||
},
|
||||
}}
|
||||
withItemsBorders={false}
|
||||
value={colorScheme}
|
||||
onChange={(v) => setColorScheme(v as MantineColorScheme)}
|
||||
data={[
|
||||
{
|
||||
value: "light",
|
||||
label: (
|
||||
<Tooltip label="Use light theme" offset={15}>
|
||||
<IconSunFilled {...iconProps} />
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: (
|
||||
<Tooltip label="Use dark theme" offset={15}>
|
||||
<IconMoonFilled {...iconProps} />
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
label: (
|
||||
<Tooltip label="Use browser-preferred theme" offset={15}>
|
||||
<IconUserFilled {...iconProps} />
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,32 +1,94 @@
|
|||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
|
||||
export const API_PATH = "api/v1";
|
||||
|
||||
export type APIResponse<T> = { status: string; data: T };
|
||||
export type SuccessAPIResponse<T> = {
|
||||
status: "success";
|
||||
data: T;
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
export const useSuspenseAPIQuery = <T>(path: string) =>
|
||||
useSuspenseQuery<{ data: T }>({
|
||||
export type ErrorAPIResponse = {
|
||||
status: "error";
|
||||
errorType: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type APIResponse<T> = SuccessAPIResponse<T> | ErrorAPIResponse;
|
||||
|
||||
export const useAPIQuery = <T>({
|
||||
key,
|
||||
path,
|
||||
params,
|
||||
enabled,
|
||||
}: {
|
||||
key?: string;
|
||||
path: string;
|
||||
params?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
}) =>
|
||||
useQuery<APIResponse<T>>({
|
||||
queryKey: [key || path],
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
gcTime: 0,
|
||||
enabled,
|
||||
queryFn: async ({ signal }) => {
|
||||
const queryString = params
|
||||
? `?${new URLSearchParams(params).toString()}`
|
||||
: "";
|
||||
return (
|
||||
fetch(`/${API_PATH}/${path}${queryString}`, {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
signal,
|
||||
})
|
||||
// TODO: think about how to check API errors here, if this code remains in use.
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(res.statusText);
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.then((res) => res.json() as Promise<APIResponse<T>>)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const useSuspenseAPIQuery = <T>(
|
||||
path: string,
|
||||
params?: Record<string, string>
|
||||
) =>
|
||||
useSuspenseQuery<SuccessAPIResponse<T>>({
|
||||
queryKey: [path],
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
gcTime: 0,
|
||||
queryFn: () =>
|
||||
fetch(`/${API_PATH}/${path}`, {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
})
|
||||
// Introduce 3 seconds delay to simulate slow network.
|
||||
// .then(
|
||||
// (res) =>
|
||||
// new Promise<typeof res>((resolve) =>
|
||||
// setTimeout(() => resolve(res), 2000)
|
||||
// )
|
||||
// )
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(res.statusText);
|
||||
}
|
||||
return res;
|
||||
queryFn: ({ signal }) => {
|
||||
const queryString = params
|
||||
? `?${new URLSearchParams(params).toString()}`
|
||||
: "";
|
||||
return (
|
||||
fetch(`/${API_PATH}/${path}${queryString}`, {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
signal,
|
||||
})
|
||||
.then((res) => res.json() as Promise<APIResponse<T>>),
|
||||
// Introduce 3 seconds delay to simulate slow network.
|
||||
// .then(
|
||||
// (res) =>
|
||||
// new Promise<typeof res>((resolve) =>
|
||||
// setTimeout(() => resolve(res), 2000)
|
||||
// )
|
||||
// )
|
||||
// TODO: think about how to check API errors here, if this code remains in use.
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(res.statusText);
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.then((res) => res.json() as Promise<SuccessAPIResponse<T>>)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
42
web/ui/mantine-ui/src/api/responseTypes/query.ts
Normal file
42
web/ui/mantine-ui/src/api/responseTypes/query.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
export interface Metric {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface Histogram {
|
||||
count: string;
|
||||
sum: string;
|
||||
buckets?: [number, string, string, string][];
|
||||
}
|
||||
|
||||
export interface InstantSample {
|
||||
metric: Metric;
|
||||
value?: SampleValue;
|
||||
histogram?: SampleHistogram;
|
||||
}
|
||||
|
||||
export interface RangeSamples {
|
||||
metric: Metric;
|
||||
values?: SampleValue[];
|
||||
histograms?: SampleHistogram[];
|
||||
}
|
||||
|
||||
export type SampleValue = [number, string];
|
||||
export type SampleHistogram = [number, Histogram];
|
||||
|
||||
export type InstantQueryResult =
|
||||
| {
|
||||
resultType: "vector";
|
||||
result: InstantSample[];
|
||||
}
|
||||
| {
|
||||
resultType: "matrix";
|
||||
result: RangeSamples[];
|
||||
}
|
||||
| {
|
||||
resultType: "scalar";
|
||||
result: SampleValue;
|
||||
}
|
||||
| {
|
||||
resultType: "string";
|
||||
result: SampleValue;
|
||||
};
|
4
web/ui/mantine-ui/src/codemirror/theme.ts
vendored
4
web/ui/mantine-ui/src/codemirror/theme.ts
vendored
|
@ -3,6 +3,10 @@ import { EditorView } from "@codemirror/view";
|
|||
import { tags } from "@lezer/highlight";
|
||||
|
||||
export const baseTheme = EditorView.theme({
|
||||
".cm-content": {
|
||||
paddingTop: "3px",
|
||||
paddingBottom: "0px",
|
||||
},
|
||||
"&.cm-editor": {
|
||||
"&.cm-focused": {
|
||||
outline: "none",
|
||||
|
|
3
web/ui/mantine-ui/src/lib/escapeString.ts
Normal file
3
web/ui/mantine-ui/src/lib/escapeString.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const escapeString = (str: string) => {
|
||||
return str.replace(/([\\"])/g, "\\$1");
|
||||
};
|
12
web/ui/mantine-ui/src/lib/formatSeries.ts
Normal file
12
web/ui/mantine-ui/src/lib/formatSeries.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { escapeString } from "./escapeString";
|
||||
|
||||
export const formatSeries = (labels: { [key: string]: string }): string => {
|
||||
if (labels === null) {
|
||||
return "scalar";
|
||||
}
|
||||
|
||||
return `${labels.__name__ || ""}{${Object.entries(labels)
|
||||
.filter(([k]) => k !== "__name__")
|
||||
.map(([k, v]) => `${k}="${escapeString(v)}"`)
|
||||
.join(", ")}}`;
|
||||
};
|
|
@ -2,6 +2,8 @@ import React from "react";
|
|||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import { Settings, SettingsContext } from "./settings.ts";
|
||||
import store from "./state/store.ts";
|
||||
import { Provider } from "react-redux";
|
||||
|
||||
// Declared/defined in public/index.html, value replaced by Prometheus when serving bundle.
|
||||
declare const GLOBAL_CONSOLES_LINK: string;
|
||||
|
@ -22,7 +24,9 @@ const settings: Settings = {
|
|||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<App />
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
</SettingsContext.Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Card, Group, Text } from "@mantine/core";
|
|||
import { IconSpy } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
|
||||
const Agent: FC = () => {
|
||||
const AgentPage: FC = () => {
|
||||
return (
|
||||
<Card shadow="xs" withBorder radius="md" p="md">
|
||||
<Group wrap="nowrap" align="center" ml="xs" mb="sm" gap="xs">
|
||||
|
@ -24,4 +24,4 @@ const Agent: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Agent;
|
||||
export default AgentPage;
|
|
@ -10,14 +10,16 @@ import {
|
|||
Switch,
|
||||
} from "@mantine/core";
|
||||
import { useSuspenseAPIQuery } from "../api/api";
|
||||
import { AlertingRulesMap } from "../api/response-types/rules";
|
||||
import badgeClasses from "../badge.module.css";
|
||||
import RuleDefinition from "../rule-definition";
|
||||
import { formatRelative, now } from "../lib/time-format";
|
||||
import { AlertingRulesMap } from "../api/responseTypes/rules";
|
||||
import badgeClasses from "../Badge.module.css";
|
||||
import RuleDefinition from "../RuleDefinition";
|
||||
import { formatRelative, now } from "../lib/formatTime";
|
||||
import { Fragment, useState } from "react";
|
||||
|
||||
export default function Alerts() {
|
||||
const { data } = useSuspenseAPIQuery<AlertingRulesMap>(`/rules?type=alert`);
|
||||
export default function AlertsPage() {
|
||||
const { data } = useSuspenseAPIQuery<AlertingRulesMap>(`/rules`, {
|
||||
type: "alert",
|
||||
});
|
||||
const [showAnnotations, setShowAnnotations] = useState(false);
|
||||
|
||||
const ruleStatsCount = {
|
||||
|
@ -67,7 +69,18 @@ export default function Alerts() {
|
|||
).length;
|
||||
|
||||
return (
|
||||
<Accordion.Item key={j} value={j.toString()}>
|
||||
<Accordion.Item
|
||||
key={j}
|
||||
value={j.toString()}
|
||||
style={{
|
||||
borderLeft:
|
||||
numFiring > 0
|
||||
? "5px solid var(--mantine-color-red-4)"
|
||||
: numPending > 0
|
||||
? "5px solid var(--mantine-color-orange-5)"
|
||||
: "5px solid var(--mantine-color-green-4)",
|
||||
}}
|
||||
>
|
||||
<Accordion.Control>
|
||||
<Group wrap="nowrap" justify="space-between" mr="lg">
|
||||
<Text>{r.name}</Text>
|
|
@ -1,7 +1,7 @@
|
|||
import { CodeHighlight } from "@mantine/code-highlight";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
|
||||
export default function Config() {
|
||||
export default function ConfigPage() {
|
||||
const {
|
||||
data: {
|
||||
data: { yaml },
|
||||
|
@ -12,5 +12,15 @@ export default function Config() {
|
|||
return fetch("/api/v1/status/config").then((res) => res.json());
|
||||
},
|
||||
});
|
||||
return <CodeHighlight code={yaml} language="yaml" miw="30vw" />;
|
||||
return (
|
||||
<CodeHighlight
|
||||
code={yaml}
|
||||
language="yaml"
|
||||
miw="30vw"
|
||||
w="fit-content"
|
||||
maw="calc(100vw - 75px)"
|
||||
mx="auto"
|
||||
mt="lg"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -16,7 +16,7 @@ import {
|
|||
IconChevronUp,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import classes from "./flags.module.css";
|
||||
import classes from "./FlagsPage.module.css";
|
||||
import { useSuspenseAPIQuery } from "../api/api";
|
||||
|
||||
interface RowData {
|
||||
|
@ -82,12 +82,9 @@ function sortData(
|
|||
);
|
||||
}
|
||||
|
||||
export default function Flags() {
|
||||
export default function FlagsPage() {
|
||||
const { data } = useSuspenseAPIQuery<Record<string, string>>(`/status/flags`);
|
||||
|
||||
// const { response, error, isLoading } =
|
||||
// useFetchAPI<Record<string, string>>(`/status/flags`);
|
||||
|
||||
const flags = Object.entries(data.data).map(([flag, value]) => ({
|
||||
flag,
|
||||
value,
|
||||
|
@ -125,7 +122,7 @@ export default function Flags() {
|
|||
));
|
||||
|
||||
return (
|
||||
<Card shadow="xs" maw={1000} withBorder>
|
||||
<Card shadow="xs" maw={1000} mx="auto" mt="lg" withBorder>
|
||||
<TextInput
|
||||
placeholder="Filter by flag name or value"
|
||||
mb="md"
|
|
@ -1,6 +1,6 @@
|
|||
import { Alert, Badge, Card, Group, Table, Text, Tooltip } from "@mantine/core";
|
||||
// import { useQuery } from "react-query";
|
||||
import { formatRelative, humanizeDuration, now } from "../lib/time-format";
|
||||
import { formatRelative, humanizeDuration, now } from "../lib/formatTime";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBell,
|
||||
|
@ -10,9 +10,9 @@ import {
|
|||
IconRepeat,
|
||||
} from "@tabler/icons-react";
|
||||
import { useSuspenseAPIQuery } from "../api/api";
|
||||
import { RulesMap } from "../api/response-types/rules";
|
||||
import badgeClasses from "../badge.module.css";
|
||||
import RuleDefinition from "../rule-definition";
|
||||
import { RulesMap } from "../api/responseTypes/rules";
|
||||
import badgeClasses from "../Badge.module.css";
|
||||
import RuleDefinition from "../RuleDefinition";
|
||||
|
||||
const healthBadgeClass = (state: string) => {
|
||||
switch (state) {
|
||||
|
@ -27,7 +27,7 @@ const healthBadgeClass = (state: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
export default function Rules() {
|
||||
export default function RulesPage() {
|
||||
const { data } = useSuspenseAPIQuery<RulesMap>(`/rules`);
|
||||
|
||||
return (
|
3
web/ui/mantine-ui/src/pages/ServiceDiscoveryPage.tsx
Normal file
3
web/ui/mantine-ui/src/pages/ServiceDiscoveryPage.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default function ServiceDiscoveryPage() {
|
||||
return <>ServiceDiscovery page</>;
|
||||
}
|
|
@ -27,14 +27,14 @@ const statusConfig: Record<
|
|||
storageRetention: { title: "Storage retention" },
|
||||
};
|
||||
|
||||
export default function Status() {
|
||||
export default function StatusPage() {
|
||||
const { data: buildinfo } =
|
||||
useSuspenseAPIQuery<Record<string, string>>(`/status/buildinfo`);
|
||||
const { data: runtimeinfo } =
|
||||
useSuspenseAPIQuery<Record<string, string>>(`/status/runtimeinfo`);
|
||||
|
||||
return (
|
||||
<Stack gap="md" maw={1000}>
|
||||
<Stack gap="lg" maw={1000} mx="auto" mt="lg">
|
||||
<Card shadow="xs" withBorder radius="md" p="md">
|
||||
<Group wrap="nowrap" align="center" ml="xs" mb="sm" gap="xs">
|
||||
<IconWall size={22} />
|
|
@ -1,8 +1,8 @@
|
|||
import { Stack, Card, Group, Table, Text } from "@mantine/core";
|
||||
import { useSuspenseAPIQuery } from "../api/api";
|
||||
import { TSDBMap } from "../api/response-types/tsdb-status";
|
||||
import { TSDBMap } from "../api/responseTypes/tsdbStatus";
|
||||
|
||||
export default function TSDBStatus() {
|
||||
export default function TSDBStatusPage() {
|
||||
const {
|
||||
data: {
|
||||
data: {
|
||||
|
@ -35,7 +35,7 @@ export default function TSDBStatus() {
|
|||
];
|
||||
|
||||
return (
|
||||
<Stack gap="md" maw={1000}>
|
||||
<Stack gap="lg" maw={1000} mx="auto" mt="lg">
|
||||
{[
|
||||
{
|
||||
title: "TSDB Head Status",
|
3
web/ui/mantine-ui/src/pages/TargetsPage.tsx
Normal file
3
web/ui/mantine-ui/src/pages/TargetsPage.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default function TargetsPage() {
|
||||
return <>Targets page</>;
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
import { Group, Textarea, Button } from "@mantine/core";
|
||||
import { IconTerminal } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import classes from "./graph.module.css";
|
||||
|
||||
export default function Graph() {
|
||||
const [expr, setExpr] = useState<string>("");
|
||||
|
||||
return (
|
||||
<Group align="baseline" wrap="nowrap" gap="xs" mt="sm">
|
||||
<Textarea
|
||||
style={{ flex: "auto" }}
|
||||
classNames={classes}
|
||||
placeholder="Enter PromQL expression..."
|
||||
value={expr}
|
||||
onChange={(event) => setExpr(event.currentTarget.value)}
|
||||
leftSection={<IconTerminal />}
|
||||
rightSectionPointerEvents="all"
|
||||
autosize
|
||||
autoFocus
|
||||
/>
|
||||
<Button variant="primary" onClick={() => console.log(expr)}>
|
||||
Execute
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
173
web/ui/mantine-ui/src/pages/query/DataTable.tsx
Normal file
173
web/ui/mantine-ui/src/pages/query/DataTable.tsx
Normal file
|
@ -0,0 +1,173 @@
|
|||
import { FC, useEffect, useId } from "react";
|
||||
import { Table, Alert, Skeleton, Box, LoadingOverlay } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react";
|
||||
import {
|
||||
InstantQueryResult,
|
||||
InstantSample,
|
||||
RangeSamples,
|
||||
} from "../../api/responseTypes/query";
|
||||
import SeriesName from "./SeriesName";
|
||||
import { useAPIQuery } from "../../api/api";
|
||||
|
||||
const maxFormattableSeries = 1000;
|
||||
const maxDisplayableSeries = 10000;
|
||||
|
||||
const limitSeries = <S extends InstantSample | RangeSamples>(
|
||||
series: S[]
|
||||
): S[] => {
|
||||
if (series.length > maxDisplayableSeries) {
|
||||
return series.slice(0, maxDisplayableSeries);
|
||||
}
|
||||
return series;
|
||||
};
|
||||
|
||||
export interface TableProps {
|
||||
expr: string;
|
||||
evalTime: number | null;
|
||||
retriggerIdx: number;
|
||||
}
|
||||
|
||||
const DataTable: FC<TableProps> = ({ expr, evalTime, retriggerIdx }) => {
|
||||
// const now = useMemo(() => Date.now() / 1000, [retriggerIdx]);
|
||||
// const { data, error, isFetching, isLoading } = useInstantQueryQuery(
|
||||
// {
|
||||
// query: expr,
|
||||
// time: evalTime !== null ? evalTime / 1000 : now,
|
||||
// },
|
||||
// { skip: !expr }
|
||||
// );
|
||||
|
||||
// const now = useMemo(() => Date.now() / 1000, [retriggerIdx]);
|
||||
const { data, error, isFetching, isLoading, refetch } =
|
||||
useAPIQuery<InstantQueryResult>({
|
||||
key: useId(),
|
||||
path: "/query",
|
||||
params: {
|
||||
query: expr,
|
||||
time: `${(evalTime !== null ? evalTime : Date.now()) / 1000}`,
|
||||
},
|
||||
enabled: expr !== "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
expr !== "" && refetch();
|
||||
}, [retriggerIdx, refetch, expr, evalTime]);
|
||||
|
||||
// Show a skeleton only on the first load, not on subsequent ones.
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box>
|
||||
{Array.from(Array(5), (_, i) => (
|
||||
<Skeleton key={i} height={30} mb={15} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
title="Error executing query"
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
>
|
||||
{error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (data === undefined) {
|
||||
return <Alert variant="transparent">No data queried yet</Alert>;
|
||||
}
|
||||
|
||||
if (data.status !== "success") {
|
||||
// TODO: Remove this case and handle it in useAPIQuery instead!
|
||||
return null;
|
||||
}
|
||||
|
||||
const { result, resultType } = data.data;
|
||||
|
||||
if (result.length === 0) {
|
||||
return (
|
||||
<Alert
|
||||
// color="light"
|
||||
title="Empty query result"
|
||||
icon={<IconInfoCircle size={14} />}
|
||||
>
|
||||
This query returned no data.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const doFormat = result.length <= maxFormattableSeries;
|
||||
|
||||
return (
|
||||
<Box pos="relative" mt="lg">
|
||||
<LoadingOverlay
|
||||
visible={isFetching}
|
||||
zIndex={1000}
|
||||
overlayProps={{ radius: "sm", blur: 1 }}
|
||||
loaderProps={{
|
||||
children: <Skeleton m={0} w="100%" h="100%" />,
|
||||
}}
|
||||
styles={{ loader: { width: "100%", height: "100%" } }}
|
||||
/>
|
||||
<Table highlightOnHover>
|
||||
<Table.Tbody>
|
||||
{resultType === "vector" ? (
|
||||
limitSeries<InstantSample>(result).map((s, idx) => (
|
||||
<Table.Tr key={idx}>
|
||||
<Table.Td>
|
||||
<SeriesName labels={s.metric} format={doFormat} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{s.value && s.value[1]}
|
||||
{s.histogram && "TODO HISTOGRAM DISPLAY"}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : resultType === "matrix" ? (
|
||||
limitSeries<RangeSamples>(result).map((s, idx) => (
|
||||
<Table.Tr key={idx}>
|
||||
<Table.Td>
|
||||
<SeriesName labels={s.metric} format={doFormat} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{s.values &&
|
||||
s.values.map((v, idx) => (
|
||||
<div key={idx}>
|
||||
{v[1]} @ {v[0]}
|
||||
</div>
|
||||
))}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : resultType === "scalar" ? (
|
||||
<Table.Tr>
|
||||
<Table.Td>Scalar value</Table.Td>
|
||||
<Table.Td>{result[1]}</Table.Td>
|
||||
</Table.Tr>
|
||||
) : resultType === "string" ? (
|
||||
<Table.Tr>
|
||||
<Table.Td>String value</Table.Td>
|
||||
<Table.Td>{result[1]}</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
<Alert
|
||||
color="red"
|
||||
title="Invalid query response"
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
maw={500}
|
||||
mx="auto"
|
||||
mt="lg"
|
||||
>
|
||||
Invalid result value type
|
||||
</Alert>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataTable;
|
13
web/ui/mantine-ui/src/pages/query/ExpressionInput.module.css
Normal file
13
web/ui/mantine-ui/src/pages/query/ExpressionInput.module.css
Normal file
|
@ -0,0 +1,13 @@
|
|||
.input {
|
||||
/* border: calc(0.0625rem * var(--mantine-scale)) solid var(--input-bd); */
|
||||
border-radius: var(--mantine-radius-default);
|
||||
flex: auto;
|
||||
/* padding: 4px 0 0 8px; */
|
||||
/* font-size: 15px; */
|
||||
/* font-family: "DejaVu Sans Mono"; */
|
||||
|
||||
&:focus-within {
|
||||
outline: rem(1.3px) solid var(--mantine-color-blue-filled);
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
257
web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx
Normal file
257
web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx
Normal file
|
@ -0,0 +1,257 @@
|
|||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
InputBase,
|
||||
Menu,
|
||||
rem,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
CompleteStrategy,
|
||||
PromQLExtension,
|
||||
newCompleteStrategy,
|
||||
} from "@prometheus-io/codemirror-promql";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import CodeMirror, {
|
||||
EditorState,
|
||||
EditorView,
|
||||
Prec,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
placeholder,
|
||||
} from "@uiw/react-codemirror";
|
||||
import {
|
||||
baseTheme,
|
||||
darkPromqlHighlighter,
|
||||
darkTheme,
|
||||
lightTheme,
|
||||
promqlHighlighter,
|
||||
} from "../../codemirror/theme";
|
||||
import {
|
||||
bracketMatching,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
syntaxTree,
|
||||
} from "@codemirror/language";
|
||||
import classes from "./ExpressionInput.module.css";
|
||||
import {
|
||||
CompletionContext,
|
||||
CompletionResult,
|
||||
autocompletion,
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
completionKeymap,
|
||||
} from "@codemirror/autocomplete";
|
||||
import {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
insertNewlineAndIndent,
|
||||
} from "@codemirror/commands";
|
||||
import { highlightSelectionMatches } from "@codemirror/search";
|
||||
import { lintKeymap } from "@codemirror/lint";
|
||||
import {
|
||||
IconAlignJustified,
|
||||
IconDotsVertical,
|
||||
IconSearch,
|
||||
IconTerminal,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
const promqlExtension = new PromQLExtension();
|
||||
|
||||
// Autocompletion strategy that wraps the main one and enriches
|
||||
// it with past query items.
|
||||
export class HistoryCompleteStrategy implements CompleteStrategy {
|
||||
private complete: CompleteStrategy;
|
||||
private queryHistory: string[];
|
||||
constructor(complete: CompleteStrategy, queryHistory: string[]) {
|
||||
this.complete = complete;
|
||||
this.queryHistory = queryHistory;
|
||||
}
|
||||
|
||||
promQL(
|
||||
context: CompletionContext
|
||||
): Promise<CompletionResult | null> | CompletionResult | null {
|
||||
return Promise.resolve(this.complete.promQL(context)).then((res) => {
|
||||
const { state, pos } = context;
|
||||
const tree = syntaxTree(state).resolve(pos, -1);
|
||||
const start = res != null ? res.from : tree.from;
|
||||
|
||||
if (start !== 0) {
|
||||
return res;
|
||||
}
|
||||
|
||||
const historyItems: CompletionResult = {
|
||||
from: start,
|
||||
to: pos,
|
||||
options: this.queryHistory.map((q) => ({
|
||||
label: q.length < 80 ? q : q.slice(0, 76).concat("..."),
|
||||
detail: "past query",
|
||||
apply: q,
|
||||
info: q.length < 80 ? undefined : q,
|
||||
})),
|
||||
validFor: /^[a-zA-Z0-9_:]+$/,
|
||||
};
|
||||
|
||||
if (res !== null) {
|
||||
historyItems.options = historyItems.options.concat(res.options);
|
||||
}
|
||||
return historyItems;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface ExpressionInputProps {
|
||||
initialExpr: string;
|
||||
executeQuery: (expr: string) => void;
|
||||
}
|
||||
|
||||
const ExpressionInput: FC<ExpressionInputProps> = ({
|
||||
initialExpr,
|
||||
executeQuery,
|
||||
}) => {
|
||||
const theme = useComputedColorScheme();
|
||||
const [expr, setExpr] = useState(initialExpr);
|
||||
useEffect(() => {
|
||||
setExpr(initialExpr);
|
||||
}, [initialExpr]);
|
||||
|
||||
// TODO: make dynamic:
|
||||
const enableAutocomplete = true;
|
||||
const enableLinter = true;
|
||||
const pathPrefix = "";
|
||||
// const metricNames = ...
|
||||
const queryHistory = [] as string[];
|
||||
|
||||
// (Re)initialize editor based on settings / setting changes.
|
||||
useEffect(() => {
|
||||
// Build the dynamic part of the config.
|
||||
promqlExtension
|
||||
.activateCompletion(enableAutocomplete)
|
||||
.activateLinter(enableLinter)
|
||||
.setComplete({
|
||||
completeStrategy: new HistoryCompleteStrategy(
|
||||
newCompleteStrategy({
|
||||
remote: {
|
||||
url: pathPrefix,
|
||||
//cache: { initialMetricList: metricNames },
|
||||
},
|
||||
}),
|
||||
queryHistory
|
||||
),
|
||||
});
|
||||
}, []); // TODO: Make this depend on external settings changes, maybe use dynamic config compartment again.
|
||||
|
||||
return (
|
||||
<Group align="flex-start" wrap="nowrap" gap="xs">
|
||||
<InputBase<any>
|
||||
leftSection={<IconTerminal />}
|
||||
rightSection={
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
aria-label="Decrease range"
|
||||
>
|
||||
<IconDotsVertical style={{ width: "1rem", height: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Query options</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<IconSearch style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
>
|
||||
Explore metrics
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<IconAlignJustified
|
||||
style={{ width: rem(14), height: rem(14) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Format expression
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={
|
||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
>
|
||||
Remove query
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
}
|
||||
component={CodeMirror}
|
||||
className={classes.input}
|
||||
basicSetup={false}
|
||||
value={expr}
|
||||
onChange={setExpr}
|
||||
autoFocus
|
||||
extensions={[
|
||||
baseTheme,
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion(),
|
||||
highlightSelectionMatches(),
|
||||
EditorView.lineWrapping,
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
...lintKeymap,
|
||||
]),
|
||||
placeholder("Enter expression (press Shift+Enter for newlines)"),
|
||||
syntaxHighlighting(
|
||||
theme === "light" ? promqlHighlighter : darkPromqlHighlighter
|
||||
),
|
||||
promqlExtension.asExtension(),
|
||||
theme === "light" ? lightTheme : darkTheme,
|
||||
keymap.of([
|
||||
{
|
||||
key: "Escape",
|
||||
run: (v: EditorView): boolean => {
|
||||
v.contentDOM.blur();
|
||||
return false;
|
||||
},
|
||||
},
|
||||
]),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Enter",
|
||||
run: (): boolean => {
|
||||
executeQuery(expr);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Shift-Enter",
|
||||
run: insertNewlineAndIndent,
|
||||
},
|
||||
])
|
||||
),
|
||||
]}
|
||||
multiline
|
||||
/>
|
||||
|
||||
<Button variant="primary" onClick={() => executeQuery(expr)}>
|
||||
Execute
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpressionInput;
|
367
web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx.old
Normal file
367
web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx.old
Normal file
|
@ -0,0 +1,367 @@
|
|||
import React, { FC, useState, useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
EditorView,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
ViewUpdate,
|
||||
placeholder,
|
||||
} from "@codemirror/view";
|
||||
import { EditorState, Prec, Compartment } from "@codemirror/state";
|
||||
import {
|
||||
bracketMatching,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
syntaxTree,
|
||||
} from "@codemirror/language";
|
||||
import {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
insertNewlineAndIndent,
|
||||
} from "@codemirror/commands";
|
||||
import { highlightSelectionMatches } from "@codemirror/search";
|
||||
import { lintKeymap } from "@codemirror/lint";
|
||||
import {
|
||||
autocompletion,
|
||||
completionKeymap,
|
||||
CompletionContext,
|
||||
CompletionResult,
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
} from "@codemirror/autocomplete";
|
||||
import {
|
||||
baseTheme,
|
||||
lightTheme,
|
||||
darkTheme,
|
||||
promqlHighlighter,
|
||||
darkPromqlHighlighter,
|
||||
} from "../../codemirror/theme";
|
||||
|
||||
import {
|
||||
CompleteStrategy,
|
||||
PromQLExtension,
|
||||
} from "@prometheus-io/codemirror-promql";
|
||||
import { newCompleteStrategy } from "@prometheus-io/codemirror-promql/dist/esm/complete";
|
||||
|
||||
const promqlExtension = new PromQLExtension();
|
||||
|
||||
interface ExpressionInputProps {
|
||||
value: string;
|
||||
onChange: (expr: string) => void;
|
||||
queryHistory: string[];
|
||||
metricNames: string[];
|
||||
executeQuery: () => void;
|
||||
}
|
||||
|
||||
const dynamicConfigCompartment = new Compartment();
|
||||
|
||||
// Autocompletion strategy that wraps the main one and enriches
|
||||
// it with past query items.
|
||||
export class HistoryCompleteStrategy implements CompleteStrategy {
|
||||
private complete: CompleteStrategy;
|
||||
private queryHistory: string[];
|
||||
constructor(complete: CompleteStrategy, queryHistory: string[]) {
|
||||
this.complete = complete;
|
||||
this.queryHistory = queryHistory;
|
||||
}
|
||||
|
||||
promQL(
|
||||
context: CompletionContext
|
||||
): Promise<CompletionResult | null> | CompletionResult | null {
|
||||
return Promise.resolve(this.complete.promQL(context)).then((res) => {
|
||||
const { state, pos } = context;
|
||||
const tree = syntaxTree(state).resolve(pos, -1);
|
||||
const start = res != null ? res.from : tree.from;
|
||||
|
||||
if (start !== 0) {
|
||||
return res;
|
||||
}
|
||||
|
||||
const historyItems: CompletionResult = {
|
||||
from: start,
|
||||
to: pos,
|
||||
options: this.queryHistory.map((q) => ({
|
||||
label: q.length < 80 ? q : q.slice(0, 76).concat("..."),
|
||||
detail: "past query",
|
||||
apply: q,
|
||||
info: q.length < 80 ? undefined : q,
|
||||
})),
|
||||
validFor: /^[a-zA-Z0-9_:]+$/,
|
||||
};
|
||||
|
||||
if (res !== null) {
|
||||
historyItems.options = historyItems.options.concat(res.options);
|
||||
}
|
||||
return historyItems;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const ExpressionInput: FC<ExpressionInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
queryHistory,
|
||||
metricNames,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const [showMetricsExplorer, setShowMetricsExplorer] =
|
||||
useState<boolean>(false);
|
||||
const pathPrefix = usePathPrefix();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const [formatError, setFormatError] = useState<string | null>(null);
|
||||
const [isFormatting, setIsFormatting] = useState<boolean>(false);
|
||||
const [exprFormatted, setExprFormatted] = useState<boolean>(false);
|
||||
|
||||
// (Re)initialize editor based on settings / setting changes.
|
||||
useEffect(() => {
|
||||
// Build the dynamic part of the config.
|
||||
promqlExtension
|
||||
.activateCompletion(enableAutocomplete)
|
||||
.activateLinter(enableLinter)
|
||||
.setComplete({
|
||||
completeStrategy: new HistoryCompleteStrategy(
|
||||
newCompleteStrategy({
|
||||
remote: {
|
||||
url: pathPrefix,
|
||||
cache: { initialMetricList: metricNames },
|
||||
},
|
||||
}),
|
||||
queryHistory
|
||||
),
|
||||
});
|
||||
|
||||
let highlighter = syntaxHighlighting(
|
||||
theme === "dark" ? darkPromqlHighlighter : promqlHighlighter
|
||||
);
|
||||
if (theme === "dark") {
|
||||
highlighter = syntaxHighlighting(darkPromqlHighlighter);
|
||||
}
|
||||
|
||||
const dynamicConfig = [
|
||||
enableHighlighting ? highlighter : [],
|
||||
promqlExtension.asExtension(),
|
||||
theme === "dark" ? darkTheme : lightTheme,
|
||||
];
|
||||
|
||||
// Create or reconfigure the editor.
|
||||
const view = viewRef.current;
|
||||
if (view === null) {
|
||||
// If the editor does not exist yet, create it.
|
||||
if (!containerRef.current) {
|
||||
throw new Error("expected CodeMirror container element to exist");
|
||||
}
|
||||
|
||||
const startState = EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
baseTheme,
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion(),
|
||||
highlightSelectionMatches(),
|
||||
EditorView.lineWrapping,
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
...lintKeymap,
|
||||
]),
|
||||
placeholder("Expression (press Shift+Enter for newlines)"),
|
||||
dynamicConfigCompartment.of(dynamicConfig),
|
||||
// This keymap is added without precedence so that closing the autocomplete dropdown
|
||||
// via Escape works without blurring the editor.
|
||||
keymap.of([
|
||||
{
|
||||
key: "Escape",
|
||||
run: (v: EditorView): boolean => {
|
||||
v.contentDOM.blur();
|
||||
return false;
|
||||
},
|
||||
},
|
||||
]),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Enter",
|
||||
run: (v: EditorView): boolean => {
|
||||
executeQuery();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Shift-Enter",
|
||||
run: insertNewlineAndIndent,
|
||||
},
|
||||
])
|
||||
),
|
||||
EditorView.updateListener.of((update: ViewUpdate): void => {
|
||||
if (update.docChanged) {
|
||||
onExpressionChange(update.state.doc.toString());
|
||||
setExprFormatted(false);
|
||||
}
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const view = new EditorView({
|
||||
state: startState,
|
||||
parent: containerRef.current,
|
||||
});
|
||||
|
||||
viewRef.current = view;
|
||||
|
||||
view.focus();
|
||||
} else {
|
||||
// The editor already exists, just reconfigure the dynamically configured parts.
|
||||
view.dispatch(
|
||||
view.state.update({
|
||||
effects: dynamicConfigCompartment.reconfigure(dynamicConfig),
|
||||
})
|
||||
);
|
||||
}
|
||||
// "value" is only used in the initial render, so we don't want to
|
||||
// re-run this effect every time that "value" changes.
|
||||
//
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
enableAutocomplete,
|
||||
enableHighlighting,
|
||||
enableLinter,
|
||||
executeQuery,
|
||||
onExpressionChange,
|
||||
queryHistory,
|
||||
theme,
|
||||
]);
|
||||
|
||||
const insertAtCursor = (value: string) => {
|
||||
const view = viewRef.current;
|
||||
if (view === null) {
|
||||
return;
|
||||
}
|
||||
const { from, to } = view.state.selection.ranges[0];
|
||||
view.dispatch(
|
||||
view.state.update({
|
||||
changes: { from, to, insert: value },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const formatExpression = () => {
|
||||
setFormatError(null);
|
||||
setIsFormatting(true);
|
||||
|
||||
fetch(
|
||||
`${pathPrefix}/${API_PATH}/format_query?${new URLSearchParams({
|
||||
query: value,
|
||||
})}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
}
|
||||
)
|
||||
.then((resp) => {
|
||||
if (!resp.ok && resp.status !== 400) {
|
||||
throw new Error(`format HTTP request failed: ${resp.statusText}`);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
})
|
||||
.then((json) => {
|
||||
if (json.status !== "success") {
|
||||
throw new Error(json.error || "invalid response JSON");
|
||||
}
|
||||
|
||||
const view = viewRef.current;
|
||||
if (view === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
view.dispatch(
|
||||
view.state.update({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: json.data },
|
||||
})
|
||||
);
|
||||
setExprFormatted(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
setFormatError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFormatting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputGroup className="expression-input">
|
||||
<InputGroupAddon addonType="prepend">
|
||||
<InputGroupText>
|
||||
{loading ? (
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faSearch} />
|
||||
)}
|
||||
</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<div ref={containerRef} className="cm-expression-input" />
|
||||
<InputGroupAddon addonType="append">
|
||||
<Button
|
||||
className="expression-input-action-btn"
|
||||
title={
|
||||
isFormatting
|
||||
? "Formatting expression"
|
||||
: exprFormatted
|
||||
? "Expression formatted"
|
||||
: "Format expression"
|
||||
}
|
||||
onClick={formatExpression}
|
||||
disabled={isFormatting || exprFormatted}
|
||||
>
|
||||
{isFormatting ? (
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
) : exprFormatted ? (
|
||||
<FontAwesomeIcon icon={faCheck} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faIndent} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="expression-input-action-btn"
|
||||
title="Open metrics explorer"
|
||||
onClick={() => setShowMetricsExplorer(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGlobeEurope} />
|
||||
</Button>
|
||||
<Button
|
||||
className="execute-btn"
|
||||
color="primary"
|
||||
onClick={executeQuery}
|
||||
>
|
||||
Execute
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
{formatError && (
|
||||
<Alert color="danger">Error formatting expression: {formatError}</Alert>
|
||||
)}
|
||||
|
||||
<MetricsExplorer
|
||||
show={showMetricsExplorer}
|
||||
updateShow={setShowMetricsExplorer}
|
||||
metrics={metricNames}
|
||||
insertAtCursor={insertAtCursor}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpressionInput;
|
29
web/ui/mantine-ui/src/pages/query/QueryPage.tsx
Normal file
29
web/ui/mantine-ui/src/pages/query/QueryPage.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { Button, Stack } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { useAppDispatch, useAppSelector } from "../../state/hooks";
|
||||
import { addPanel } from "../../state/queryPageSlice";
|
||||
import Panel from "./QueryPanel";
|
||||
|
||||
export default function QueryPage() {
|
||||
const panels = useAppSelector((state) => state.queryPage.panels);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="xl">
|
||||
{panels.map((p, idx) => (
|
||||
<Panel key={p.id} idx={idx} />
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
mt="xl"
|
||||
leftSection={<IconPlus size={18} />}
|
||||
onClick={() => dispatch(addPanel())}
|
||||
>
|
||||
Add query
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
229
web/ui/mantine-ui/src/pages/query/QueryPanel.tsx
Normal file
229
web/ui/mantine-ui/src/pages/query/QueryPanel.tsx
Normal file
|
@ -0,0 +1,229 @@
|
|||
import {
|
||||
Group,
|
||||
Tabs,
|
||||
Center,
|
||||
Space,
|
||||
Box,
|
||||
Input,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconChartAreaFilled,
|
||||
IconChartGridDots,
|
||||
IconChartLine,
|
||||
IconGraph,
|
||||
IconTable,
|
||||
} from "@tabler/icons-react";
|
||||
import { FC, useState } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../state/hooks";
|
||||
import {
|
||||
GraphDisplayMode,
|
||||
setExpr,
|
||||
setVisualizer,
|
||||
} from "../../state/queryPageSlice";
|
||||
import DataTable from "./DataTable";
|
||||
import TimeInput from "./TimeInput";
|
||||
import RangeInput from "./RangeInput";
|
||||
import ExpressionInput from "./ExpressionInput";
|
||||
|
||||
export interface PanelProps {
|
||||
idx: number;
|
||||
}
|
||||
|
||||
// TODO: This is duplicated everywhere, unify it.
|
||||
const iconStyle = { width: "0.9rem", height: "0.9rem" };
|
||||
|
||||
const QueryPanel: FC<PanelProps> = ({ idx }) => {
|
||||
// Used to indicate to the selected display component that it should retrigger
|
||||
// the query, even if the expression has not changed (e.g. when the user presses
|
||||
// the "Execute" button or hits <Enter> again).
|
||||
const [retriggerIdx, setRetriggerIdx] = useState<number>(0);
|
||||
|
||||
const panel = useAppSelector((state) => state.queryPage.panels[idx]);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return (
|
||||
<Stack gap={0} mt="sm">
|
||||
<ExpressionInput
|
||||
initialExpr={panel.expr}
|
||||
executeQuery={(expr: string) => {
|
||||
setRetriggerIdx((idx) => idx + 1);
|
||||
dispatch(setExpr({ idx, expr }));
|
||||
}}
|
||||
/>
|
||||
<Tabs mt="md" defaultValue="table" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="table" leftSection={<IconTable style={iconStyle} />}>
|
||||
Table
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="graph" leftSection={<IconGraph style={iconStyle} />}>
|
||||
Graph
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel p="sm" value="table">
|
||||
<Stack gap="lg" mt="sm">
|
||||
<TimeInput
|
||||
time={panel.visualizer.endTime}
|
||||
range={panel.visualizer.range}
|
||||
description="Evaluation time"
|
||||
onChangeTime={(time) =>
|
||||
dispatch(
|
||||
setVisualizer({
|
||||
idx,
|
||||
visualizer: { ...panel.visualizer, endTime: time },
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DataTable
|
||||
expr={panel.expr}
|
||||
evalTime={panel.visualizer.endTime}
|
||||
retriggerIdx={retriggerIdx}
|
||||
/>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel
|
||||
p="sm"
|
||||
value="graph"
|
||||
// style={{ border: "1px solid lightgrey", borderTop: "none" }}
|
||||
>
|
||||
<Group mt="xs" justify="space-between">
|
||||
<Group>
|
||||
<RangeInput
|
||||
range={panel.visualizer.range}
|
||||
onChangeRange={(range) =>
|
||||
dispatch(
|
||||
setVisualizer({
|
||||
idx,
|
||||
visualizer: { ...panel.visualizer, range },
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TimeInput
|
||||
time={panel.visualizer.endTime}
|
||||
range={panel.visualizer.range}
|
||||
description="End time"
|
||||
onChangeTime={(time) =>
|
||||
dispatch(
|
||||
setVisualizer({
|
||||
idx,
|
||||
visualizer: { ...panel.visualizer, endTime: time },
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Input value="" placeholder="Res. (s)" style={{ width: 80 }} />
|
||||
</Group>
|
||||
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
dispatch(
|
||||
setVisualizer({
|
||||
idx,
|
||||
visualizer: {
|
||||
...panel.visualizer,
|
||||
displayMode: value as GraphDisplayMode,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
value={panel.visualizer.displayMode}
|
||||
data={[
|
||||
{
|
||||
value: GraphDisplayMode.Lines,
|
||||
label: (
|
||||
<Center>
|
||||
<IconChartLine style={iconStyle} />
|
||||
<Box ml={10}>Unstacked</Box>
|
||||
</Center>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: GraphDisplayMode.Stacked,
|
||||
label: (
|
||||
<Center>
|
||||
<IconChartAreaFilled style={iconStyle} />
|
||||
<Box ml={10}>Stacked</Box>
|
||||
</Center>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: GraphDisplayMode.Heatmap,
|
||||
label: (
|
||||
<Center>
|
||||
<IconChartGridDots style={iconStyle} />
|
||||
<Box ml={10}>Heatmap</Box>
|
||||
</Center>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* <Switch color="gray" defaultChecked label="Show exemplars" /> */}
|
||||
{/* <Switch
|
||||
checked={panel.visualizer.showExemplars}
|
||||
onChange={(event) =>
|
||||
dispatch(
|
||||
setVisualizer({
|
||||
idx,
|
||||
visualizer: {
|
||||
...panel.visualizer,
|
||||
showExemplars: event.currentTarget.checked,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
color={"rgba(34,139,230,.1)"}
|
||||
size="md"
|
||||
label="Show exemplars"
|
||||
thumbIcon={
|
||||
panel.visualizer.showExemplars ? (
|
||||
<IconCheck
|
||||
style={{ width: "0.9rem", height: "0.9rem" }}
|
||||
color={"rgba(34,139,230,.1)"}
|
||||
stroke={3}
|
||||
/>
|
||||
) : (
|
||||
<IconX
|
||||
style={{ width: "0.9rem", height: "0.9rem" }}
|
||||
color="rgba(34,139,230,.1)"
|
||||
stroke={3}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/> */}
|
||||
</Group>
|
||||
<Space h="lg" />
|
||||
<Center
|
||||
style={{
|
||||
height: 450,
|
||||
backgroundColor: "#fbfbfb",
|
||||
border: "2px dotted #e7e7e7",
|
||||
fontSize: 20,
|
||||
color: "#999",
|
||||
}}
|
||||
>
|
||||
GRAPH PLACEHOLDER
|
||||
</Center>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
{/* Link button to remove this panel. */}
|
||||
{/* <Group justify="right">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
fw={500}
|
||||
// color="red"
|
||||
onClick={() => dispatch(removePanel(idx))}
|
||||
>
|
||||
Remove query
|
||||
</Button>
|
||||
</Group> */}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryPanel;
|
102
web/ui/mantine-ui/src/pages/query/RangeInput.tsx
Normal file
102
web/ui/mantine-ui/src/pages/query/RangeInput.tsx
Normal file
|
@ -0,0 +1,102 @@
|
|||
import { FC, useState } from "react";
|
||||
import { ActionIcon, Group, Input } from "@mantine/core";
|
||||
import { IconMinus, IconPlus } from "@tabler/icons-react";
|
||||
import { formatDuration, parseDuration } from "../../lib/formatTime";
|
||||
|
||||
interface RangeInputProps {
|
||||
range: number;
|
||||
onChangeRange: (range: number) => void;
|
||||
}
|
||||
|
||||
const iconStyle = { width: "0.9rem", height: "0.9rem" };
|
||||
|
||||
const rangeSteps = [
|
||||
1,
|
||||
10,
|
||||
60,
|
||||
5 * 60,
|
||||
15 * 60,
|
||||
30 * 60,
|
||||
60 * 60,
|
||||
2 * 60 * 60,
|
||||
6 * 60 * 60,
|
||||
12 * 60 * 60,
|
||||
24 * 60 * 60,
|
||||
48 * 60 * 60,
|
||||
7 * 24 * 60 * 60,
|
||||
14 * 24 * 60 * 60,
|
||||
28 * 24 * 60 * 60,
|
||||
56 * 24 * 60 * 60,
|
||||
112 * 24 * 60 * 60,
|
||||
182 * 24 * 60 * 60,
|
||||
365 * 24 * 60 * 60,
|
||||
730 * 24 * 60 * 60,
|
||||
].map((s) => s * 1000);
|
||||
|
||||
const RangeInput: FC<RangeInputProps> = ({ range, onChangeRange }) => {
|
||||
// TODO: Make sure that when "range" changes externally (like via the URL),
|
||||
// the input is updated, either via useEffect() or some better architecture.
|
||||
const [rangeInput, setRangeInput] = useState<string>(formatDuration(range));
|
||||
|
||||
const onChangeRangeInput = (rangeText: string): void => {
|
||||
const newRange = parseDuration(rangeText);
|
||||
if (newRange === null) {
|
||||
setRangeInput(formatDuration(range));
|
||||
} else {
|
||||
onChangeRange(newRange);
|
||||
}
|
||||
};
|
||||
|
||||
const increaseRange = (): void => {
|
||||
for (const step of rangeSteps) {
|
||||
if (range < step) {
|
||||
setRangeInput(formatDuration(step));
|
||||
onChangeRange(step);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const decreaseRange = (): void => {
|
||||
for (const step of rangeSteps.slice().reverse()) {
|
||||
if (range > step) {
|
||||
setRangeInput(formatDuration(step));
|
||||
onChangeRange(step);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group gap={5}>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
aria-label="Decrease range"
|
||||
onClick={decreaseRange}
|
||||
>
|
||||
<IconMinus style={iconStyle} />
|
||||
</ActionIcon>
|
||||
<Input
|
||||
value={rangeInput}
|
||||
onChange={(event) => setRangeInput(event.currentTarget.value)}
|
||||
onBlur={() => onChangeRangeInput(rangeInput)}
|
||||
onKeyDown={(event) =>
|
||||
event.key === "Enter" && onChangeRangeInput(rangeInput)
|
||||
}
|
||||
aria-label="Range"
|
||||
style={{ width: rangeInput.length + 3 + "ch" }}
|
||||
/>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
aria-label="Increase range"
|
||||
onClick={increaseRange}
|
||||
>
|
||||
<IconPlus style={iconStyle} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default RangeInput;
|
19
web/ui/mantine-ui/src/pages/query/SeriesName.module.css
Normal file
19
web/ui/mantine-ui/src/pages/query/SeriesName.module.css
Normal file
|
@ -0,0 +1,19 @@
|
|||
.metricName {
|
||||
}
|
||||
|
||||
.labelPair:hover {
|
||||
--bg-expand: 4px;
|
||||
background-color: #add6ffa0;
|
||||
border-radius: 3px;
|
||||
padding: var(--bg-expand);
|
||||
margin: calc(-1 * var(--bg-expand));
|
||||
color: #495057;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.labelName {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.labelValue {
|
||||
}
|
77
web/ui/mantine-ui/src/pages/query/SeriesName.tsx
Normal file
77
web/ui/mantine-ui/src/pages/query/SeriesName.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import React, { FC } from "react";
|
||||
// import { useToastContext } from "../../contexts/ToastContext";
|
||||
import { formatSeries } from "../../lib/formatSeries";
|
||||
import classes from "./SeriesName.module.css";
|
||||
import { escapeString } from "../../lib/escapeString";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
interface SeriesNameProps {
|
||||
labels: { [key: string]: string } | null;
|
||||
format: boolean;
|
||||
}
|
||||
|
||||
const SeriesName: FC<SeriesNameProps> = ({ labels, format }) => {
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const renderFormatted = (): React.ReactElement => {
|
||||
const labelNodes: React.ReactElement[] = [];
|
||||
let first = true;
|
||||
for (const label in labels) {
|
||||
if (label === "__name__") {
|
||||
continue;
|
||||
}
|
||||
|
||||
labelNodes.push(
|
||||
<span key={label}>
|
||||
{!first && ", "}
|
||||
<span
|
||||
className={classes.labelPair}
|
||||
onClick={(e) => {
|
||||
const text = e.currentTarget.innerText;
|
||||
clipboard.copy(text);
|
||||
notifications.show({
|
||||
title: "Copied matcher!",
|
||||
message: `Label matcher ${text} copied to clipboard`,
|
||||
});
|
||||
}}
|
||||
title="Click to copy label matcher"
|
||||
>
|
||||
<span className={classes.labelName}>{label}</span>=
|
||||
<span className={classes.labelValue}>
|
||||
"{escapeString(labels[label])}"
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (first) {
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className={classes.metricName}>
|
||||
{labels ? labels.__name__ : ""}
|
||||
</span>
|
||||
{"{"}
|
||||
{labelNodes}
|
||||
{"}"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (labels === null) {
|
||||
return <>scalar</>;
|
||||
}
|
||||
|
||||
if (format) {
|
||||
return renderFormatted();
|
||||
}
|
||||
// Return a simple text node. This is much faster to scroll through
|
||||
// for longer lists (hundreds of items).
|
||||
return <>{formatSeries(labels)}</>;
|
||||
};
|
||||
|
||||
export default SeriesName;
|
64
web/ui/mantine-ui/src/pages/query/TimeInput.tsx
Normal file
64
web/ui/mantine-ui/src/pages/query/TimeInput.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { Group, ActionIcon } from "@mantine/core";
|
||||
import { DatesProvider, DateTimePicker } from "@mantine/dates";
|
||||
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
|
||||
interface TimeInputProps {
|
||||
time: number | null; // Timestamp in milliseconds.
|
||||
range: number; // Range in seconds.
|
||||
description: string;
|
||||
onChangeTime: (time: number | null) => void;
|
||||
}
|
||||
|
||||
const iconStyle = { width: "0.9rem", height: "0.9rem" };
|
||||
|
||||
const TimeInput: FC<TimeInputProps> = ({
|
||||
time,
|
||||
range,
|
||||
description,
|
||||
onChangeTime,
|
||||
}) => {
|
||||
const baseTime = () => (time !== null ? time : Date.now().valueOf());
|
||||
|
||||
return (
|
||||
<Group gap={5}>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
title="Decrease time"
|
||||
aria-label="Decrease time"
|
||||
onClick={() => onChangeTime(baseTime() - range / 2)}
|
||||
>
|
||||
<IconChevronLeft style={iconStyle} />
|
||||
</ActionIcon>
|
||||
<DatesProvider settings={{ timezone: "UTC" }}>
|
||||
<DateTimePicker
|
||||
w={180}
|
||||
valueFormat="YYYY-MM-DD HH:mm:ss"
|
||||
withSeconds
|
||||
clearable
|
||||
value={time !== null ? new Date(time) : undefined}
|
||||
onChange={(value) => onChangeTime(value ? value.getTime() : null)}
|
||||
aria-label={description}
|
||||
placeholder={description}
|
||||
onClick={() => {
|
||||
if (time === null) {
|
||||
onChangeTime(baseTime());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DatesProvider>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
title="Increase time"
|
||||
aria-label="Increase time"
|
||||
onClick={() => onChangeTime(baseTime() + range / 2)}
|
||||
>
|
||||
<IconChevronRight style={iconStyle} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeInput;
|
|
@ -1,3 +0,0 @@
|
|||
export default function ServiceDiscovery() {
|
||||
return <>ServiceDiscovery page</>;
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export default function Targets() {
|
||||
return <>Targets page</>;
|
||||
}
|
49
web/ui/mantine-ui/src/state/api.ts
Normal file
49
web/ui/mantine-ui/src/state/api.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
|
||||
import { ErrorAPIResponse, SuccessAPIResponse } from "../api/api";
|
||||
import { InstantQueryResult } from "../api/responseTypes/query";
|
||||
|
||||
// Define a service using a base URL and expected endpoints
|
||||
export const prometheusApi = createApi({
|
||||
reducerPath: "prometheusApi",
|
||||
baseQuery: fetchBaseQuery({ baseUrl: "/api/v1/" }),
|
||||
keepUnusedDataFor: 0, // Turn off caching.
|
||||
endpoints: (builder) => ({
|
||||
instantQuery: builder.query<
|
||||
SuccessAPIResponse<InstantQueryResult>,
|
||||
{ query: string; time: number }
|
||||
>({
|
||||
query: ({ query, time }) => {
|
||||
return {
|
||||
url: `query`,
|
||||
params: {
|
||||
query,
|
||||
time,
|
||||
},
|
||||
};
|
||||
//`query?query=${encodeURIComponent(query)}&time=${time}`,
|
||||
},
|
||||
transformErrorResponse: (error): string => {
|
||||
if (!error.data) {
|
||||
return "Failed to fetch data";
|
||||
}
|
||||
|
||||
return (error.data as ErrorAPIResponse).error;
|
||||
},
|
||||
// transformResponse: (
|
||||
// response: APIResponse<InstantQueryResult>
|
||||
// ): SuccessAPIResponse<InstantQueryResult> => {
|
||||
// if (!response.status) {
|
||||
// throw new Error("Invalid response");
|
||||
// }
|
||||
// if (response.status === "error") {
|
||||
// throw new Error(response.error);
|
||||
// }
|
||||
// return response;
|
||||
// },
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// Export hooks for usage in functional components, which are
|
||||
// auto-generated based on the defined endpoints
|
||||
export const { useInstantQueryQuery, useLazyInstantQueryQuery } = prometheusApi;
|
6
web/ui/mantine-ui/src/state/hooks.ts
Normal file
6
web/ui/mantine-ui/src/state/hooks.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { useDispatch, useSelector } from "react-redux";
|
||||
import type { RootState, AppDispatch } from "./store";
|
||||
|
||||
// Use throughout your app instead of plain `useDispatch` and `useSelector`
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
||||
export const useAppSelector = useSelector.withTypes<RootState>();
|
83
web/ui/mantine-ui/src/state/queryPageSlice.ts
Normal file
83
web/ui/mantine-ui/src/state/queryPageSlice.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { randomId } from "@mantine/hooks";
|
||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||
|
||||
export enum GraphDisplayMode {
|
||||
Lines = "lines",
|
||||
Stacked = "stacked",
|
||||
Heatmap = "heatmap",
|
||||
}
|
||||
|
||||
// NOTE: This is not represented as a discriminated union type
|
||||
// because we want to preserve and partially share settings while
|
||||
// switching between display modes.
|
||||
export interface Visualizer {
|
||||
activeTab: "table" | "graph" | "explain";
|
||||
endTime: number | null; // Timestamp in milliseconds.
|
||||
range: number; // Range in seconds.
|
||||
resolution: number | null; // Resolution step in seconds.
|
||||
displayMode: GraphDisplayMode;
|
||||
showExemplars: boolean;
|
||||
}
|
||||
|
||||
export type Panel = {
|
||||
// The id is helpful as a stable key for React.
|
||||
id: string;
|
||||
expr: string;
|
||||
exprStale: boolean;
|
||||
showMetricsExplorer: boolean;
|
||||
visualizer: Visualizer;
|
||||
};
|
||||
|
||||
interface QueryPageState {
|
||||
panels: Panel[];
|
||||
}
|
||||
|
||||
const newDefaultPanel = (): Panel => ({
|
||||
id: randomId(),
|
||||
expr: "",
|
||||
exprStale: false,
|
||||
showMetricsExplorer: false,
|
||||
visualizer: {
|
||||
activeTab: "table",
|
||||
endTime: null,
|
||||
// endTime: 1709414194000,
|
||||
range: 3600 * 1000,
|
||||
resolution: null,
|
||||
displayMode: GraphDisplayMode.Lines,
|
||||
showExemplars: false,
|
||||
},
|
||||
});
|
||||
|
||||
const initialState: QueryPageState = {
|
||||
panels: [newDefaultPanel()],
|
||||
};
|
||||
|
||||
export const queryPageSlice = createSlice({
|
||||
name: "queryPage",
|
||||
initialState,
|
||||
reducers: {
|
||||
addPanel: (state) => {
|
||||
state.panels.push(newDefaultPanel());
|
||||
},
|
||||
removePanel: (state, { payload }: PayloadAction<number>) => {
|
||||
state.panels.splice(payload, 1);
|
||||
},
|
||||
setExpr: (
|
||||
state,
|
||||
{ payload }: PayloadAction<{ idx: number; expr: string }>
|
||||
) => {
|
||||
state.panels[payload.idx].expr = payload.expr;
|
||||
},
|
||||
setVisualizer: (
|
||||
state,
|
||||
{ payload }: PayloadAction<{ idx: number; visualizer: Visualizer }>
|
||||
) => {
|
||||
state.panels[payload.idx].visualizer = payload.visualizer;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { addPanel, removePanel, setExpr, setVisualizer } =
|
||||
queryPageSlice.actions;
|
||||
|
||||
export default queryPageSlice.reducer;
|
19
web/ui/mantine-ui/src/state/store.ts
Normal file
19
web/ui/mantine-ui/src/state/store.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import queryPageSlice from "./queryPageSlice";
|
||||
import { prometheusApi } from "./api";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
queryPage: queryPageSlice,
|
||||
[prometheusApi.reducerPath]: prometheusApi.reducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat(prometheusApi.middleware),
|
||||
});
|
||||
|
||||
// Infer the `RootState` and `AppDispatch` types from the store itself
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
export default store;
|
|
@ -1,67 +0,0 @@
|
|||
import {
|
||||
useMantineColorScheme,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
rem,
|
||||
MantineColorScheme,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconMoonFilled,
|
||||
IconSunFilled,
|
||||
IconUserFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
|
||||
export const ThemeSelector: FC = () => {
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const iconProps = {
|
||||
style: { width: rem(20), height: rem(20), display: "block" },
|
||||
stroke: 1.5,
|
||||
};
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<SegmentedControl
|
||||
color="gray.7"
|
||||
size="xs"
|
||||
// styles={{ root: { backgroundColor: "var(--mantine-color-gray-7)" } }}
|
||||
styles={{
|
||||
root: {
|
||||
padding: 3,
|
||||
backgroundColor: "var(--mantine-color-gray-6)",
|
||||
},
|
||||
}}
|
||||
withItemsBorders={false}
|
||||
value={colorScheme}
|
||||
onChange={(v) => setColorScheme(v as MantineColorScheme)}
|
||||
data={[
|
||||
{
|
||||
value: "light",
|
||||
label: (
|
||||
<Tooltip label="Use light theme" offset={15}>
|
||||
<IconSunFilled {...iconProps} />
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: (
|
||||
<Tooltip label="Use dark theme" offset={15}>
|
||||
<IconMoonFilled {...iconProps} />
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
label: (
|
||||
<Tooltip label="Use browser-preferred theme" offset={15}>
|
||||
<IconUserFilled {...iconProps} />
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
331
web/ui/package-lock.json
generated
331
web/ui/package-lock.json
generated
|
@ -115,17 +115,20 @@
|
|||
"@codemirror/view": "^6.24.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@mantine/code-highlight": "^7.5.3",
|
||||
"@mantine/core": "^7.5.3",
|
||||
"@mantine/dates": "^7.5.3",
|
||||
"@mantine/hooks": "^7.5.3",
|
||||
"@mantine/code-highlight": "^7.6.1",
|
||||
"@mantine/core": "^7.6.1",
|
||||
"@mantine/dates": "^7.6.1",
|
||||
"@mantine/hooks": "^7.6.1",
|
||||
"@mantine/notifications": "^7.6.1",
|
||||
"@prometheus-io/codemirror-promql": "^0.50.0-rc.1",
|
||||
"@reduxjs/toolkit": "^2.2.1",
|
||||
"@tabler/icons-react": "^2.47.0",
|
||||
"@tanstack/react-query": "^5.22.2",
|
||||
"@uiw/react-codemirror": "^4.21.22",
|
||||
"dayjs": "^1.11.10",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^9.1.0",
|
||||
"react-router-dom": "^6.22.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -143,6 +146,91 @@
|
|||
"vite": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"mantine-ui/node_modules/@floating-ui/react": {
|
||||
"version": "0.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.9.tgz",
|
||||
"integrity": "sha512-p86wynZJVEkEq2BBjY/8p2g3biQ6TlgT4o/3KgFKyTWoJLU1GZ8wpctwRqtkEl2tseYA+kw7dBAIDFcednfI5w==",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.8",
|
||||
"@floating-ui/utils": "^0.2.1",
|
||||
"tabbable": "^6.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"mantine-ui/node_modules/@mantine/code-highlight": {
|
||||
"version": "7.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/code-highlight/-/code-highlight-7.6.1.tgz",
|
||||
"integrity": "sha512-FDgbDQzlB+ldJzkWEscCtNyE5hqE1DgBjlrw3EeOhdK0FRijzXrUpABjnmZZPuoMAbxBaBPaguL4VwrHCzEOmg==",
|
||||
"dependencies": {
|
||||
"clsx": "2.1.0",
|
||||
"highlight.js": "^11.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "7.6.1",
|
||||
"@mantine/hooks": "7.6.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"mantine-ui/node_modules/@mantine/core": {
|
||||
"version": "7.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.6.1.tgz",
|
||||
"integrity": "sha512-52BgYXAMD+E6vDiGIGOJlLBc0pdT2+gzrB0g+v7c7xeiNXqHEG5cEplLErfNBHh9kMQHiDHCiCb5Su9jqoUlXw==",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.9",
|
||||
"clsx": "2.1.0",
|
||||
"react-number-format": "^5.3.1",
|
||||
"react-remove-scroll": "^2.5.7",
|
||||
"react-textarea-autosize": "8.5.3",
|
||||
"type-fest": "^3.13.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/hooks": "7.6.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"mantine-ui/node_modules/@mantine/dates": {
|
||||
"version": "7.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-7.6.1.tgz",
|
||||
"integrity": "sha512-xHe5sINtFuqptmZCXfp0aeurC8wjiycBzHvk87CqfhLIGWBTSAkrCKk3KzdUeEKfVsLY1l21cFb7Sv7mr4lfTw==",
|
||||
"dependencies": {
|
||||
"clsx": "2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "7.6.1",
|
||||
"@mantine/hooks": "7.6.1",
|
||||
"dayjs": ">=1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"mantine-ui/node_modules/@mantine/hooks": {
|
||||
"version": "7.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.6.1.tgz",
|
||||
"integrity": "sha512-zsOGzFRcQZuER2rzAjfrAqp98W7WCFA43nF1QZUKV7AHTq8q1mtr3DOhFfO3/CA+t1lai68gp1guVcIhP4lrwQ==",
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"mantine-ui/node_modules/@mantine/notifications": {
|
||||
"version": "7.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-7.6.1.tgz",
|
||||
"integrity": "sha512-Aiui/faUBVQVgDPW9poCe8WdRZkXmIe9aFTnmf+WTopMWK/zfLBp02IjLY1f59zs5NeF/vfXaMxiuQq+KH2hTQ==",
|
||||
"dependencies": {
|
||||
"@mantine/store": "7.6.1",
|
||||
"react-transition-group": "4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "7.6.1",
|
||||
"@mantine/hooks": "7.6.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"mantine-ui/node_modules/@prometheus-io/codemirror-promql": {
|
||||
"version": "0.50.0-rc.1",
|
||||
"resolved": "https://registry.npmjs.org/@prometheus-io/codemirror-promql/-/codemirror-promql-0.50.0-rc.1.tgz",
|
||||
|
@ -172,6 +260,14 @@
|
|||
"@lezer/lr": "^1.2.3"
|
||||
}
|
||||
},
|
||||
"mantine-ui/node_modules/clsx": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz",
|
||||
"integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"mantine-ui/node_modules/lru-cache": {
|
||||
"version": "7.18.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
|
||||
|
@ -180,6 +276,17 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"mantine-ui/node_modules/type-fest": {
|
||||
"version": "3.13.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
|
||||
"integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"module/codemirror-promql": {
|
||||
"name": "@prometheus-io/codemirror-promql",
|
||||
"version": "0.49.1",
|
||||
|
@ -1527,20 +1634,6 @@
|
|||
"@floating-ui/utils": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.24.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.24.8.tgz",
|
||||
"integrity": "sha512-AuYeDoaR8jtUlUXtZ1IJ/6jtBkGnSpJXbGNzokBL87VDJ8opMq1Bgrc0szhK482ReQY6KZsMoZCVSb4xwalkBA==",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.1",
|
||||
"aria-hidden": "^1.2.3",
|
||||
"tabbable": "^6.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz",
|
||||
|
@ -2101,69 +2194,10 @@
|
|||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/code-highlight": {
|
||||
"version": "7.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/code-highlight/-/code-highlight-7.5.3.tgz",
|
||||
"integrity": "sha512-TLZSkVAfX3KH9XKjJl965KX6TjpMKtNzObjI6Uvo/J/5Rvqhe7xbhBPJDT7yhSD+wjnTMsEWEb68rmQa3M/cEA==",
|
||||
"dependencies": {
|
||||
"clsx": "2.0.0",
|
||||
"highlight.js": "^11.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "7.5.3",
|
||||
"@mantine/hooks": "7.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/core": {
|
||||
"version": "7.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.5.3.tgz",
|
||||
"integrity": "sha512-Wvv6DJXI+GX9mmKG5HITTh/24sCZ0RoYQHdTHh0tOfGnEy+RleyhA82UjnMsp0n2NjfCISBwbiKgfya6b2iaFw==",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.24.8",
|
||||
"clsx": "2.0.0",
|
||||
"react-number-format": "^5.3.1",
|
||||
"react-remove-scroll": "^2.5.7",
|
||||
"react-textarea-autosize": "8.5.3",
|
||||
"type-fest": "^3.13.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/hooks": "7.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/core/node_modules/type-fest": {
|
||||
"version": "3.13.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
|
||||
"integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/dates": {
|
||||
"version": "7.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-7.5.3.tgz",
|
||||
"integrity": "sha512-v6fFdW+7HAd7XsZFMJVMuFE2RHbQAVnsUNeP0/5h+H4qEj0soTmMvHPP8wXEed5v85r9CcEMGOGq1n6RFRpWHA==",
|
||||
"dependencies": {
|
||||
"clsx": "2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "7.5.3",
|
||||
"@mantine/hooks": "7.5.3",
|
||||
"dayjs": ">=1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/hooks": {
|
||||
"version": "7.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.5.3.tgz",
|
||||
"integrity": "sha512-mFI448mAs12v8FrgSVhytqlhTVrEjIfd/PqPEfwJu5YcZIq4YZdqpzJIUbANnRrFSvmoQpDb1PssdKx7Ds35hw==",
|
||||
"node_modules/@mantine/store": {
|
||||
"version": "7.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/store/-/store-7.6.1.tgz",
|
||||
"integrity": "sha512-UqSsJLlAL53OSSUNK/aTXpkss9DX0TppTbtBKXPyflYfq0B9vKwQKumxEsg3UGVC4cjiQq2VD4mjGT94r+deug==",
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0"
|
||||
}
|
||||
|
@ -2215,6 +2249,29 @@
|
|||
"resolved": "mantine-ui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.1.tgz",
|
||||
"integrity": "sha512-8CREoqJovQW/5I4yvvijm/emUiCCmcs4Ev4XPWd4mizSO+dD3g5G6w34QK5AGeNrSH7qM8Fl66j4vuV7dpOdkw==",
|
||||
"dependencies": {
|
||||
"immer": "^10.0.3",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.1.tgz",
|
||||
|
@ -2670,6 +2727,11 @@
|
|||
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
|
||||
"integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA=="
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.32",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz",
|
||||
|
@ -3061,17 +3123,6 @@
|
|||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz",
|
||||
"integrity": "sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/array-union": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
|
||||
|
@ -3415,14 +3466,6 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz",
|
||||
"integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/co": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||
|
@ -3541,8 +3584,7 @@
|
|||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"devOptional": true
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.10",
|
||||
|
@ -3644,6 +3686,15 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.4.678",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.678.tgz",
|
||||
|
@ -4354,6 +4405,15 @@
|
|||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.0.3.tgz",
|
||||
"integrity": "sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||
|
@ -6107,6 +6167,32 @@
|
|||
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.1.0.tgz",
|
||||
"integrity": "sha512-6qoDzIO+gbrza8h3hjMA9aq4nwVFCKFtY2iLxCtVT38Swyy2C/dJCGBXHeHLtx6qlg/8qzc2MrhOeduf5K32wQ==",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.3",
|
||||
"use-sync-external-store": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25",
|
||||
"react": "^18.0",
|
||||
"react-native": ">=0.69",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz",
|
||||
|
@ -6229,6 +6315,34 @@
|
|||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
|
@ -6244,6 +6358,11 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.0.tgz",
|
||||
"integrity": "sha512-aw7jcGLDpSgNDyWBQLv2cedml85qd95/iszJjN988zX1t7AVRJi19d9kto5+W7oCfQ94gyo40dVbT6g2k4/kXg=="
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.8",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
|
||||
|
@ -6940,6 +7059,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
|
||||
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
|
Loading…
Reference in a new issue