add julius work

Signed-off-by: Augustin Husson <husson.augustin@gmail.com>
This commit is contained in:
Augustin Husson 2024-02-21 12:16:45 +01:00
parent 34d5c8dfaf
commit c627cde8bf
28 changed files with 1922 additions and 54 deletions

View file

@ -24,9 +24,12 @@
"@mantine/dates": "^7.5.3",
"@mantine/hooks": "^7.5.3",
"@prometheus-io/codemirror-promql": "0.49.1",
"@tabler/icons-react": "^2.47.0",
"@tanstack/react-query": "^5.22.2",
"@uiw/react-codemirror": "^4.21.22",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.1"
},
"devDependencies": {
"@types/react": "^18.2.55",

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="Layer_1"
x="0px"
y="0px"
width="115.333px"
height="114px"
viewBox="0 0 115.333 114"
enable-background="new 0 0 115.333 114"
xml:space="preserve"
sodipodi:docname="prometheus_logo_orange.svg"
inkscape:version="0.92.1 r15371"><metadata
id="metadata4495"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs4493" /><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1484"
inkscape:window-height="886"
id="namedview4491"
showgrid="false"
inkscape:zoom="5.2784901"
inkscape:cx="60.603667"
inkscape:cy="60.329656"
inkscape:window-x="54"
inkscape:window-y="7"
inkscape:window-maximized="0"
inkscape:current-layer="Layer_1" /><g
id="Layer_2" /><path
style="fill:#e6522c;fill-opacity:1"
inkscape:connector-curvature="0"
id="path4486"
d="M 56.667,0.667 C 25.372,0.667 0,26.036 0,57.332 c 0,31.295 25.372,56.666 56.667,56.666 31.295,0 56.666,-25.371 56.666,-56.666 0,-31.296 -25.372,-56.665 -56.666,-56.665 z m 0,106.055 c -8.904,0 -16.123,-5.948 -16.123,-13.283 H 72.79 c 0,7.334 -7.219,13.283 -16.123,13.283 z M 83.297,89.04 H 30.034 V 79.382 H 83.298 V 89.04 Z M 83.106,74.411 H 30.186 C 30.01,74.208 29.83,74.008 29.66,73.802 24.208,67.182 22.924,63.726 21.677,60.204 c -0.021,-0.116 6.611,1.355 11.314,2.413 0,0 2.42,0.56 5.958,1.205 -3.397,-3.982 -5.414,-9.044 -5.414,-14.218 0,-11.359 8.712,-21.285 5.569,-29.308 3.059,0.249 6.331,6.456 6.552,16.161 3.252,-4.494 4.613,-12.701 4.613,-17.733 0,-5.21 3.433,-11.262 6.867,-11.469 -3.061,5.045 0.793,9.37 4.219,20.099 1.285,4.03 1.121,10.812 2.113,15.113 C 63.797,33.534 65.333,20.5 71,16 c -2.5,5.667 0.37,12.758 2.333,16.167 3.167,5.5 5.087,9.667 5.087,17.548 0,5.284 -1.951,10.259 -5.242,14.148 3.742,-0.702 6.326,-1.335 6.326,-1.335 l 12.152,-2.371 c 10e-4,-10e-4 -1.765,7.261 -8.55,14.254 z" /></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -1,42 +1,32 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.control {
display: block;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
font-weight: 500;
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
@mixin hover {
background-color: var(--mantine-color-gray-8);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
.link {
display: block;
line-height: 1;
padding: rem(8px) rem(12px);
border-radius: var(--mantine-radius-sm);
text-decoration: none;
color: var(--mantine-color-gray-0);
font-size: var(--mantine-font-size-sm);
font-weight: 500;
background-color: transparent;
@mixin hover {
background-color: var(--mantine-color-gray-6);
color: var(--mantine-color-gray-0);
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
[data-mantine-color-scheme] &[aria-current="page"] {
background-color: var(--mantine-color-blue-filled);
color: var(--mantine-color-white);
}
}

View file

@ -0,0 +1,32 @@
.control {
display: block;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
font-weight: 500;
@mixin hover {
background-color: var(--mantine-color-gray-8);
}
}
.link {
display: block;
line-height: 1;
padding: rem(8px) rem(12px);
border-radius: var(--mantine-radius-sm);
text-decoration: none;
color: var(--mantine-color-gray-0);
font-size: var(--mantine-font-size-sm);
font-weight: 500;
background-color: transparent;
@mixin hover {
background-color: var(--mantine-color-gray-6);
color: var(--mantine-color-gray-0);
}
[data-mantine-color-scheme] &[aria-current="page"] {
background-color: var(--mantine-color-blue-filled);
color: var(--mantine-color-white);
}
}

View file

@ -1,20 +1,305 @@
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import { PromQLExtension } from "@prometheus-io/codemirror-promql";
import "@mantine/core/styles.css";
import "@mantine/code-highlight/styles.css";
import classes from "./App.module.css";
import PrometheusLogo from "./images/prometheus-logo.svg";
import {
AppShell,
Burger,
Button,
Group,
MantineProvider,
Menu,
Skeleton,
Text,
createTheme,
rem,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconBellFilled,
IconChartAreaFilled,
IconChevronDown,
IconChevronRight,
IconCloudDataConnection,
IconDatabase,
IconFileAnalytics,
IconFlag,
IconHeartRateMonitor,
IconHelp,
IconInfoCircle,
IconServerCog,
} from "@tabler/icons-react";
import {
BrowserRouter,
NavLink,
Navigate,
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 { Suspense, useContext } from "react";
import ErrorBoundary from "./error-boundary";
import { ThemeSelector } from "./theme-selector";
import { SettingsContext } from "./settings";
import Agent from "./pages/agent";
const queryClient = new QueryClient();
const monitoringStatusPages = [
{
title: "Targets",
path: "/targets",
icon: <IconHeartRateMonitor style={{ width: rem(14), height: rem(14) }} />,
element: <Targets />,
},
{
title: "Rules",
path: "/rules",
icon: <IconTable style={{ width: rem(14), height: rem(14) }} />,
element: <Rules />,
},
{
title: "Service discovery",
path: "/service-discovery",
icon: (
<IconCloudDataConnection style={{ width: rem(14), height: rem(14) }} />
),
element: <ServiceDiscovery />,
},
];
const serverStatusPages = [
{
title: "Runtime & build information",
path: "/status",
icon: <IconInfoCircle style={{ width: rem(14), height: rem(14) }} />,
element: <Status />,
},
{
title: "TSDB status",
path: "/tsdb-status",
icon: <IconDatabase style={{ width: rem(14), height: rem(14) }} />,
element: <TSDBStatus />,
},
{
title: "Command-line flags",
path: "/flags",
icon: <IconFlag style={{ width: rem(14), height: rem(14) }} />,
element: <Flags />,
},
{
title: "Configuration",
path: "/config",
icon: <IconServerCog style={{ width: rem(14), height: rem(14) }} />,
element: <Config />,
},
];
const allStatusPages = [...monitoringStatusPages, ...serverStatusPages];
const theme = createTheme({
colors: {
"codebox-bg": [
"#f5f5f5",
"#e7e7e7",
"#cdcdcd",
"#b2b2b2",
"#9a9a9a",
"#8b8b8b",
"#848484",
"#717171",
"#656565",
"#575757",
],
},
});
const promqlExtension = new PromQLExtension();
function App() {
const [opened, { toggle }] = useDisclosure();
const { agentMode } = useContext(SettingsContext);
return (
<CodeMirror
basicSetup={false}
value="rate(foo)"
editable={false}
extensions={[
promqlExtension.asExtension(),
EditorView.lineWrapping,
]}
/>
)
const navLinks = (
<>
<Button
component={NavLink}
to="/graph"
className={classes.link}
leftSection={<IconChartAreaFilled size={16} />}
>
Graph
</Button>
<Button
component={NavLink}
to="/alerts"
className={classes.link}
leftSection={<IconBellFilled size={16} />}
>
Alerts
</Button>
<Menu shadow="md" width={230}>
<Routes>
{allStatusPages.map((p) => (
<Route
key={p.path}
path={p.path}
element={
<Menu.Target>
<Button
component={NavLink}
to={p.path}
className={classes.link}
leftSection={p.icon}
rightSection={<IconChevronDown size={16} />}
>
Status <IconChevronRight size={16} /> {p.title}
</Button>
</Menu.Target>
}
/>
))}
<Route
path="*"
element={
<Menu.Target>
<Button
component={NavLink}
to="/"
className={classes.link}
leftSection={<IconFileAnalytics size={16} />}
rightSection={<IconChevronDown size={16} />}
onClick={(e) => {
e.preventDefault();
}}
>
Status
</Button>
</Menu.Target>
}
/>
</Routes>
<Menu.Dropdown>
<Menu.Label>Monitoring status</Menu.Label>
{monitoringStatusPages.map((p) => (
<Menu.Item
key={p.path}
component={NavLink}
to={p.path}
leftSection={p.icon}
>
{p.title}
</Menu.Item>
))}
<Menu.Divider />
<Menu.Label>Server status</Menu.Label>
{serverStatusPages.map((p) => (
<Menu.Item
key={p.path}
component={NavLink}
to={p.path}
leftSection={p.icon}
>
{p.title}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
<Button
component="a"
href="https://prometheus.io/docs/prometheus/latest/getting_started/"
className={classes.link}
leftSection={<IconHelp size={16} />}
target="_blank"
>
Help
</Button>
</>
);
return (
<BrowserRouter>
<MantineProvider defaultColorScheme="auto" theme={theme}>
<QueryClientProvider client={queryClient}>
<AppShell
header={{ height: 56 }}
navbar={{
width: 300,
breakpoint: "sm",
collapsed: { desktop: true, mobile: !opened },
}}
padding="md"
>
<AppShell.Header bg="rgb(65, 73, 81)" c="#fff">
<Group h="100%" px="md">
<Group style={{ flex: 1 }}>
<Group gap={10}>
<img src={PrometheusLogo} height={30} />
<Text fz={20}>Prometheus{agentMode && " Agent"}</Text>
</Group>
<Group ml="lg" gap={12} visibleFrom="sm">
{navLinks}
</Group>
</Group>
<Burger
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="sm"
color="gray.2"
/>
{<ThemeSelector />}
</Group>
</AppShell.Header>
<AppShell.Navbar py="md" px={4} bg="rgb(65, 73, 81)" c="#fff">
{navLinks}
</AppShell.Navbar>
<AppShell.Main>
<ErrorBoundary key={location.pathname}>
<Suspense
fallback={Array.from(Array(10), (_, i) => (
<Skeleton key={i} height={40} mb={15} width={1000} />
))}
>
<Routes>
<Route
path="/"
element={
<Navigate to={agentMode ? "/agent" : "/graph"} />
}
/>
<Route path="/graph" element={<Graph />} />
<Route path="/agent" element={<Agent />} />
<Route path="/alerts" element={<Alerts />} />
{allStatusPages.map((p) => (
<Route key={p.path} path={p.path} element={p.element} />
))}
</Routes>
</Suspense>
</ErrorBoundary>
</AppShell.Main>
</AppShell>
{/* <ReactQueryDevtools initialIsOpen={false} /> */}
</QueryClientProvider>
</MantineProvider>
</BrowserRouter>
);
}
export default App
export default App;

32
web/ui/app/src/api/api.ts Normal file
View file

@ -0,0 +1,32 @@
import { useSuspenseQuery } from "@tanstack/react-query";
export const API_PATH = "api/v1";
export type APIResponse<T> = { status: string; data: T };
export const useSuspenseAPIQuery = <T>(path: string) =>
useSuspenseQuery<{ data: 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;
})
.then((res) => res.json() as Promise<APIResponse<T>>),
});

View file

@ -0,0 +1,51 @@
type RuleState = "pending" | "firing" | "inactive";
export interface Alert {
labels: Record<string, string>;
state: RuleState;
value: string;
annotations: Record<string, string>;
activeAt: string;
keepFiringSince: string;
}
type CommonRuleFields = {
name: string;
query: string;
evaluationTime: string;
health: string;
lastError?: string;
lastEvaluation: string;
};
type AlertingRule = {
type: "alerting";
// For alerting rules, the 'labels' field is always present, even when there are no labels.
labels: Record<string, string>;
annotations: Record<string, string>;
duration: number;
keepFiringFor: number;
state: RuleState;
alerts: Alert[];
} & CommonRuleFields;
type RecordingRule = {
type: "recording";
// For recording rules, the 'labels' field is only present when there are labels.
labels?: Record<string, string>;
} & CommonRuleFields;
export type Rule = AlertingRule | RecordingRule;
interface RuleGroup {
name: string;
file: string;
interval: string;
rules: Rule[];
evaluationTime: string;
lastEvaluation: string;
}
export interface RulesMap {
groups: RuleGroup[];
}

View file

@ -0,0 +1,20 @@
interface Stats {
name: string;
value: number;
}
interface HeadStats {
numSeries: number;
numLabelPairs: number;
chunkCount: number;
minTime: number;
maxTime: number;
}
export interface TSDBMap {
headStats: HeadStats;
seriesCountByMetricName: Stats[];
labelValueCountByLabelName: Stats[];
memoryInBytesByLabelName: Stats[];
seriesCountByLabelValuePair: Stats[];
}

View file

@ -0,0 +1,45 @@
.codebox {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-gray-9)
);
}
.statsBadge {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-gray-9)
);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-5));
}
.labelBadge {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-gray-9)
);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-5));
}
.healthOk {
background-color: light-dark(
var(--mantine-color-green-1),
var(--mantine-color-green-9)
);
color: light-dark(var(--mantine-color-green-9), var(--mantine-color-green-1));
}
.healthErr {
background-color: light-dark(
var(--mantine-color-red-1),
var(--mantine-color-red-9)
);
color: light-dark(var(--mantine-color-red-9), var(--mantine-color-red-1));
}
.healthUnknown {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-gray-9)
);
}

295
web/ui/app/src/codemirror/theme.ts vendored Normal file
View file

@ -0,0 +1,295 @@
import { HighlightStyle } from "@codemirror/language";
import { EditorView } from "@codemirror/view";
import { tags } from "@lezer/highlight";
export const baseTheme = EditorView.theme({
"&.cm-editor": {
"&.cm-focused": {
outline: "none",
outline_fallback: "none",
},
backgroundColor: "transparent",
},
".cm-scroller": {
overflow: "hidden",
fontFamily: '"DejaVu Sans Mono", monospace',
},
".cm-placeholder": {
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"',
},
".cm-matchingBracket": {
fontWeight: "bold",
outline: "1px dashed transparent",
},
".cm-nonmatchingBracket": { borderColor: "red" },
".cm-tooltip.cm-tooltip-autocomplete": {
"& > ul": {
maxHeight: "350px",
fontFamily: '"DejaVu Sans Mono", monospace',
maxWidth: "unset",
},
"& > ul > li": {
padding: "2px 1em 2px 3px",
},
minWidth: "30%",
},
".cm-completionDetail": {
float: "right",
color: "#999",
},
".cm-tooltip.cm-completionInfo": {
marginTop: "-11px",
padding: "10px",
fontFamily:
"'Open Sans', 'Lucida Sans Unicode', 'Lucida Grande', sans-serif;",
border: "none",
minWidth: "250px",
maxWidth: "min-content",
},
".cm-completionInfo.cm-completionInfo-right": {
"&:before": {
content: "' '",
height: "0",
position: "absolute",
width: "0",
left: "-20px",
borderWidth: "10px",
borderStyle: "solid",
borderColor: "transparent",
},
marginLeft: "12px",
},
".cm-completionInfo.cm-completionInfo-left": {
"&:before": {
content: "' '",
height: "0",
position: "absolute",
width: "0",
right: "-20px",
borderWidth: "10px",
borderStyle: "solid",
borderColor: "transparent",
},
marginRight: "12px",
},
".cm-completionMatchedText": {
textDecoration: "none",
fontWeight: "bold",
},
".cm-selectionMatch": {
backgroundColor: "#e6f3ff",
},
".cm-diagnostic": {
"&.cm-diagnostic-error": {
borderLeft: "3px solid #e65013",
},
},
".cm-completionIcon": {
boxSizing: "content-box",
fontSize: "16px",
lineHeight: "1",
marginRight: "10px",
verticalAlign: "top",
"&:after": { content: "'\\ea88'" },
fontFamily: "codicon",
paddingRight: "0",
opacity: "1",
},
".cm-completionIcon-function, .cm-completionIcon-method": {
"&:after": { content: "'\\ea8c'" },
},
".cm-completionIcon-class": {
"&:after": { content: "'○'" },
},
".cm-completionIcon-interface": {
"&:after": { content: "'◌'" },
},
".cm-completionIcon-variable": {
"&:after": { content: "'𝑥'" },
},
".cm-completionIcon-constant": {
"&:after": { content: "'\\eb5f'" },
},
".cm-completionIcon-type": {
"&:after": { content: "'𝑡'" },
},
".cm-completionIcon-enum": {
"&:after": { content: "''" },
},
".cm-completionIcon-property": {
"&:after": { content: "'□'" },
},
".cm-completionIcon-keyword": {
"&:after": { content: "'\\eb62'" },
},
".cm-completionIcon-namespace": {
"&:after": { content: "'▢'" },
},
".cm-completionIcon-text": {
"&:after": { content: "'\\ea95'" },
color: "#ee9d28",
},
});
export const lightTheme = EditorView.theme(
{
".cm-tooltip": {
backgroundColor: "#f8f8f8",
borderColor: "rgba(52, 79, 113, 0.2)",
},
".cm-tooltip.cm-tooltip-autocomplete": {
"& li:hover": {
backgroundColor: "#ddd",
},
"& > ul > li[aria-selected]": {
backgroundColor: "#d6ebff",
color: "unset",
},
},
".cm-tooltip.cm-completionInfo": {
backgroundColor: "#d6ebff",
},
".cm-tooltip > .cm-completionInfo.cm-completionInfo-right": {
"&:before": {
borderRightColor: "#d6ebff",
},
},
".cm-tooltip > .cm-completionInfo.cm-completionInfo-left": {
"&:before": {
borderLeftColor: "#d6ebff",
},
},
".cm-line": {
"&::selection": {
backgroundColor: "#add6ff",
},
"& > span::selection": {
backgroundColor: "#add6ff",
},
},
".cm-matchingBracket": {
color: "#000",
backgroundColor: "#dedede",
},
".cm-completionMatchedText": {
color: "#0066bf",
},
".cm-completionIcon": {
color: "#007acc",
},
".cm-completionIcon-constant": {
color: "#007acc",
},
".cm-completionIcon-function, .cm-completionIcon-method": {
color: "#652d90",
},
".cm-completionIcon-keyword": {
color: "#616161",
},
},
{ dark: false }
);
export const darkTheme = EditorView.theme(
{
".cm-content": {
caretColor: "#fff",
},
".cm-tooltip.cm-completionInfo": {
backgroundColor: "#333338",
},
".cm-tooltip > .cm-completionInfo.cm-completionInfo-right": {
"&:before": {
borderRightColor: "#333338",
},
},
".cm-tooltip > .cm-completionInfo.cm-completionInfo-left": {
"&:before": {
borderLeftColor: "#333338",
},
},
".cm-line": {
"&::selection": {
backgroundColor: "#767676",
},
"& > span::selection": {
backgroundColor: "#767676",
},
},
".cm-matchingBracket, &.cm-focused .cm-matchingBracket": {
backgroundColor: "#616161",
},
".cm-completionMatchedText": {
color: "#7dd3fc",
},
".cm-completionIcon, .cm-completionIcon-constant": {
color: "#7dd3fc",
},
".cm-completionIcon-function, .cm-completionIcon-method": {
color: "#d8b4fe",
},
".cm-completionIcon-keyword": {
color: "#cbd5e1 !important",
},
},
{ dark: true }
);
export const promqlHighlighter = HighlightStyle.define([
{ tag: tags.number, color: "#09885a" },
{ tag: tags.string, color: "#a31515" },
{ tag: tags.keyword, color: "#008080" },
{ tag: tags.function(tags.variableName), color: "#008080" },
{ tag: tags.labelName, color: "#800000" },
{ tag: tags.operator },
{ tag: tags.modifier, color: "#008080" },
{ tag: tags.paren },
{ tag: tags.squareBracket },
{ tag: tags.brace },
{ tag: tags.invalid, color: "red" },
{ tag: tags.comment, color: "#888", fontStyle: "italic" },
]);
export const darkPromqlHighlighter = HighlightStyle.define([
{ tag: tags.number, color: "#22c55e" },
{ tag: tags.string, color: "#fca5a5" },
{ tag: tags.keyword, color: "#14bfad" },
{ tag: tags.function(tags.variableName), color: "#14bfad" },
{ tag: tags.labelName, color: "#ff8585" },
{ tag: tags.operator },
{ tag: tags.modifier, color: "#14bfad" },
{ tag: tags.paren },
{ tag: tags.squareBracket },
{ tag: tags.brace },
{ tag: tags.invalid, color: "#ff3d3d" },
{ tag: tags.comment, color: "#9ca3af", fontStyle: "italic" },
]);

View file

@ -0,0 +1,52 @@
import { Alert } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { Component, ErrorInfo, ReactNode } from "react";
import { useLocation } from "react-router-dom";
interface Props {
children?: ReactNode;
}
interface State {
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
public state: State = {
error: null,
};
public static getDerivedStateFromError(error: Error): State {
// Update state so the next render will show the fallback UI.
return { error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error:", error, errorInfo);
}
public render() {
if (this.state.error !== null) {
return (
<Alert
color="red"
title="Error querying page data"
icon={<IconAlertTriangle size={14} />}
>
<strong>Error:</strong> {this.state.error.message}
</Alert>
);
}
return this.props.children;
}
}
const ResettingErrorBoundary = (props: Props) => {
const location = useLocation();
return (
<ErrorBoundary key={location.pathname}>{props.children}</ErrorBoundary>
);
};
export default ResettingErrorBoundary;

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="115.333px" height="114px" viewBox="0 0 115.333 114" enable-background="new 0 0 115.333 114" xml:space="preserve">
<g id="Layer_2">
</g>
<g>
<path fill="#EEEEEE" d="M56.667,0.667C25.372,0.667,0,26.036,0,57.332c0,31.295,25.372,56.666,56.667,56.666
s56.666-25.371,56.666-56.666C113.333,26.036,87.961,0.667,56.667,0.667z M56.667,106.722c-8.904,0-16.123-5.948-16.123-13.283
H72.79C72.79,100.773,65.571,106.722,56.667,106.722z M83.297,89.04H30.034v-9.658h53.264V89.04z M83.106,74.411h-52.92
c-0.176-0.203-0.356-0.403-0.526-0.609c-5.452-6.62-6.736-10.076-7.983-13.598c-0.021-0.116,6.611,1.355,11.314,2.413
c0,0,2.42,0.56,5.958,1.205c-3.397-3.982-5.414-9.044-5.414-14.218c0-11.359,8.712-21.285,5.569-29.308
c3.059,0.249,6.331,6.456,6.552,16.161c3.252-4.494,4.613-12.701,4.613-17.733c0-5.21,3.433-11.262,6.867-11.469
c-3.061,5.045,0.793,9.37,4.219,20.099c1.285,4.03,1.121,10.812,2.113,15.113C63.797,33.534,65.333,20.5,71,16
c-2.5,5.667,0.37,12.758,2.333,16.167c3.167,5.5,5.087,9.667,5.087,17.548c0,5.284-1.951,10.259-5.242,14.148
c3.742-0.702,6.326-1.335,6.326-1.335l12.152-2.371C91.657,60.156,89.891,67.418,83.106,74.411z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,118 @@
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
dayjs.extend(duration);
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
export const parseDuration = (durationStr: string): number | null => {
if (durationStr === "") {
return null;
}
if (durationStr === "0") {
// Allow 0 without a unit.
return 0;
}
const durationRE = new RegExp(
"^(([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?$"
);
const matches = durationStr.match(durationRE);
if (!matches) {
return null;
}
let dur = 0;
// Parse the match at pos `pos` in the regex and use `mult` to turn that
// into ms, then add that value to the total parsed duration.
const m = (pos: number, mult: number) => {
if (matches[pos] === undefined) {
return;
}
const n = parseInt(matches[pos]);
dur += n * mult;
};
m(2, 1000 * 60 * 60 * 24 * 365); // y
m(4, 1000 * 60 * 60 * 24 * 7); // w
m(6, 1000 * 60 * 60 * 24); // d
m(8, 1000 * 60 * 60); // h
m(10, 1000 * 60); // m
m(12, 1000); // s
m(14, 1); // ms
return dur;
};
export const formatDuration = (d: number): string => {
let ms = d;
let r = "";
if (ms === 0) {
return "0s";
}
const f = (unit: string, mult: number, exact: boolean) => {
if (exact && ms % mult !== 0) {
return;
}
const v = Math.floor(ms / mult);
if (v > 0) {
r += `${v}${unit}`;
ms -= v * mult;
}
};
// Only format years and weeks if the remainder is zero, as it is often
// easier to read 90d than 12w6d.
f("y", 1000 * 60 * 60 * 24 * 365, true);
f("w", 1000 * 60 * 60 * 24 * 7, true);
f("d", 1000 * 60 * 60 * 24, false);
f("h", 1000 * 60 * 60, false);
f("m", 1000 * 60, false);
f("s", 1000, false);
f("ms", 1, false);
return r;
};
export function parseTime(timeText: string): number {
return dayjs.utc(timeText).valueOf();
}
export const now = (): number => dayjs().valueOf();
export const humanizeDuration = (milliseconds: number): string => {
const sign = milliseconds < 0 ? "-" : "";
const unsignedMillis = milliseconds < 0 ? -1 * milliseconds : milliseconds;
const duration = dayjs.duration(unsignedMillis, "ms");
const ms = Math.floor(duration.milliseconds());
const s = Math.floor(duration.seconds());
const m = Math.floor(duration.minutes());
const h = Math.floor(duration.hours());
const d = Math.floor(duration.asDays());
if (d !== 0) {
return `${sign}${d}d ${h}h ${m}m ${s}s`;
}
if (h !== 0) {
return `${sign}${h}h ${m}m ${s}s`;
}
if (m !== 0) {
return `${sign}${m}m ${s}s`;
}
if (s !== 0) {
return `${sign}${s}.${ms}s`;
}
if (unsignedMillis > 0) {
return `${sign}${unsignedMillis.toFixed(3)}ms`;
}
return "0s";
};
export const formatRelative = (startStr: string, end: number): string => {
const start = parseTime(startStr);
if (start < 0) {
return "Never";
}
return humanizeDuration(end - start) + " ago";
};

View file

@ -0,0 +1,27 @@
import { Card, Group, Text } from "@mantine/core";
import { IconSpy } from "@tabler/icons-react";
import { FC } from "react";
const Agent: FC = () => {
return (
<Card shadow="xs" withBorder radius="md" p="md">
<Group wrap="nowrap" align="center" ml="xs" mb="sm" gap="xs">
<IconSpy size={22} />
<Text fz="xl" fw={600}>
Prometheus Agent
</Text>
</Group>
<Text p="md">
This Prometheus instance is running in <strong>agent mode</strong>. In
this mode, Prometheus is only used to scrape discovered targets and
forward the scraped metrics to remote write endpoints.
</Text>
<Text p="md">
Some features are not available in this mode, such as querying and
alerting.
</Text>
</Card>
);
};
export default Agent;

View file

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

View file

@ -0,0 +1,16 @@
import { CodeHighlight } from "@mantine/code-highlight";
import { useSuspenseQuery } from "@tanstack/react-query";
export default function Config() {
const {
data: {
data: { yaml },
},
} = useSuspenseQuery<{ data: { yaml: string } }>({
queryKey: ["config"],
queryFn: () => {
return fetch("/api/v1/status/config").then((res) => res.json());
},
});
return <CodeHighlight code={yaml} language="yaml" miw="30vw" />;
}

View file

@ -0,0 +1,21 @@
.th {
padding: 0;
}
.control {
width: 100%;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
}
.icon {
width: rem(21px);
height: rem(21px);
border-radius: rem(21px);
}

View file

@ -0,0 +1,183 @@
import { useState } from "react";
import {
Table,
UnstyledButton,
Group,
Text,
Center,
TextInput,
rem,
keys,
Card,
} from "@mantine/core";
import {
IconSelector,
IconChevronDown,
IconChevronUp,
IconSearch,
} from "@tabler/icons-react";
import classes from "./flags.module.css";
import { useSuspenseAPIQuery } from "../api/api";
interface RowData {
flag: string;
value: string;
}
interface ThProps {
children: React.ReactNode;
reversed: boolean;
sorted: boolean;
onSort(): void;
}
function Th({ children, reversed, sorted, onSort }: ThProps) {
const Icon = sorted
? reversed
? IconChevronUp
: IconChevronDown
: IconSelector;
return (
<Table.Th className={classes.th}>
<UnstyledButton onClick={onSort} className={classes.control}>
<Group justify="space-between">
<Text fw={600} fz="sm">
{children}
</Text>
<Center className={classes.icon}>
<Icon style={{ width: rem(16), height: rem(16) }} stroke={1.5} />
</Center>
</Group>
</UnstyledButton>
</Table.Th>
);
}
function filterData(data: RowData[], search: string) {
const query = search.toLowerCase().trim();
return data.filter((item) =>
keys(data[0]).some((key) => item[key].toLowerCase().includes(query))
);
}
function sortData(
data: RowData[],
payload: { sortBy: keyof RowData | null; reversed: boolean; search: string }
) {
const { sortBy } = payload;
if (!sortBy) {
return filterData(data, payload.search);
}
return filterData(
[...data].sort((a, b) => {
if (payload.reversed) {
return b[sortBy].localeCompare(a[sortBy]);
}
return a[sortBy].localeCompare(b[sortBy]);
}),
payload.search
);
}
export default function Flags() {
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,
}));
const [search, setSearch] = useState("");
const [sortedData, setSortedData] = useState(flags);
const [sortBy, setSortBy] = useState<keyof RowData | null>(null);
const [reverseSortDirection, setReverseSortDirection] = useState(false);
const setSorting = (field: keyof RowData) => {
const reversed = field === sortBy ? !reverseSortDirection : false;
setReverseSortDirection(reversed);
setSortBy(field);
setSortedData(sortData(flags, { sortBy: field, reversed, search }));
};
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.currentTarget;
setSearch(value);
setSortedData(
sortData(flags, { sortBy, reversed: reverseSortDirection, search: value })
);
};
const rows = sortedData.map((row) => (
<Table.Tr key={row.flag}>
<Table.Td>
<code>--{row.flag}</code>
</Table.Td>
<Table.Td>
<code>{row.value}</code>
</Table.Td>
</Table.Tr>
));
return (
<Card shadow="xs" maw={1000} withBorder>
<TextInput
placeholder="Filter by flag name or value"
mb="md"
autoFocus
leftSection={
<IconSearch
style={{ width: rem(16), height: rem(16) }}
stroke={1.5}
/>
}
value={search}
onChange={handleSearchChange}
/>
<Table
horizontalSpacing="md"
verticalSpacing="xs"
miw={700}
layout="fixed"
>
<Table.Tbody>
<Table.Tr>
<Th
sorted={sortBy === "flag"}
reversed={reverseSortDirection}
onSort={() => setSorting("flag")}
>
Flag
</Th>
<Th
sorted={sortBy === "value"}
reversed={reverseSortDirection}
onSort={() => setSorting("value")}
>
Value
</Th>
</Table.Tr>
</Table.Tbody>
<Table.Tbody>
{rows.length > 0 ? (
rows
) : (
<Table.Tr>
<Table.Td colSpan={2}>
<Text fw={500} ta="center">
Nothing found
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Card>
);
}

View file

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

View file

@ -0,0 +1,257 @@
import {
Alert,
Badge,
Card,
Group,
Table,
Text,
Tooltip,
useComputedColorScheme,
} from "@mantine/core";
// import { useQuery } from "react-query";
import {
formatDuration,
formatRelative,
humanizeDuration,
now,
} from "../lib/time-format";
import {
IconAlertTriangle,
IconBell,
IconClockPause,
IconClockPlay,
IconDatabaseImport,
IconHourglass,
IconRefresh,
IconRepeat,
} from "@tabler/icons-react";
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import { useSuspenseAPIQuery } from "../api/api";
import { RulesMap } from "../api/response-types/rules";
import { syntaxHighlighting } from "@codemirror/language";
import {
baseTheme,
darkPromqlHighlighter,
lightTheme,
promqlHighlighter,
} from "../codemirror/theme";
import { PromQLExtension } from "@prometheus-io/codemirror-promql";
import classes from "../codebox.module.css";
const healthBadgeClass = (state: string) => {
switch (state) {
case "ok":
return classes.healthOk;
case "err":
return classes.healthErr;
case "unknown":
return classes.healthUnknown;
default:
return "orange";
}
};
const promqlExtension = new PromQLExtension();
export default function Rules() {
const { data } = useSuspenseAPIQuery<RulesMap>(`/rules`);
const theme = useComputedColorScheme();
return (
<>
{data.data.groups.map((g) => (
<Card
shadow="xs"
withBorder
radius="md"
p="md"
mb="md"
key={g.name + ";" + g.file}
>
<Group mb="md" mt="xs" ml="xs" justify="space-between">
<Group align="baseline">
<Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)">
{g.name}
</Text>
<Text fz="sm" c="gray.6">
{g.file}
</Text>
</Group>
<Group>
<Tooltip label="Last group evaluation" withArrow>
<Badge
variant="light"
className={classes.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={<IconRefresh size={12} />}
>
{formatRelative(g.lastEvaluation, now())}
</Badge>
</Tooltip>
<Tooltip label="Duration of last group evaluation" withArrow>
<Badge
variant="light"
className={classes.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={<IconHourglass size={12} />}
>
{humanizeDuration(parseFloat(g.evaluationTime) * 1000)}
</Badge>
</Tooltip>
<Tooltip label="Group evaluation interval" withArrow>
<Badge
variant="light"
className={classes.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={<IconRepeat size={12} />}
>
{humanizeDuration(parseFloat(g.interval) * 1000)}
</Badge>
</Tooltip>
</Group>
</Group>
<Table>
<Table.Tbody>
{g.rules.map((r) => (
<Table.Tr key={r.name}>
<Table.Td p="md" valign="top">
<Group gap="xs" wrap="nowrap">
{r.type === "alerting" ? (
<IconBell size={14} />
) : (
<IconDatabaseImport size={14} />
)}
<Text fz="sm" fw={600}>
{r.name}
</Text>
</Group>
<Group mt="md" gap="xs">
<Badge className={healthBadgeClass(r.health)}>
{r.health}
</Badge>
<Group gap="xs" wrap="wrap">
<Tooltip label="Last rule evaluation" withArrow>
<Badge
variant="light"
className={classes.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={<IconRefresh size={12} />}
>
{formatRelative(r.lastEvaluation, now())}
</Badge>
</Tooltip>
<Tooltip
label="Duration of last rule evaluation"
withArrow
>
<Badge
variant="light"
className={classes.statsBadge}
styles={{ label: { textTransform: "none" } }}
leftSection={<IconHourglass size={12} />}
>
{humanizeDuration(
parseFloat(r.evaluationTime) * 1000
)}
</Badge>
</Tooltip>
</Group>
</Group>
</Table.Td>
<Table.Td p="md">
<Card
p="xs"
className={classes.codebox}
radius="sm"
shadow="none"
>
<CodeMirror
basicSetup={false}
value={r.query}
editable={false}
extensions={[
baseTheme,
lightTheme,
syntaxHighlighting(
theme === "light"
? promqlHighlighter
: darkPromqlHighlighter
),
promqlExtension.asExtension(),
EditorView.lineWrapping,
]}
/>
</Card>
{r.lastError && (
<Alert
color="red"
mt="sm"
title="Rule failed to evaluate"
icon={<IconAlertTriangle size={14} />}
>
<strong>Error:</strong> {r.lastError}
</Alert>
)}
{r.type === "alerting" && (
<Group mt="md" gap="xs">
{r.duration && (
<Badge
variant="light"
styles={{ label: { textTransform: "none" } }}
leftSection={<IconClockPause size={12} />}
>
for: {formatDuration(r.duration * 1000)}
</Badge>
)}
{r.keepFiringFor && (
<Badge
variant="light"
styles={{ label: { textTransform: "none" } }}
leftSection={<IconClockPlay size={12} />}
>
keep_firing_for: {formatDuration(r.duration * 1000)}
</Badge>
)}
</Group>
)}
{r.labels && Object.keys(r.labels).length > 0 && (
<Group mt="md" gap="xs">
{Object.entries(r.labels).map(([k, v]) => (
<Badge
variant="light"
className={classes.labelBadge}
styles={{ label: { textTransform: "none" } }}
key={k}
>
{k}: {v}
</Badge>
))}
</Group>
)}
{/* {Object.keys(r.annotations).length > 0 && (
<Group mt="md" gap="xs">
{Object.entries(r.annotations).map(([k, v]) => (
<Badge
variant="light"
color="orange.9"
styles={{ label: { textTransform: "none" } }}
key={k}
>
{k}: {v}
</Badge>
))}
</Group>
)} */}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
))}
</>
);
}

View file

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

View file

@ -0,0 +1,82 @@
import { Card, Group, Stack, Table, Text } from "@mantine/core";
import { useSuspenseAPIQuery } from "../api/api";
import { IconRun, IconWall } from "@tabler/icons-react";
const statusConfig: Record<
string,
{
title?: string;
formatValue?: (v: any) => string;
}
> = {
startTime: {
title: "Start time",
formatValue: (v: string) => new Date(v).toUTCString(),
},
CWD: { title: "Working directory" },
reloadConfigSuccess: {
title: "Configuration reload",
formatValue: (v: boolean) => (v ? "Successful" : "Unsuccessful"),
},
lastConfigTime: {
title: "Last successful configuration reload",
formatValue: (v: string) => new Date(v).toUTCString(),
},
corruptionCount: { title: "WAL corruptions" },
goroutineCount: { title: "Goroutines" },
storageRetention: { title: "Storage retention" },
};
export default function Status() {
const { data: buildinfo } =
useSuspenseAPIQuery<Record<string, string>>(`/status/buildinfo`);
const { data: runtimeinfo } =
useSuspenseAPIQuery<Record<string, string>>(`/status/runtimeinfo`);
return (
<Stack gap="md" maw={1000}>
<Card shadow="xs" withBorder radius="md" p="md">
<Group wrap="nowrap" align="center" ml="xs" mb="sm" gap="xs">
<IconWall size={22} />
<Text fz="xl" fw={600}>
Build information
</Text>
</Group>
<Table layout="fixed">
<Table.Tbody>
{Object.entries(buildinfo.data).map(([k, v]) => (
<Table.Tr key={k}>
<Table.Th style={{ textTransform: "capitalize" }}>{k}</Table.Th>
<Table.Td>{v}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
<Card shadow="xs" withBorder radius="md" p="md">
<Group wrap="nowrap" align="center" ml="xs" mb="sm" gap="xs">
<IconRun size={22} />
<Text fz="xl" fw={600}>
Runtime information
</Text>
</Group>
<Table layout="fixed">
<Table.Tbody>
{Object.entries(runtimeinfo.data).map(([k, v]) => {
const { title = k, formatValue = (val: string) => val } =
statusConfig[k] || {};
return (
<Table.Tr key={k}>
<Table.Th style={{ textTransform: "capitalize" }}>
{title}
</Table.Th>
<Table.Td>{formatValue(v)}</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Card>
</Stack>
);
}

View file

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

View file

@ -0,0 +1,101 @@
import { Stack, Card, Group, Table, Text } from "@mantine/core";
import { useSuspenseAPIQuery } from "../api/api";
import { TSDBMap } from "../api/response-types/tsdb-status";
export default function TSDBStatus() {
const {
data: {
data: {
headStats,
labelValueCountByLabelName,
seriesCountByMetricName,
memoryInBytesByLabelName,
seriesCountByLabelValuePair,
},
},
} = useSuspenseAPIQuery<TSDBMap>(`/status/tsdb`);
const unixToTime = (unix: number): string => {
try {
return `${new Date(unix).toISOString()} (${unix})`;
} catch {
if (numSeries === 0) {
return "No datapoints yet";
}
return `Error parsing time (${unix})`;
}
};
const { chunkCount, numSeries, numLabelPairs, minTime, maxTime } = headStats;
const stats = [
{ name: "Number of Series", value: numSeries },
{ name: "Number of Chunks", value: chunkCount },
{ name: "Number of Label Pairs", value: numLabelPairs },
{ name: "Current Min Time", value: `${unixToTime(minTime)}` },
{ name: "Current Max Time", value: `${unixToTime(maxTime)}` },
];
return (
<Stack gap="md" maw={1000}>
{[
{
title: "TSDB Head Status",
stats,
formatAsCode: false,
},
{
title: "Top 10 label names with value count",
stats: labelValueCountByLabelName,
formatAsCode: true,
},
{
title: "Top 10 series count by metric names",
stats: seriesCountByMetricName,
formatAsCode: true,
},
{
title: "Top 10 label names with high memory usage",
unit: "Bytes",
stats: memoryInBytesByLabelName,
formatAsCode: true,
},
{
title: "Top 10 series count by label value pairs",
stats: seriesCountByLabelValuePair,
formatAsCode: true,
},
].map(({ title, unit = "Count", stats, formatAsCode }) => (
<Card shadow="xs" withBorder radius="md" p="md">
<Group wrap="nowrap" align="center" ml="xs" mb="sm" gap="xs">
<Text fz="xl" fw={600}>
{title}
</Text>
</Group>
<Table layout="fixed">
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>{unit}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{stats.map(({ name, value }) => {
return (
<Table.Tr key={name}>
<Table.Td
style={{
wordBreak: "break-all",
}}
>
{formatAsCode ? <code>{name}</code> : name}
</Table.Td>
<Table.Td>{value}</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Card>
))}
</Stack>
);
}

View file

@ -0,0 +1,13 @@
import { createContext } from "react";
export interface Settings {
consolesLink: string | null;
agentMode: boolean;
ready: boolean;
}
export const SettingsContext = createContext<Settings>({
consolesLink: null,
agentMode: false,
ready: false,
});

View file

@ -0,0 +1,67 @@
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>
);
};

View file

@ -4,4 +4,11 @@ import react from '@vitejs/plugin-react-swc'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})
server: {
proxy: {
"/api": {
target: "http://localhost:9090",
},
},
},
});

View file

@ -36,9 +36,12 @@
"@mantine/dates": "^7.5.3",
"@mantine/hooks": "^7.5.3",
"@prometheus-io/codemirror-promql": "0.49.1",
"@tabler/icons-react": "^2.47.0",
"@tanstack/react-query": "^5.22.2",
"@uiw/react-codemirror": "^4.21.22",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.1"
},
"devDependencies": {
"@types/react": "^18.2.55",
@ -904,6 +907,14 @@
"resolved": "lezer-promql",
"link": true
},
"node_modules/@remix-run/router": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.1.tgz",
"integrity": "sha512-zcU0gM3z+3iqj8UX45AmWY810l3oUmXM7uH4dt5xtzvMhRtYVhKGOmgOd1877dOPPepfCjUv57w+syamWIYe7w==",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz",
@ -1330,6 +1341,55 @@
"integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==",
"dev": true
},
"node_modules/@tabler/icons": {
"version": "2.47.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-2.47.0.tgz",
"integrity": "sha512-4w5evLh+7FUUiA1GucvGj2ReX2TvOjEr4ejXdwL/bsjoSkof6r1gQmzqI+VHrE2CpJpB3al7bCTulOkFa/RcyA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "2.47.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-2.47.0.tgz",
"integrity": "sha512-iqly2FvCF/qUbgmvS8E40rVeYY7laltc5GUjRxQj59DuX0x/6CpKHTXt86YlI2whg4czvd/c8Ce8YR08uEku0g==",
"dependencies": {
"@tabler/icons": "2.47.0",
"prop-types": "^15.7.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.22.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.22.2.tgz",
"integrity": "sha512-z3PwKFUFACMUqe1eyesCIKg3Jv1mysSrYfrEW5ww5DCDUD4zlpTKBvUDaEjsfZzL3ULrFLDM9yVUxI/fega1Qg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.22.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.22.2.tgz",
"integrity": "sha512-TaxJDRzJ8/NWRT4lY2jguKCrNI6MRN+67dELzPjNUlvqzTxGANlMp68l7aC7hG8Bd1uHNxHl7ihv7MT50i/43A==",
"dependencies": {
"@tanstack/query-core": "5.22.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18.0.0"
}
},
"node_modules/@types/estree": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
@ -3115,6 +3175,36 @@
}
}
},
"node_modules/react-router": {
"version": "6.22.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.1.tgz",
"integrity": "sha512-0pdoRGwLtemnJqn1K0XHUbnKiX0S4X8CgvVVmHGOWmofESj31msHo/1YiqcJWK7Wxfq2a4uvvtS01KAQyWK/CQ==",
"dependencies": {
"@remix-run/router": "1.15.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/react-router-dom": {
"version": "6.22.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.1.tgz",
"integrity": "sha512-iwMyyyrbL7zkKY7MRjOVRy+TMnS/OPusaFVxM2P11x9dzSzGmLsebkCvYirGq0DWB9K9hOspHYYtDz33gE5Duw==",
"dependencies": {
"@remix-run/router": "1.15.1",
"react-router": "6.22.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",