Lots of more progress on the new Mantine UI

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2024-03-07 13:16:54 +01:00
parent 89ecb3a3f2
commit 2bb14c5787
46 changed files with 2090 additions and 3048 deletions

File diff suppressed because it is too large Load diff

View file

@ -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": {

View file

@ -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} />
))}

View file

@ -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>

View file

@ -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 {

View 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>
),
},
]}
/>
);
};

View file

@ -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>>)
);
},
});

View 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;
};

View file

@ -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",

View file

@ -0,0 +1,3 @@
export const escapeString = (str: string) => {
return str.replace(/([\\"])/g, "\\$1");
};

View 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(", ")}}`;
};

View file

@ -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>
);

View file

@ -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;

View file

@ -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>

View file

@ -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"
/>
);
}

View file

@ -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"

View file

@ -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 (

View file

@ -0,0 +1,3 @@
export default function ServiceDiscoveryPage() {
return <>ServiceDiscovery page</>;
}

View file

@ -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} />

View file

@ -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",

View file

@ -0,0 +1,3 @@
export default function TargetsPage() {
return <>Targets page</>;
}

View file

@ -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>
);
}

View 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;

View 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;
}
}

View 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;

View 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;

View 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>
</>
);
}

View 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;

View 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;

View 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 {
}

View 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;

View 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;

View file

@ -1,3 +0,0 @@
export default function ServiceDiscovery() {
return <>ServiceDiscovery page</>;
}

View file

@ -1,3 +0,0 @@
export default function Targets() {
return <>Targets page</>;
}

View 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;

View 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>();

View 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;

View 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;

View file

@ -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
View file

@ -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",