diff --git a/web/ui/mantine-ui/index.html b/web/ui/mantine-ui/index.html
index 83b4f801ea..f54cf951c0 100644
--- a/web/ui/mantine-ui/index.html
+++ b/web/ui/mantine-ui/index.html
@@ -4,7 +4,29 @@
-
Prometheus Server
+
+
+
+
+
+ TITLE_PLACEHOLDER
diff --git a/web/ui/mantine-ui/package.json b/web/ui/mantine-ui/package.json
index fe4d234498..221a6c36bd 100644
--- a/web/ui/mantine-ui/package.json
+++ b/web/ui/mantine-ui/package.json
@@ -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",
diff --git a/web/ui/mantine-ui/src/App.module.css b/web/ui/mantine-ui/src/App.module.css
new file mode 100644
index 0000000000..82c11a4ff2
--- /dev/null
+++ b/web/ui/mantine-ui/src/App.module.css
@@ -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);
+ }
+}
diff --git a/web/ui/mantine-ui/src/App.tsx b/web/ui/mantine-ui/src/App.tsx
index 00e16b11b8..4c7732bb53 100644
--- a/web/ui/mantine-ui/src/App.tsx
+++ b/web/ui/mantine-ui/src/App.tsx
@@ -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: ,
+ element: ,
+ },
+ {
+ title: "Rules",
+ path: "/rules",
+ icon: ,
+ element: ,
+ },
+ {
+ title: "Service discovery",
+ path: "/service-discovery",
+ icon: (
+
+ ),
+ element: ,
+ },
+];
+
+const serverStatusPages = [
+ {
+ title: "Runtime & build information",
+ path: "/status",
+ icon: ,
+ element: ,
+ },
+ {
+ title: "TSDB status",
+ path: "/tsdb-status",
+ icon: ,
+ element: ,
+ },
+ {
+ title: "Command-line flags",
+ path: "/flags",
+ icon: ,
+ element: ,
+ },
+ {
+ title: "Configuration",
+ path: "/config",
+ icon: ,
+ element: ,
+ },
+];
+
+const allStatusPages = [...monitoringStatusPages, ...serverStatusPages];
+
+const theme = createTheme({
+ colors: {
+ "codebox-bg": [
+ "#f5f5f5",
+ "#e7e7e7",
+ "#cdcdcd",
+ "#b2b2b2",
+ "#9a9a9a",
+ "#8b8b8b",
+ "#848484",
+ "#717171",
+ "#656565",
+ "#575757",
+ ],
+ },
+});
function App() {
- return ;
+ const [opened, { toggle }] = useDisclosure();
+ const { agentMode } = useContext(SettingsContext);
+
+ const navLinks = (
+ <>
+ }
+ >
+ Graph
+
+ }
+ >
+ Alerts
+
+
+
+
+ }
+ target="_blank"
+ >
+ Help
+
+ >
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+ Prometheus{agentMode && " Agent"}
+
+
+ {navLinks}
+
+
+
+ {}
+
+
+
+
+ {navLinks}
+
+
+
+
+ (
+
+ ))}
+ >
+
+
+ }
+ />
+ } />
+ } />
+ } />
+ {allStatusPages.map((p) => (
+
+ ))}
+
+
+
+
+
+ {/* */}
+
+
+
+ );
}
export default App;
diff --git a/web/ui/mantine-ui/src/api/api.ts b/web/ui/mantine-ui/src/api/api.ts
new file mode 100644
index 0000000000..48576ee90f
--- /dev/null
+++ b/web/ui/mantine-ui/src/api/api.ts
@@ -0,0 +1,32 @@
+import { useSuspenseQuery } from "@tanstack/react-query";
+
+export const API_PATH = "api/v1";
+
+export type APIResponse = { status: string; data: T };
+
+export const useSuspenseAPIQuery = (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((resolve) =>
+ // setTimeout(() => resolve(res), 2000)
+ // )
+ // )
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error(res.statusText);
+ }
+ return res;
+ })
+ .then((res) => res.json() as Promise>),
+ });
diff --git a/web/ui/mantine-ui/src/api/response-types/rules.ts b/web/ui/mantine-ui/src/api/response-types/rules.ts
new file mode 100644
index 0000000000..eba15a6169
--- /dev/null
+++ b/web/ui/mantine-ui/src/api/response-types/rules.ts
@@ -0,0 +1,51 @@
+type RuleState = "pending" | "firing" | "inactive";
+
+export interface Alert {
+ labels: Record;
+ state: RuleState;
+ value: string;
+ annotations: Record;
+ 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;
+ annotations: Record;
+ 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;
+} & 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[];
+}
diff --git a/web/ui/mantine-ui/src/api/response-types/tsdb-status.ts b/web/ui/mantine-ui/src/api/response-types/tsdb-status.ts
new file mode 100644
index 0000000000..98b93177ce
--- /dev/null
+++ b/web/ui/mantine-ui/src/api/response-types/tsdb-status.ts
@@ -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[];
+}
diff --git a/web/ui/mantine-ui/src/codebox.module.css b/web/ui/mantine-ui/src/codebox.module.css
new file mode 100644
index 0000000000..23387c7764
--- /dev/null
+++ b/web/ui/mantine-ui/src/codebox.module.css
@@ -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)
+ );
+}
diff --git a/web/ui/mantine-ui/src/codemirror/theme.ts b/web/ui/mantine-ui/src/codemirror/theme.ts
new file mode 100644
index 0000000000..9028b527c8
--- /dev/null
+++ b/web/ui/mantine-ui/src/codemirror/theme.ts
@@ -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" },
+]);
diff --git a/web/ui/mantine-ui/src/error-boundary.tsx b/web/ui/mantine-ui/src/error-boundary.tsx
new file mode 100644
index 0000000000..2a6f824914
--- /dev/null
+++ b/web/ui/mantine-ui/src/error-boundary.tsx
@@ -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 {
+ 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 (
+ }
+ >
+ Error: {this.state.error.message}
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+const ResettingErrorBoundary = (props: Props) => {
+ const location = useLocation();
+ return (
+ {props.children}
+ );
+};
+
+export default ResettingErrorBoundary;
diff --git a/web/ui/mantine-ui/src/images/prometheus-logo.svg b/web/ui/mantine-ui/src/images/prometheus-logo.svg
new file mode 100644
index 0000000000..b914095ecf
--- /dev/null
+++ b/web/ui/mantine-ui/src/images/prometheus-logo.svg
@@ -0,0 +1,19 @@
+
+
+
+
diff --git a/web/ui/mantine-ui/src/lib/time-format.ts b/web/ui/mantine-ui/src/lib/time-format.ts
new file mode 100644
index 0000000000..f31e574682
--- /dev/null
+++ b/web/ui/mantine-ui/src/lib/time-format.ts
@@ -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";
+};
diff --git a/web/ui/mantine-ui/src/main.tsx b/web/ui/mantine-ui/src/main.tsx
index 3d7150da80..8e3f4e82d6 100644
--- a/web/ui/mantine-ui/src/main.tsx
+++ b/web/ui/mantine-ui/src/main.tsx
@@ -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(
-
- ,
-)
+
+
+
+
+);
diff --git a/web/ui/mantine-ui/src/pages/agent.tsx b/web/ui/mantine-ui/src/pages/agent.tsx
new file mode 100644
index 0000000000..40ff3817e3
--- /dev/null
+++ b/web/ui/mantine-ui/src/pages/agent.tsx
@@ -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 (
+
+
+
+
+ Prometheus Agent
+
+
+
+ This Prometheus instance is running in agent mode. In
+ this mode, Prometheus is only used to scrape discovered targets and
+ forward the scraped metrics to remote write endpoints.
+
+
+ Some features are not available in this mode, such as querying and
+ alerting.
+
+
+ );
+};
+
+export default Agent;
diff --git a/web/ui/mantine-ui/src/pages/alerts.tsx b/web/ui/mantine-ui/src/pages/alerts.tsx
new file mode 100644
index 0000000000..e5514d254b
--- /dev/null
+++ b/web/ui/mantine-ui/src/pages/alerts.tsx
@@ -0,0 +1,3 @@
+export default function Alerts() {
+ return <>Alerts page>;
+}
diff --git a/web/ui/mantine-ui/src/pages/config.tsx b/web/ui/mantine-ui/src/pages/config.tsx
new file mode 100644
index 0000000000..3cbace38c0
--- /dev/null
+++ b/web/ui/mantine-ui/src/pages/config.tsx
@@ -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 ;
+}
diff --git a/web/ui/mantine-ui/src/pages/flags.module.css b/web/ui/mantine-ui/src/pages/flags.module.css
new file mode 100644
index 0000000000..221b2de028
--- /dev/null
+++ b/web/ui/mantine-ui/src/pages/flags.module.css
@@ -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);
+}
diff --git a/web/ui/mantine-ui/src/pages/flags.tsx b/web/ui/mantine-ui/src/pages/flags.tsx
new file mode 100644
index 0000000000..af4203e6dd
--- /dev/null
+++ b/web/ui/mantine-ui/src/pages/flags.tsx
@@ -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 (
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+ );
+}
+
+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>(`/status/flags`);
+
+ // const { response, error, isLoading } =
+ // useFetchAPI>(`/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(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) => {
+ const { value } = event.currentTarget;
+ setSearch(value);
+ setSortedData(
+ sortData(flags, { sortBy, reversed: reverseSortDirection, search: value })
+ );
+ };
+
+ const rows = sortedData.map((row) => (
+
+
+ --{row.flag}
+
+
+ {row.value}
+
+
+ ));
+
+ return (
+
+
+ }
+ value={search}
+ onChange={handleSearchChange}
+ />
+
+
+
+ setSorting("flag")}
+ >
+ Flag
+ |
+
+ setSorting("value")}
+ >
+ Value
+ |
+
+
+
+ {rows.length > 0 ? (
+ rows
+ ) : (
+
+
+
+ Nothing found
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/web/ui/mantine-ui/src/pages/graph.tsx b/web/ui/mantine-ui/src/pages/graph.tsx
new file mode 100644
index 0000000000..1e1c158139
--- /dev/null
+++ b/web/ui/mantine-ui/src/pages/graph.tsx
@@ -0,0 +1,3 @@
+export default function Graph() {
+ return <>Graph page>;
+}
diff --git a/web/ui/mantine-ui/src/pages/rules.tsx b/web/ui/mantine-ui/src/pages/rules.tsx
new file mode 100644
index 0000000000..ab1e8a5075
--- /dev/null
+++ b/web/ui/mantine-ui/src/pages/rules.tsx
@@ -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(`/rules`);
+ const theme = useComputedColorScheme();
+
+ return (
+ <>
+ {data.data.groups.map((g) => (
+
+
+
+
+ {g.name}
+
+
+ {g.file}
+
+
+
+
+ }
+ >
+ {formatRelative(g.lastEvaluation, now())}
+
+
+
+ }
+ >
+ {humanizeDuration(parseFloat(g.evaluationTime) * 1000)}
+
+
+
+ }
+ >
+ {humanizeDuration(parseFloat(g.interval) * 1000)}
+
+
+
+
+
+
+ {g.rules.map((r) => (
+
+
+
+ {r.type === "alerting" ? (
+
+ ) : (
+
+ )}
+
+ {r.name}
+
+
+
+
+ {r.health}
+
+
+
+
+ }
+ >
+ {formatRelative(r.lastEvaluation, now())}
+
+
+
+
+ }
+ >
+ {humanizeDuration(
+ parseFloat(r.evaluationTime) * 1000
+ )}
+
+
+
+
+
+
+
+
+
+
+ {r.lastError && (
+ }
+ >
+ Error: {r.lastError}
+
+ )}
+ {r.type === "alerting" && (
+
+ {r.duration && (
+ }
+ >
+ for: {formatDuration(r.duration * 1000)}
+
+ )}
+ {r.keepFiringFor && (
+ }
+ >
+ keep_firing_for: {formatDuration(r.duration * 1000)}
+
+ )}
+
+ )}
+ {r.labels && Object.keys(r.labels).length > 0 && (
+
+ {Object.entries(r.labels).map(([k, v]) => (
+
+ {k}: {v}
+
+ ))}
+
+ )}
+ {/* {Object.keys(r.annotations).length > 0 && (
+
+ {Object.entries(r.annotations).map(([k, v]) => (
+
+ {k}: {v}
+
+ ))}
+
+ )} */}
+
+
+ ))}
+
+
+
+ ))}
+ >
+ );
+}
diff --git a/web/ui/mantine-ui/src/pages/service-discovery.tsx b/web/ui/mantine-ui/src/pages/service-discovery.tsx
new file mode 100644
index 0000000000..fff68c3a9e
--- /dev/null
+++ b/web/ui/mantine-ui/src/pages/service-discovery.tsx
@@ -0,0 +1,3 @@
+export default function ServiceDiscovery() {
+ return <>ServiceDiscovery page>;
+}
diff --git a/web/ui/mantine-ui/src/pages/status.tsx b/web/ui/mantine-ui/src/pages/status.tsx
new file mode 100644
index 0000000000..327cc04204
--- /dev/null
+++ b/web/ui/mantine-ui/src/pages/status.tsx
@@ -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>(`/status/buildinfo`);
+ const { data: runtimeinfo } =
+ useSuspenseAPIQuery>(`/status/runtimeinfo`);
+
+ return (
+
+
+
+
+
+ Build information
+
+
+
+
+ {Object.entries(buildinfo.data).map(([k, v]) => (
+
+ {k}
+ {v}
+
+ ))}
+
+
+
+
+
+
+
+ Runtime information
+
+
+
+
+ {Object.entries(runtimeinfo.data).map(([k, v]) => {
+ const { title = k, formatValue = (val: string) => val } =
+ statusConfig[k] || {};
+ return (
+
+
+ {title}
+
+ {formatValue(v)}
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/web/ui/mantine-ui/src/pages/targets.tsx b/web/ui/mantine-ui/src/pages/targets.tsx
new file mode 100644
index 0000000000..7668cbc6e9
--- /dev/null
+++ b/web/ui/mantine-ui/src/pages/targets.tsx
@@ -0,0 +1,3 @@
+export default function Targets() {
+ return <>Targets page>;
+}
diff --git a/web/ui/mantine-ui/src/pages/tsdb-status.tsx b/web/ui/mantine-ui/src/pages/tsdb-status.tsx
new file mode 100644
index 0000000000..1bdbd03ecc
--- /dev/null
+++ b/web/ui/mantine-ui/src/pages/tsdb-status.tsx
@@ -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(`/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 (
+
+ {[
+ {
+ 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 }) => (
+
+
+
+ {title}
+
+
+
+
+
+ Name
+ {unit}
+
+
+
+ {stats.map(({ name, value }) => {
+ return (
+
+
+ {formatAsCode ? {name}
: name}
+
+ {value}
+
+ );
+ })}
+
+
+
+ ))}
+
+ );
+}
diff --git a/web/ui/mantine-ui/src/settings.ts b/web/ui/mantine-ui/src/settings.ts
new file mode 100644
index 0000000000..1595870a73
--- /dev/null
+++ b/web/ui/mantine-ui/src/settings.ts
@@ -0,0 +1,13 @@
+import { createContext } from "react";
+
+export interface Settings {
+ consolesLink: string | null;
+ agentMode: boolean;
+ ready: boolean;
+}
+
+export const SettingsContext = createContext({
+ consolesLink: null,
+ agentMode: false,
+ ready: false,
+});
diff --git a/web/ui/mantine-ui/src/theme-selector.tsx b/web/ui/mantine-ui/src/theme-selector.tsx
new file mode 100644
index 0000000000..f01aa91f7c
--- /dev/null
+++ b/web/ui/mantine-ui/src/theme-selector.tsx
@@ -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 (
+
+ setColorScheme(v as MantineColorScheme)}
+ data={[
+ {
+ value: "light",
+ label: (
+
+
+
+ ),
+ },
+ {
+ value: "dark",
+ label: (
+
+
+
+ ),
+ },
+ {
+ value: "auto",
+ label: (
+
+
+
+ ),
+ },
+ ]}
+ />
+
+ );
+};
diff --git a/web/ui/mantine-ui/vite.config.ts b/web/ui/mantine-ui/vite.config.ts
index 5a33944a9b..0ed5aa400d 100644
--- a/web/ui/mantine-ui/vite.config.ts
+++ b/web/ui/mantine-ui/vite.config.ts
@@ -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",
+ },
+ },
+ },
+});