Implement several status pages and other general aspects

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2024-02-21 11:13:48 +01:00
parent dfabda7507
commit d6a347e5de
27 changed files with 1818 additions and 15 deletions

View file

@ -4,7 +4,29 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/prometheus-logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prometheus Server</title>
<!--
Placeholders replaced by Prometheus during serving:
- GLOBAL_CONSOLES_LINK is replaced and set to the consoles link if it exists.
It will render a "Consoles" link in the navbar when it is non-empty.
- PROMETHEUS_AGENT_MODE is replaced by a boolean indicating if Prometheus is running in agent mode.
It true, it will disable querying capacities in the UI and generally adapt the UI to the agent mode.
It has to be represented as a string, because booleans can be mangled to !1 in production builds.
- PROMETHEUS_READY is replaced by a boolean indicating whether Prometheus was ready at the time the
web app was served. It has to be represented as a string, because booleans can be mangled to !1 in
production builds.
-->
<script>
const GLOBAL_CONSOLES_LINK='CONSOLES_LINK_PLACEHOLDER';
const GLOBAL_AGENT_MODE='AGENT_MODE_PLACEHOLDER';
const GLOBAL_READY='READY_PLACEHOLDER';
</script>
<!--
The TITLE_PLACEHOLDER magic value is replaced during serving by Prometheus.
We need it dynamic because it can be overridden by the command line flag `web.page-title`.
-->
<title>TITLE_PLACEHOLDER</title>
</head>
<body>
<div id="root"></div>

View file

@ -10,12 +10,28 @@
"preview": "vite preview"
},
"dependencies": {
"@codemirror/autocomplete": "^6.12.0",
"@codemirror/language": "^6.10.1",
"@codemirror/lint": "^6.5.0",
"@codemirror/state": "^6.4.0",
"@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",
"@prometheus-io/codemirror-promql": "^0.50.0-rc.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-dom": "^18.2.0",
"react-router-dom": "^6.22.1"
},
"devDependencies": {
"@types/eslint": "~8.56.2",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@typescript-eslint/eslint-plugin": "^6.21.0",

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,9 +1,305 @@
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 { MantineProvider } from "@mantine/core";
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",
],
},
});
function App() {
return <MantineProvider></MantineProvider>;
const [opened, { toggle }] = useDisclosure();
const { agentMode } = useContext(SettingsContext);
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;

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

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

@ -1,10 +1,29 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { Settings, SettingsContext } from "./settings.ts";
ReactDOM.createRoot(document.getElementById('root')!).render(
// Declared/defined in public/index.html, value replaced by Prometheus when serving bundle.
declare const GLOBAL_CONSOLES_LINK: string;
declare const GLOBAL_AGENT_MODE: string;
declare const GLOBAL_READY: string;
const settings: Settings = {
consolesLink:
GLOBAL_CONSOLES_LINK === "CONSOLES_LINK_PLACEHOLDER" ||
GLOBAL_CONSOLES_LINK === "" ||
GLOBAL_CONSOLES_LINK === null
? null
: GLOBAL_CONSOLES_LINK,
agentMode: GLOBAL_AGENT_MODE === "true",
ready: GLOBAL_READY === "true",
};
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
<SettingsContext.Provider value={settings}>
<App />
</SettingsContext.Provider>
</React.StrictMode>
);

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

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