Signed-off-by: Julius Volz <julius.volz@gmail.com>pull/14448/head
parent
89ecb3a3f2
commit
2bb14c5787
File diff suppressed because it is too large
Load Diff
@ -1,20 +1,10 @@ |
||||
import { |
||||
Alert, |
||||
Badge, |
||||
Card, |
||||
Group, |
||||
useComputedColorScheme, |
||||
} from "@mantine/core"; |
||||
import { |
||||
IconAlertTriangle, |
||||
IconClockPause, |
||||
IconClockPlay, |
||||
} from "@tabler/icons-react"; |
||||
import { Badge, Card, Group, useComputedColorScheme } from "@mantine/core"; |
||||
import { IconClockPause, IconClockPlay } from "@tabler/icons-react"; |
||||
import { FC } from "react"; |
||||
import { formatDuration } from "./lib/time-format"; |
||||
import { formatDuration } from "./lib/formatTime"; |
||||
import codeboxClasses from "./codebox.module.css"; |
||||
import badgeClasses from "./badge.module.css"; |
||||
import { Rule } from "./api/response-types/rules"; |
||||
import badgeClasses from "./Badge.module.css"; |
||||
import { Rule } from "./api/responseTypes/rules"; |
||||
import CodeMirror, { EditorView } from "@uiw/react-codemirror"; |
||||
import { syntaxHighlighting } from "@codemirror/language"; |
||||
import { |
||||
@ -0,0 +1,64 @@ |
||||
import { |
||||
useMantineColorScheme, |
||||
SegmentedControl, |
||||
rem, |
||||
MantineColorScheme, |
||||
Tooltip, |
||||
} from "@mantine/core"; |
||||
import { |
||||
IconMoonFilled, |
||||
IconSunFilled, |
||||
IconUserFilled, |
||||
} from "@tabler/icons-react"; |
||||
import { FC } from "react"; |
||||
|
||||
export const ThemeSelector: FC = () => { |
||||
const { colorScheme, setColorScheme } = useMantineColorScheme(); |
||||
const iconProps = { |
||||
style: { width: rem(20), height: rem(20), display: "block" }, |
||||
stroke: 1.5, |
||||
}; |
||||
|
||||
return ( |
||||
<SegmentedControl |
||||
color="gray.7" |
||||
size="xs" |
||||
// styles={{ root: { backgroundColor: "var(--mantine-color-gray-7)" } }}
|
||||
styles={{ |
||||
root: { |
||||
padding: 3, |
||||
backgroundColor: "var(--mantine-color-gray-6)", |
||||
}, |
||||
}} |
||||
withItemsBorders={false} |
||||
value={colorScheme} |
||||
onChange={(v) => setColorScheme(v as MantineColorScheme)} |
||||
data={[ |
||||
{ |
||||
value: "light", |
||||
label: ( |
||||
<Tooltip label="Use light theme" offset={15}> |
||||
<IconSunFilled {...iconProps} /> |
||||
</Tooltip> |
||||
), |
||||
}, |
||||
{ |
||||
value: "dark", |
||||
label: ( |
||||
<Tooltip label="Use dark theme" offset={15}> |
||||
<IconMoonFilled {...iconProps} /> |
||||
</Tooltip> |
||||
), |
||||
}, |
||||
{ |
||||
value: "auto", |
||||
label: ( |
||||
<Tooltip label="Use browser-preferred theme" offset={15}> |
||||
<IconUserFilled {...iconProps} /> |
||||
</Tooltip> |
||||
), |
||||
}, |
||||
]} |
||||
/> |
||||
); |
||||
}; |
||||
@ -1,32 +1,94 @@ |
||||
import { useSuspenseQuery } from "@tanstack/react-query"; |
||||
import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; |
||||
|
||||
export const API_PATH = "api/v1"; |
||||
|
||||
export type APIResponse<T> = { status: string; data: T }; |
||||
export type SuccessAPIResponse<T> = { |
||||
status: "success"; |
||||
data: T; |
||||
warnings?: string[]; |
||||
}; |
||||
|
||||
export const useSuspenseAPIQuery = <T>(path: string) => |
||||
useSuspenseQuery<{ data: T }>({ |
||||
export type ErrorAPIResponse = { |
||||
status: "error"; |
||||
errorType: string; |
||||
error: string; |
||||
}; |
||||
|
||||
export type APIResponse<T> = SuccessAPIResponse<T> | ErrorAPIResponse; |
||||
|
||||
export const useAPIQuery = <T>({ |
||||
key, |
||||
path, |
||||
params, |
||||
enabled, |
||||
}: { |
||||
key?: string; |
||||
path: string; |
||||
params?: Record<string, string>; |
||||
enabled?: boolean; |
||||
}) => |
||||
useQuery<APIResponse<T>>({ |
||||
queryKey: [key || path], |
||||
retry: false, |
||||
refetchOnWindowFocus: false, |
||||
gcTime: 0, |
||||
enabled, |
||||
queryFn: async ({ signal }) => { |
||||
const queryString = params |
||||
? `?${new URLSearchParams(params).toString()}` |
||||
: ""; |
||||
return ( |
||||
fetch(`/${API_PATH}/${path}${queryString}`, { |
||||
cache: "no-store", |
||||
credentials: "same-origin", |
||||
signal, |
||||
}) |
||||
// TODO: think about how to check API errors here, if this code remains in use.
|
||||
.then((res) => { |
||||
if (!res.ok) { |
||||
throw new Error(res.statusText); |
||||
} |
||||
return res; |
||||
}) |
||||
.then((res) => res.json() as Promise<APIResponse<T>>) |
||||
); |
||||
}, |
||||
}); |
||||
|
||||
export const useSuspenseAPIQuery = <T>( |
||||
path: string, |
||||
params?: Record<string, string> |
||||
) => |
||||
useSuspenseQuery<SuccessAPIResponse<T>>({ |
||||
queryKey: [path], |
||||
retry: false, |
||||
refetchOnWindowFocus: false, |
||||
gcTime: 0, |
||||
queryFn: () => |
||||
fetch(`/${API_PATH}/${path}`, { |
||||
cache: "no-store", |
||||
credentials: "same-origin", |
||||
}) |
||||
// Introduce 3 seconds delay to simulate slow network.
|
||||
// .then(
|
||||
// (res) =>
|
||||
// new Promise<typeof res>((resolve) =>
|
||||
// setTimeout(() => resolve(res), 2000)
|
||||
// )
|
||||
// )
|
||||
.then((res) => { |
||||
if (!res.ok) { |
||||
throw new Error(res.statusText); |
||||
} |
||||
return res; |
||||
queryFn: ({ signal }) => { |
||||
const queryString = params |
||||
? `?${new URLSearchParams(params).toString()}` |
||||
: ""; |
||||
return ( |
||||
fetch(`/${API_PATH}/${path}${queryString}`, { |
||||
cache: "no-store", |
||||
credentials: "same-origin", |
||||
signal, |
||||
}) |
||||
.then((res) => res.json() as Promise<APIResponse<T>>), |
||||
// Introduce 3 seconds delay to simulate slow network.
|
||||
// .then(
|
||||
// (res) =>
|
||||
// new Promise<typeof res>((resolve) =>
|
||||
// setTimeout(() => resolve(res), 2000)
|
||||
// )
|
||||
// )
|
||||
// TODO: think about how to check API errors here, if this code remains in use.
|
||||
.then((res) => { |
||||
if (!res.ok) { |
||||
throw new Error(res.statusText); |
||||
} |
||||
return res; |
||||
}) |
||||
.then((res) => res.json() as Promise<SuccessAPIResponse<T>>) |
||||
); |
||||
}, |
||||
}); |
||||
|
||||
@ -0,0 +1,42 @@ |
||||
export interface Metric { |
||||
[key: string]: string; |
||||
} |
||||
|
||||
export interface Histogram { |
||||
count: string; |
||||
sum: string; |
||||
buckets?: [number, string, string, string][]; |
||||
} |
||||
|
||||
export interface InstantSample { |
||||
metric: Metric; |
||||
value?: SampleValue; |
||||
histogram?: SampleHistogram; |
||||
} |
||||
|
||||
export interface RangeSamples { |
||||
metric: Metric; |
||||
values?: SampleValue[]; |
||||
histograms?: SampleHistogram[]; |
||||
} |
||||
|
||||
export type SampleValue = [number, string]; |
||||
export type SampleHistogram = [number, Histogram]; |
||||
|
||||
export type InstantQueryResult = |
||||
| { |
||||
resultType: "vector"; |
||||
result: InstantSample[]; |
||||
} |
||||
| { |
||||
resultType: "matrix"; |
||||
result: RangeSamples[]; |
||||
} |
||||
| { |
||||
resultType: "scalar"; |
||||
result: SampleValue; |
||||
} |
||||
| { |
||||
resultType: "string"; |
||||
result: SampleValue; |
||||
}; |
||||
@ -0,0 +1,3 @@ |
||||
export const escapeString = (str: string) => { |
||||
return str.replace(/([\\"])/g, "\\$1"); |
||||
}; |
||||
@ -0,0 +1,12 @@ |
||||
import { escapeString } from "./escapeString"; |
||||
|
||||
export const formatSeries = (labels: { [key: string]: string }): string => { |
||||
if (labels === null) { |
||||
return "scalar"; |
||||
} |
||||
|
||||
return `${labels.__name__ || ""}{${Object.entries(labels) |
||||
.filter(([k]) => k !== "__name__") |
||||
.map(([k, v]) => `${k}="${escapeString(v)}"`) |
||||
.join(", ")}}`;
|
||||
}; |
||||
@ -0,0 +1,3 @@ |
||||
export default function ServiceDiscoveryPage() { |
||||
return <>ServiceDiscovery page</>; |
||||
} |
||||
@ -0,0 +1,3 @@ |
||||
export default function TargetsPage() { |
||||
return <>Targets page</>; |
||||
} |
||||
@ -1,27 +0,0 @@ |
||||
import { Group, Textarea, Button } from "@mantine/core"; |
||||
import { IconTerminal } from "@tabler/icons-react"; |
||||
import { useState } from "react"; |
||||
import classes from "./graph.module.css"; |
||||
|
||||
export default function Graph() { |
||||
const [expr, setExpr] = useState<string>(""); |
||||
|
||||
return ( |
||||
<Group align="baseline" wrap="nowrap" gap="xs" mt="sm"> |
||||
<Textarea |
||||
style={{ flex: "auto" }} |
||||
classNames={classes} |
||||
placeholder="Enter PromQL expression..." |
||||
value={expr} |
||||
onChange={(event) => setExpr(event.currentTarget.value)} |
||||
leftSection={<IconTerminal />} |
||||
rightSectionPointerEvents="all" |
||||
autosize |
||||
autoFocus |
||||
/> |
||||
<Button variant="primary" onClick={() => console.log(expr)}> |
||||
Execute |
||||
</Button> |
||||
</Group> |
||||
); |
||||
} |
||||
@ -0,0 +1,173 @@ |
||||
import { FC, useEffect, useId } from "react"; |
||||
import { Table, Alert, Skeleton, Box, LoadingOverlay } from "@mantine/core"; |
||||
import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react"; |
||||
import { |
||||
InstantQueryResult, |
||||
InstantSample, |
||||
RangeSamples, |
||||
} from "../../api/responseTypes/query"; |
||||
import SeriesName from "./SeriesName"; |
||||
import { useAPIQuery } from "../../api/api"; |
||||
|
||||
const maxFormattableSeries = 1000; |
||||
const maxDisplayableSeries = 10000; |
||||
|
||||
const limitSeries = <S extends InstantSample | RangeSamples>( |
||||
series: S[] |
||||
): S[] => { |
||||
if (series.length > maxDisplayableSeries) { |
||||
return series.slice(0, maxDisplayableSeries); |
||||
} |
||||
return series; |
||||
}; |
||||
|
||||
export interface TableProps { |
||||
expr: string; |
||||
evalTime: number | null; |
||||
retriggerIdx: number; |
||||
} |
||||
|
||||
const DataTable: FC<TableProps> = ({ expr, evalTime, retriggerIdx }) => { |
||||
// const now = useMemo(() => Date.now() / 1000, [retriggerIdx]);
|
||||
// const { data, error, isFetching, isLoading } = useInstantQueryQuery(
|
||||
// {
|
||||
// query: expr,
|
||||
// time: evalTime !== null ? evalTime / 1000 : now,
|
||||
// },
|
||||
// { skip: !expr }
|
||||
// );
|
||||
|
||||
// const now = useMemo(() => Date.now() / 1000, [retriggerIdx]);
|
||||
const { data, error, isFetching, isLoading, refetch } = |
||||
useAPIQuery<InstantQueryResult>({ |
||||
key: useId(), |
||||
path: "/query", |
||||
params: { |
||||
query: expr, |
||||
time: `${(evalTime !== null ? evalTime : Date.now()) / 1000}`, |
||||
}, |
||||
enabled: expr !== "", |
||||
}); |
||||
|
||||
useEffect(() => { |
||||
expr !== "" && refetch(); |
||||
}, [retriggerIdx, refetch, expr, evalTime]); |
||||
|
||||
// Show a skeleton only on the first load, not on subsequent ones.
|
||||
if (isLoading) { |
||||
return ( |
||||
<Box> |
||||
{Array.from(Array(5), (_, i) => ( |
||||
<Skeleton key={i} height={30} mb={15} /> |
||||
))} |
||||
</Box> |
||||
); |
||||
} |
||||
|
||||
if (error) { |
||||
return ( |
||||
<Alert |
||||
color="red" |
||||
title="Error executing query" |
||||
icon={<IconAlertTriangle size={14} />} |
||||
> |
||||
{error.message} |
||||
</Alert> |
||||
); |
||||
} |
||||
|
||||
if (data === undefined) { |
||||
return <Alert variant="transparent">No data queried yet</Alert>; |
||||
} |
||||
|
||||
if (data.status !== "success") { |
||||
// TODO: Remove this case and handle it in useAPIQuery instead!
|
||||
return null; |
||||
} |
||||
|
||||
const { result, resultType } = data.data; |
||||
|
||||
if (result.length === 0) { |
||||
return ( |
||||
<Alert |
||||
// color="light"
|
||||
title="Empty query result" |
||||
icon={<IconInfoCircle size={14} />} |
||||
> |
||||
This query returned no data. |
||||
</Alert> |
||||
); |
||||
} |
||||
|
||||
const doFormat = result.length <= maxFormattableSeries; |
||||
|
||||
return ( |
||||
<Box pos="relative" mt="lg"> |
||||
<LoadingOverlay |
||||
visible={isFetching} |
||||
zIndex={1000} |
||||
overlayProps={{ radius: "sm", blur: 1 }} |
||||
loaderProps={{ |
||||
children: <Skeleton m={0} w="100%" h="100%" />, |
||||
}} |
||||
styles={{ loader: { width: "100%", height: "100%" } }} |
||||
/> |
||||
<Table highlightOnHover> |
||||
<Table.Tbody> |
||||
{resultType === "vector" ? ( |
||||
limitSeries<InstantSample>(result).map((s, idx) => ( |
||||
<Table.Tr key={idx}> |
||||
<Table.Td> |
||||
<SeriesName labels={s.metric} format={doFormat} /> |
||||
</Table.Td> |
||||
<Table.Td> |
||||
{s.value && s.value[1]} |
||||
{s.histogram && "TODO HISTOGRAM DISPLAY"} |
||||
</Table.Td> |
||||
</Table.Tr> |
||||
)) |
||||
) : resultType === "matrix" ? ( |
||||
limitSeries<RangeSamples>(result).map((s, idx) => ( |
||||
<Table.Tr key={idx}> |
||||
<Table.Td> |
||||
<SeriesName labels={s.metric} format={doFormat} /> |
||||
</Table.Td> |
||||
<Table.Td> |
||||
{s.values && |
||||
s.values.map((v, idx) => ( |
||||
<div key={idx}> |
||||
{v[1]} @ {v[0]} |
||||
</div> |
||||
))} |
||||
</Table.Td> |
||||
</Table.Tr> |
||||
)) |
||||
) : resultType === "scalar" ? ( |
||||
<Table.Tr> |
||||
<Table.Td>Scalar value</Table.Td> |
||||
<Table.Td>{result[1]}</Table.Td> |
||||
</Table.Tr> |
||||
) : resultType === "string" ? ( |
||||
<Table.Tr> |
||||
<Table.Td>String value</Table.Td> |
||||
<Table.Td>{result[1]}</Table.Td> |
||||
</Table.Tr> |
||||
) : ( |
||||
<Alert |
||||
color="red" |
||||
title="Invalid query response" |
||||
icon={<IconAlertTriangle size={14} />} |
||||
maw={500} |
||||
mx="auto" |
||||
mt="lg" |
||||
> |
||||
Invalid result value type |
||||
</Alert> |
||||
)} |
||||
</Table.Tbody> |
||||
</Table> |
||||
</Box> |
||||
); |
||||
}; |
||||
|
||||
export default DataTable; |
||||
@ -0,0 +1,13 @@ |
||||
.input { |
||||
/* border: calc(0.0625rem * var(--mantine-scale)) solid var(--input-bd); */ |
||||
border-radius: var(--mantine-radius-default); |
||||
flex: auto; |
||||
/* padding: 4px 0 0 8px; */ |
||||
/* font-size: 15px; */ |
||||
/* font-family: "DejaVu Sans Mono"; */ |
||||
|
||||
&:focus-within { |
||||
outline: rem(1.3px) solid var(--mantine-color-blue-filled); |
||||
border-color: transparent; |
||||
} |
||||
} |
||||
@ -0,0 +1,257 @@ |
||||
import { |
||||
ActionIcon, |
||||
Button, |
||||
Group, |
||||
InputBase, |
||||
Menu, |
||||
rem, |
||||
useComputedColorScheme, |
||||
} from "@mantine/core"; |
||||
import { |
||||
CompleteStrategy, |
||||
PromQLExtension, |
||||
newCompleteStrategy, |
||||
} from "@prometheus-io/codemirror-promql"; |
||||
import { FC, useEffect, useState } from "react"; |
||||
import CodeMirror, { |
||||
EditorState, |
||||
EditorView, |
||||
Prec, |
||||
highlightSpecialChars, |
||||
keymap, |
||||
placeholder, |
||||
} from "@uiw/react-codemirror"; |
||||
import { |
||||
baseTheme, |
||||
darkPromqlHighlighter, |
||||
darkTheme, |
||||
lightTheme, |
||||
promqlHighlighter, |
||||
} from "../../codemirror/theme"; |
||||
import { |
||||
bracketMatching, |
||||
indentOnInput, |
||||
syntaxHighlighting, |
||||
syntaxTree, |
||||
} from "@codemirror/language"; |
||||
import classes from "./ExpressionInput.module.css"; |
||||
import { |
||||
CompletionContext, |
||||
CompletionResult, |
||||
autocompletion, |
||||
closeBrackets, |
||||
closeBracketsKeymap, |
||||
completionKeymap, |
||||
} from "@codemirror/autocomplete"; |
||||
import { |
||||
defaultKeymap, |
||||
history, |
||||
historyKeymap, |
||||
insertNewlineAndIndent, |
||||
} from "@codemirror/commands"; |
||||
import { highlightSelectionMatches } from "@codemirror/search"; |
||||
import { lintKeymap } from "@codemirror/lint"; |
||||
import { |
||||
IconAlignJustified, |
||||
IconDotsVertical, |
||||
IconSearch, |
||||
IconTerminal, |
||||
IconTrash, |
||||
} from "@tabler/icons-react"; |
||||
|
||||
const promqlExtension = new PromQLExtension(); |
||||
|
||||
// Autocompletion strategy that wraps the main one and enriches
|
||||
// it with past query items.
|
||||
export class HistoryCompleteStrategy implements CompleteStrategy { |
||||
private complete: CompleteStrategy; |
||||
private queryHistory: string[]; |
||||
constructor(complete: CompleteStrategy, queryHistory: string[]) { |
||||
this.complete = complete; |
||||
this.queryHistory = queryHistory; |
||||
} |
||||
|
||||
promQL( |
||||
context: CompletionContext |
||||
): Promise<CompletionResult | null> | CompletionResult | null { |
||||
return Promise.resolve(this.complete.promQL(context)).then((res) => { |
||||
const { state, pos } = context; |
||||
const tree = syntaxTree(state).resolve(pos, -1); |
||||
const start = res != null ? res.from : tree.from; |
||||
|
||||
if (start !== 0) { |
||||
return res; |
||||
} |
||||
|
||||
const historyItems: CompletionResult = { |
||||
from: start, |
||||
to: pos, |
||||
options: this.queryHistory.map((q) => ({ |
||||
label: q.length < 80 ? q : q.slice(0, 76).concat("..."), |
||||
detail: "past query", |
||||
apply: q, |
||||
info: q.length < 80 ? undefined : q, |
||||
})), |
||||
validFor: /^[a-zA-Z0-9_:]+$/, |
||||
}; |
||||
|
||||
if (res !== null) { |
||||
historyItems.options = historyItems.options.concat(res.options); |
||||
} |
||||
return historyItems; |
||||
}); |
||||
} |
||||
} |
||||
|
||||
interface ExpressionInputProps { |
||||
initialExpr: string; |
||||
executeQuery: (expr: string) => void; |
||||
} |
||||
|
||||
const ExpressionInput: FC<ExpressionInputProps> = ({ |
||||
initialExpr, |
||||
executeQuery, |
||||
}) => { |
||||
const theme = useComputedColorScheme(); |
||||
const [expr, setExpr] = useState(initialExpr); |
||||
useEffect(() => { |
||||
setExpr(initialExpr); |
||||
}, [initialExpr]); |
||||
|
||||
// TODO: make dynamic:
|
||||
const enableAutocomplete = true; |
||||
const enableLinter = true; |
||||
const pathPrefix = ""; |
||||
// const metricNames = ...
|
||||
const queryHistory = [] as string[]; |
||||
|
||||
// (Re)initialize editor based on settings / setting changes.
|
||||
useEffect(() => { |
||||
// Build the dynamic part of the config.
|
||||
promqlExtension |
||||
.activateCompletion(enableAutocomplete) |
||||
.activateLinter(enableLinter) |
||||
.setComplete({ |
||||
completeStrategy: new HistoryCompleteStrategy( |
||||
newCompleteStrategy({ |
||||
remote: { |
||||
url: pathPrefix, |
||||
//cache: { initialMetricList: metricNames },
|
||||
}, |
||||
}), |
||||
queryHistory |
||||
), |
||||
}); |
||||
}, []); // TODO: Make this depend on external settings changes, maybe use dynamic config compartment again.
|
||||
|
||||
return ( |
||||
<Group align="flex-start" wrap="nowrap" gap="xs"> |
||||
<InputBase<any> |
||||
leftSection={<IconTerminal />} |
||||
rightSection={ |
||||
<Menu shadow="md" width={200}> |
||||
<Menu.Target> |
||||
<ActionIcon |
||||
size="lg" |
||||
variant="transparent" |
||||
color="gray" |
||||
aria-label="Decrease range" |
||||
> |
||||
<IconDotsVertical style={{ width: "1rem", height: "1rem" }} /> |
||||
</ActionIcon> |
||||
</Menu.Target> |
||||
<Menu.Dropdown> |
||||
<Menu.Label>Query options</Menu.Label> |
||||
<Menu.Item |
||||
leftSection={ |
||||
<IconSearch style={{ width: rem(14), height: rem(14) }} /> |
||||
} |
||||
> |
||||
Explore metrics |
||||
</Menu.Item> |
||||
<Menu.Item |
||||
leftSection={ |
||||
<IconAlignJustified |
||||
style={{ width: rem(14), height: rem(14) }} |
||||
/> |
||||
} |
||||
> |
||||
Format expression |
||||
</Menu.Item> |
||||
<Menu.Item |
||||
color="red" |
||||
leftSection={ |
||||
<IconTrash style={{ width: rem(14), height: rem(14) }} /> |
||||
} |
||||
> |
||||
Remove query |
||||
</Menu.Item> |
||||
</Menu.Dropdown> |
||||
</Menu> |
||||
} |
||||
component={CodeMirror} |
||||
className={classes.input} |
||||
basicSetup={false} |
||||
value={expr} |
||||
onChange={setExpr} |
||||
autoFocus |
||||
extensions={[ |
||||
baseTheme, |
||||
highlightSpecialChars(), |
||||
history(), |
||||
EditorState.allowMultipleSelections.of(true), |
||||
indentOnInput(), |
||||
bracketMatching(), |
||||
closeBrackets(), |
||||
autocompletion(), |
||||
highlightSelectionMatches(), |
||||
EditorView.lineWrapping, |
||||
keymap.of([ |
||||
...closeBracketsKeymap, |
||||
...defaultKeymap, |
||||
...historyKeymap, |
||||
...completionKeymap, |
||||
...lintKeymap, |
||||
]), |
||||
placeholder("Enter expression (press Shift+Enter for newlines)"), |
||||
syntaxHighlighting( |
||||
theme === "light" ? promqlHighlighter : darkPromqlHighlighter |
||||
), |
||||
promqlExtension.asExtension(), |
||||
theme === "light" ? lightTheme : darkTheme, |
||||
keymap.of([ |
||||
{ |
||||
key: "Escape", |
||||
run: (v: EditorView): boolean => { |
||||
v.contentDOM.blur(); |
||||
return false; |
||||
}, |
||||
}, |
||||
]), |
||||
Prec.highest( |
||||
keymap.of([ |
||||
{ |
||||
key: "Enter", |
||||
run: (): boolean => { |
||||
executeQuery(expr); |
||||
return true; |
||||
}, |
||||
}, |
||||
{ |
||||
key: "Shift-Enter", |
||||
run: insertNewlineAndIndent, |
||||
}, |
||||
]) |
||||
), |
||||
]} |
||||
multiline |
||||
/> |
||||
|
||||
<Button variant="primary" onClick={() => executeQuery(expr)}> |
||||
Execute |
||||
</Button> |
||||
</Group> |
||||
); |
||||
}; |
||||
|
||||
export default ExpressionInput; |
||||
@ -0,0 +1,367 @@ |
||||
import React, { FC, useState, useEffect, useRef } from "react"; |
||||
|
||||
import { |
||||
EditorView, |
||||
highlightSpecialChars, |
||||
keymap, |
||||
ViewUpdate, |
||||
placeholder, |
||||
} from "@codemirror/view"; |
||||
import { EditorState, Prec, Compartment } from "@codemirror/state"; |
||||
import { |
||||
bracketMatching, |
||||
indentOnInput, |
||||
syntaxHighlighting, |
||||
syntaxTree, |
||||
} from "@codemirror/language"; |
||||
import { |
||||
defaultKeymap, |
||||
history, |
||||
historyKeymap, |
||||
insertNewlineAndIndent, |
||||
} from "@codemirror/commands"; |
||||
import { highlightSelectionMatches } from "@codemirror/search"; |
||||
import { lintKeymap } from "@codemirror/lint"; |
||||
import { |
||||
autocompletion, |
||||
completionKeymap, |
||||
CompletionContext, |
||||
CompletionResult, |
||||
closeBrackets, |
||||
closeBracketsKeymap, |
||||
} from "@codemirror/autocomplete"; |
||||
import { |
||||
baseTheme, |
||||
lightTheme, |
||||
darkTheme, |
||||
promqlHighlighter, |
||||
darkPromqlHighlighter, |
||||
} from "../../codemirror/theme"; |
||||
|
||||
import { |
||||
CompleteStrategy, |
||||
PromQLExtension, |
||||
} from "@prometheus-io/codemirror-promql"; |
||||
import { newCompleteStrategy } from "@prometheus-io/codemirror-promql/dist/esm/complete"; |
||||
|
||||
const promqlExtension = new PromQLExtension(); |
||||
|
||||
interface ExpressionInputProps { |
||||
value: string; |
||||
onChange: (expr: string) => void; |
||||
queryHistory: string[]; |
||||
metricNames: string[]; |
||||
executeQuery: () => void; |
||||
} |
||||
|
||||
const dynamicConfigCompartment = new Compartment(); |
||||
|
||||
// Autocompletion strategy that wraps the main one and enriches
|
||||
// it with past query items.
|
||||
export class HistoryCompleteStrategy implements CompleteStrategy { |
||||
private complete: CompleteStrategy; |
||||
private queryHistory: string[]; |
||||
constructor(complete: CompleteStrategy, queryHistory: string[]) { |
||||
this.complete = complete; |
||||
this.queryHistory = queryHistory; |
||||
} |
||||
|
||||
promQL( |
||||
context: CompletionContext |
||||
): Promise<CompletionResult | null> | CompletionResult | null { |
||||
return Promise.resolve(this.complete.promQL(context)).then((res) => { |
||||
const { state, pos } = context; |
||||
const tree = syntaxTree(state).resolve(pos, -1); |
||||
const start = res != null ? res.from : tree.from; |
||||
|
||||
if (start !== 0) { |
||||
return res; |
||||
} |
||||
|
||||
const historyItems: CompletionResult = { |
||||
from: start, |
||||
to: pos, |
||||
options: this.queryHistory.map((q) => ({ |
||||
label: q.length < 80 ? q : q.slice(0, 76).concat("..."), |
||||
detail: "past query", |
||||
apply: q, |
||||
info: q.length < 80 ? undefined : q, |
||||
})), |
||||
validFor: /^[a-zA-Z0-9_:]+$/, |
||||
}; |
||||
|
||||
if (res !== null) { |
||||
historyItems.options = historyItems.options.concat(res.options); |
||||
} |
||||
return historyItems; |
||||
}); |
||||
} |
||||
} |
||||
|
||||
const ExpressionInput: FC<ExpressionInputProps> = ({ |
||||
value, |
||||
onChange, |
||||
queryHistory, |
||||
metricNames, |
||||
}) => { |
||||
const containerRef = useRef<HTMLDivElement>(null); |
||||
const viewRef = useRef<EditorView | null>(null); |
||||
const [showMetricsExplorer, setShowMetricsExplorer] = |
||||
useState<boolean>(false); |
||||
const pathPrefix = usePathPrefix(); |
||||
const { theme } = useTheme(); |
||||
|
||||
const [formatError, setFormatError] = useState<string | null>(null); |
||||
const [isFormatting, setIsFormatting] = useState<boolean>(false); |
||||
const [exprFormatted, setExprFormatted] = useState<boolean>(false); |
||||
|
||||
// (Re)initialize editor based on settings / setting changes.
|
||||
useEffect(() => { |
||||
// Build the dynamic part of the config.
|
||||
promqlExtension |
||||
.activateCompletion(enableAutocomplete) |
||||
.activateLinter(enableLinter) |
||||
.setComplete({ |
||||
completeStrategy: new HistoryCompleteStrategy( |
||||
newCompleteStrategy({ |
||||
remote: { |
||||
url: pathPrefix, |
||||
cache: { initialMetricList: metricNames }, |
||||
}, |
||||
}), |
||||
queryHistory |
||||
), |
||||
}); |
||||
|
||||
let highlighter = syntaxHighlighting( |
||||
theme === "dark" ? darkPromqlHighlighter : promqlHighlighter |
||||
); |
||||
if (theme === "dark") { |
||||
highlighter = syntaxHighlighting(darkPromqlHighlighter); |
||||
} |
||||
|
||||
const dynamicConfig = [ |
||||
enableHighlighting ? highlighter : [], |
||||
promqlExtension.asExtension(), |
||||
theme === "dark" ? darkTheme : lightTheme, |
||||
]; |
||||
|
||||
// Create or reconfigure the editor.
|
||||
const view = viewRef.current; |
||||
if (view === null) { |
||||
// If the editor does not exist yet, create it.
|
||||
if (!containerRef.current) { |
||||
throw new Error("expected CodeMirror container element to exist"); |
||||
} |
||||
|
||||
const startState = EditorState.create({ |
||||
doc: value, |
||||
extensions: [ |
||||
baseTheme, |
||||
highlightSpecialChars(), |
||||
history(), |
||||
EditorState.allowMultipleSelections.of(true), |
||||
indentOnInput(), |
||||
bracketMatching(), |
||||
closeBrackets(), |
||||
autocompletion(), |
||||
highlightSelectionMatches(), |
||||
EditorView.lineWrapping, |
||||
keymap.of([ |
||||
...closeBracketsKeymap, |
||||
...defaultKeymap, |
||||
...historyKeymap, |
||||
...completionKeymap, |
||||
...lintKeymap, |
||||
]), |
||||
placeholder("Expression (press Shift+Enter for newlines)"), |
||||
dynamicConfigCompartment.of(dynamicConfig), |
||||
// This keymap is added without precedence so that closing the autocomplete dropdown
|
||||
// via Escape works without blurring the editor.
|
||||
keymap.of([ |
||||
{ |
||||
key: "Escape", |
||||
run: (v: EditorView): boolean => { |
||||
v.contentDOM.blur(); |
||||
return false; |
||||
}, |
||||
}, |
||||
]), |
||||
Prec.highest( |
||||
keymap.of([ |
||||
{ |
||||
key: "Enter", |
||||
run: (v: EditorView): boolean => { |
||||
executeQuery(); |
||||
return true; |
||||
}, |
||||
}, |
||||
{ |
||||
key: "Shift-Enter", |
||||
run: insertNewlineAndIndent, |
||||
}, |
||||
]) |
||||
), |
||||
EditorView.updateListener.of((update: ViewUpdate): void => { |
||||
if (update.docChanged) { |
||||
onExpressionChange(update.state.doc.toString()); |
||||
setExprFormatted(false); |
||||
} |
||||
}), |
||||
], |
||||
}); |
||||
|
||||
const view = new EditorView({ |
||||
state: startState, |
||||
parent: containerRef.current, |
||||
}); |
||||
|
||||
viewRef.current = view; |
||||
|
||||
view.focus(); |
||||
} else { |
||||
// The editor already exists, just reconfigure the dynamically configured parts.
|
||||
view.dispatch( |
||||
view.state.update({ |
||||
effects: dynamicConfigCompartment.reconfigure(dynamicConfig), |
||||
}) |
||||
); |
||||
} |
||||
// "value" is only used in the initial render, so we don't want to
|
||||
// re-run this effect every time that "value" changes.
|
||||
//
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ |
||||
enableAutocomplete, |
||||
enableHighlighting, |
||||
enableLinter, |
||||
executeQuery, |
||||
onExpressionChange, |
||||
queryHistory, |
||||
theme, |
||||
]); |
||||
|
||||
const insertAtCursor = (value: string) => { |
||||
const view = viewRef.current; |
||||
if (view === null) { |
||||
return; |
||||
} |
||||
const { from, to } = view.state.selection.ranges[0]; |
||||
view.dispatch( |
||||
view.state.update({ |
||||
changes: { from, to, insert: value }, |
||||
}) |
||||
); |
||||
}; |
||||
|
||||
const formatExpression = () => { |
||||
setFormatError(null); |
||||
setIsFormatting(true); |
||||
|
||||
fetch( |
||||
`${pathPrefix}/${API_PATH}/format_query?${new URLSearchParams({ |
||||
query: value, |
||||
})}`,
|
||||
{ |
||||
cache: "no-store", |
||||
credentials: "same-origin", |
||||
} |
||||
) |
||||
.then((resp) => { |
||||
if (!resp.ok && resp.status !== 400) { |
||||
throw new Error(`format HTTP request failed: ${resp.statusText}`); |
||||
} |
||||
|
||||
return resp.json(); |
||||
}) |
||||
.then((json) => { |
||||
if (json.status !== "success") { |
||||
throw new Error(json.error || "invalid response JSON"); |
||||
} |
||||
|
||||
const view = viewRef.current; |
||||
if (view === null) { |
||||
return; |
||||
} |
||||
|
||||
view.dispatch( |
||||
view.state.update({ |
||||
changes: { from: 0, to: view.state.doc.length, insert: json.data }, |
||||
}) |
||||
); |
||||
setExprFormatted(true); |
||||
}) |
||||
.catch((err) => { |
||||
setFormatError(err.message); |
||||
}) |
||||
.finally(() => { |
||||
setIsFormatting(false); |
||||
}); |
||||
}; |
||||
|
||||
return ( |
||||
<> |
||||
<InputGroup className="expression-input"> |
||||
<InputGroupAddon addonType="prepend"> |
||||
<InputGroupText> |
||||
{loading ? ( |
||||
<FontAwesomeIcon icon={faSpinner} spin /> |
||||
) : ( |
||||
<FontAwesomeIcon icon={faSearch} /> |
||||
)} |
||||
</InputGroupText> |
||||
</InputGroupAddon> |
||||
<div ref={containerRef} className="cm-expression-input" /> |
||||
<InputGroupAddon addonType="append"> |
||||
<Button |
||||
className="expression-input-action-btn" |
||||
title={ |
||||
isFormatting |
||||
? "Formatting expression" |
||||
: exprFormatted |
||||
? "Expression formatted" |
||||
: "Format expression" |
||||
} |
||||
onClick={formatExpression} |
||||
disabled={isFormatting || exprFormatted} |
||||
> |
||||
{isFormatting ? ( |
||||
<FontAwesomeIcon icon={faSpinner} spin /> |
||||
) : exprFormatted ? ( |
||||
<FontAwesomeIcon icon={faCheck} /> |
||||
) : ( |
||||
<FontAwesomeIcon icon={faIndent} /> |
||||
)} |
||||
</Button> |
||||
<Button |
||||
className="expression-input-action-btn" |
||||
title="Open metrics explorer" |
||||
onClick={() => setShowMetricsExplorer(true)} |
||||
> |
||||
<FontAwesomeIcon icon={faGlobeEurope} /> |
||||
</Button> |
||||
<Button |
||||
className="execute-btn" |
||||
color="primary" |
||||
onClick={executeQuery} |
||||
> |
||||
Execute |
||||
</Button> |
||||
</InputGroupAddon> |
||||
</InputGroup> |
||||
|
||||
{formatError && ( |
||||
<Alert color="danger">Error formatting expression: {formatError}</Alert> |
||||
)} |
||||
|
||||
<MetricsExplorer |
||||
show={showMetricsExplorer} |
||||
updateShow={setShowMetricsExplorer} |
||||
metrics={metricNames} |
||||
insertAtCursor={insertAtCursor} |
||||
/> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default ExpressionInput; |
||||
@ -0,0 +1,29 @@ |
||||
import { Button, Stack } from "@mantine/core"; |
||||
import { IconPlus } from "@tabler/icons-react"; |
||||
import { useAppDispatch, useAppSelector } from "../../state/hooks"; |
||||
import { addPanel } from "../../state/queryPageSlice"; |
||||
import Panel from "./QueryPanel"; |
||||
|
||||
export default function QueryPage() { |
||||
const panels = useAppSelector((state) => state.queryPage.panels); |
||||
const dispatch = useAppDispatch(); |
||||
|
||||
return ( |
||||
<> |
||||
<Stack gap="xl"> |
||||
{panels.map((p, idx) => ( |
||||
<Panel key={p.id} idx={idx} /> |
||||
))} |
||||
</Stack> |
||||
|
||||
<Button |
||||
variant="light" |
||||
mt="xl" |
||||
leftSection={<IconPlus size={18} />} |
||||
onClick={() => dispatch(addPanel())} |
||||
> |
||||
Add query |
||||
</Button> |
||||
</> |
||||
); |
||||
} |
||||
@ -0,0 +1,229 @@ |
||||
import { |
||||
Group, |
||||
Tabs, |
||||
Center, |
||||
Space, |
||||
Box, |
||||
Input, |
||||
SegmentedControl, |
||||
Stack, |
||||
} from "@mantine/core"; |
||||
import { |
||||
IconChartAreaFilled, |
||||
IconChartGridDots, |
||||
IconChartLine, |
||||
IconGraph, |
||||
IconTable, |
||||
} from "@tabler/icons-react"; |
||||
import { FC, useState } from "react"; |
||||
import { useAppDispatch, useAppSelector } from "../../state/hooks"; |
||||
import { |
||||
GraphDisplayMode, |
||||
setExpr, |
||||
setVisualizer, |
||||
} from "../../state/queryPageSlice"; |
||||
import DataTable from "./DataTable"; |
||||
import TimeInput from "./TimeInput"; |
||||
import RangeInput from "./RangeInput"; |
||||
import ExpressionInput from "./ExpressionInput"; |
||||
|
||||
export interface PanelProps { |
||||
idx: number; |
||||
} |
||||
|
||||
// TODO: This is duplicated everywhere, unify it.
|
||||
const iconStyle = { width: "0.9rem", height: "0.9rem" }; |
||||
|
||||
const QueryPanel: FC<PanelProps> = ({ idx }) => { |
||||
// Used to indicate to the selected display component that it should retrigger
|
||||
// the query, even if the expression has not changed (e.g. when the user presses
|
||||
// the "Execute" button or hits <Enter> again).
|
||||
const [retriggerIdx, setRetriggerIdx] = useState<number>(0); |
||||
|
||||
const panel = useAppSelector((state) => state.queryPage.panels[idx]); |
||||
const dispatch = useAppDispatch(); |
||||
|
||||
return ( |
||||
<Stack gap={0} mt="sm"> |
||||
<ExpressionInput |
||||
initialExpr={panel.expr} |
||||
executeQuery={(expr: string) => { |
||||
setRetriggerIdx((idx) => idx + 1); |
||||
dispatch(setExpr({ idx, expr })); |
||||
}} |
||||
/> |
||||
<Tabs mt="md" defaultValue="table" keepMounted={false}> |
||||
<Tabs.List> |
||||
<Tabs.Tab value="table" leftSection={<IconTable style={iconStyle} />}> |
||||
Table |
||||
</Tabs.Tab> |
||||
<Tabs.Tab value="graph" leftSection={<IconGraph style={iconStyle} />}> |
||||
Graph |
||||
</Tabs.Tab> |
||||
</Tabs.List> |
||||
<Tabs.Panel p="sm" value="table"> |
||||
<Stack gap="lg" mt="sm"> |
||||
<TimeInput |
||||
time={panel.visualizer.endTime} |
||||
range={panel.visualizer.range} |
||||
description="Evaluation time" |
||||
onChangeTime={(time) => |
||||
dispatch( |
||||
setVisualizer({ |
||||
idx, |
||||
visualizer: { ...panel.visualizer, endTime: time }, |
||||
}) |
||||
) |
||||
} |
||||
/> |
||||
<DataTable |
||||
expr={panel.expr} |
||||
evalTime={panel.visualizer.endTime} |
||||
retriggerIdx={retriggerIdx} |
||||
/> |
||||
</Stack> |
||||
</Tabs.Panel> |
||||
<Tabs.Panel |
||||
p="sm" |
||||
value="graph" |
||||
// style={{ border: "1px solid lightgrey", borderTop: "none" }}
|
||||
> |
||||
<Group mt="xs" justify="space-between"> |
||||
<Group> |
||||
<RangeInput |
||||
range={panel.visualizer.range} |
||||
onChangeRange={(range) => |
||||
dispatch( |
||||
setVisualizer({ |
||||
idx, |
||||
visualizer: { ...panel.visualizer, range }, |
||||
}) |
||||
) |
||||
} |
||||
/> |
||||
|
||||
<TimeInput |
||||
time={panel.visualizer.endTime} |
||||
range={panel.visualizer.range} |
||||
description="End time" |
||||
onChangeTime={(time) => |
||||
dispatch( |
||||
setVisualizer({ |
||||
idx, |
||||
visualizer: { ...panel.visualizer, endTime: time }, |
||||
}) |
||||
) |
||||
} |
||||
/> |
||||
|
||||
<Input value="" placeholder="Res. (s)" style={{ width: 80 }} /> |
||||
</Group> |
||||
|
||||
<SegmentedControl |
||||
onChange={(value) => |
||||
dispatch( |
||||
setVisualizer({ |
||||
idx, |
||||
visualizer: { |
||||
...panel.visualizer, |
||||
displayMode: value as GraphDisplayMode, |
||||
}, |
||||
}) |
||||
) |
||||
} |
||||
value={panel.visualizer.displayMode} |
||||
data={[ |
||||
{ |
||||
value: GraphDisplayMode.Lines, |
||||
label: ( |
||||
<Center> |
||||
<IconChartLine style={iconStyle} /> |
||||
<Box ml={10}>Unstacked</Box> |
||||
</Center> |
||||
), |
||||
}, |
||||
{ |
||||
value: GraphDisplayMode.Stacked, |
||||
label: ( |
||||
<Center> |
||||
<IconChartAreaFilled style={iconStyle} /> |
||||
<Box ml={10}>Stacked</Box> |
||||
</Center> |
||||
), |
||||
}, |
||||
{ |
||||
value: GraphDisplayMode.Heatmap, |
||||
label: ( |
||||
<Center> |
||||
<IconChartGridDots style={iconStyle} /> |
||||
<Box ml={10}>Heatmap</Box> |
||||
</Center> |
||||
), |
||||
}, |
||||
]} |
||||
/> |
||||
{/* <Switch color="gray" defaultChecked label="Show exemplars" /> */} |
||||
{/* <Switch |
||||
checked={panel.visualizer.showExemplars} |
||||
onChange={(event) => |
||||
dispatch( |
||||
setVisualizer({ |
||||
idx, |
||||
visualizer: { |
||||
...panel.visualizer, |
||||
showExemplars: event.currentTarget.checked, |
||||
}, |
||||
}) |
||||
) |
||||
} |
||||
color={"rgba(34,139,230,.1)"} |
||||
size="md" |
||||
label="Show exemplars" |
||||
thumbIcon={ |
||||
panel.visualizer.showExemplars ? ( |
||||
<IconCheck |
||||
style={{ width: "0.9rem", height: "0.9rem" }} |
||||
color={"rgba(34,139,230,.1)"} |
||||
stroke={3} |
||||
/> |
||||
) : ( |
||||
<IconX |
||||
style={{ width: "0.9rem", height: "0.9rem" }} |
||||
color="rgba(34,139,230,.1)" |
||||
stroke={3} |
||||
/> |
||||
) |
||||
} |
||||
/> */} |
||||
</Group> |
||||
<Space h="lg" /> |
||||
<Center |
||||
style={{ |
||||
height: 450, |
||||
backgroundColor: "#fbfbfb", |
||||
border: "2px dotted #e7e7e7", |
||||
fontSize: 20, |
||||
color: "#999", |
||||
}} |
||||
> |
||||
GRAPH PLACEHOLDER |
||||
</Center> |
||||
</Tabs.Panel> |
||||
</Tabs> |
||||
{/* Link button to remove this panel. */} |
||||
{/* <Group justify="right"> |
||||
<Button |
||||
variant="subtle" |
||||
size="sm" |
||||
fw={500} |
||||
// color="red"
|
||||
onClick={() => dispatch(removePanel(idx))} |
||||
> |
||||
Remove query |
||||
</Button> |
||||
</Group> */} |
||||
</Stack> |
||||
); |
||||
}; |
||||
|
||||
export default QueryPanel; |
||||
@ -0,0 +1,102 @@ |
||||
import { FC, useState } from "react"; |
||||
import { ActionIcon, Group, Input } from "@mantine/core"; |
||||
import { IconMinus, IconPlus } from "@tabler/icons-react"; |
||||
import { formatDuration, parseDuration } from "../../lib/formatTime"; |
||||
|
||||
interface RangeInputProps { |
||||
range: number; |
||||
onChangeRange: (range: number) => void; |
||||
} |
||||
|
||||
const iconStyle = { width: "0.9rem", height: "0.9rem" }; |
||||
|
||||
const rangeSteps = [ |
||||
1, |
||||
10, |
||||
60, |
||||
5 * 60, |
||||
15 * 60, |
||||
30 * 60, |
||||
60 * 60, |
||||
2 * 60 * 60, |
||||
6 * 60 * 60, |
||||
12 * 60 * 60, |
||||
24 * 60 * 60, |
||||
48 * 60 * 60, |
||||
7 * 24 * 60 * 60, |
||||
14 * 24 * 60 * 60, |
||||
28 * 24 * 60 * 60, |
||||
56 * 24 * 60 * 60, |
||||
112 * 24 * 60 * 60, |
||||
182 * 24 * 60 * 60, |
||||
365 * 24 * 60 * 60, |
||||
730 * 24 * 60 * 60, |
||||
].map((s) => s * 1000); |
||||
|
||||
const RangeInput: FC<RangeInputProps> = ({ range, onChangeRange }) => { |
||||
// TODO: Make sure that when "range" changes externally (like via the URL),
|
||||
// the input is updated, either via useEffect() or some better architecture.
|
||||
const [rangeInput, setRangeInput] = useState<string>(formatDuration(range)); |
||||
|
||||
const onChangeRangeInput = (rangeText: string): void => { |
||||
const newRange = parseDuration(rangeText); |
||||
if (newRange === null) { |
||||
setRangeInput(formatDuration(range)); |
||||
} else { |
||||
onChangeRange(newRange); |
||||
} |
||||
}; |
||||
|
||||
const increaseRange = (): void => { |
||||
for (const step of rangeSteps) { |
||||
if (range < step) { |
||||
setRangeInput(formatDuration(step)); |
||||
onChangeRange(step); |
||||
return; |
||||
} |
||||
} |
||||
}; |
||||
|
||||
const decreaseRange = (): void => { |
||||
for (const step of rangeSteps.slice().reverse()) { |
||||
if (range > step) { |
||||
setRangeInput(formatDuration(step)); |
||||
onChangeRange(step); |
||||
return; |
||||
} |
||||
} |
||||
}; |
||||
|
||||
return ( |
||||
<Group gap={5}> |
||||
<ActionIcon |
||||
size="lg" |
||||
variant="subtle" |
||||
aria-label="Decrease range" |
||||
onClick={decreaseRange} |
||||
> |
||||
<IconMinus style={iconStyle} /> |
||||
</ActionIcon> |
||||
<Input |
||||
value={rangeInput} |
||||
onChange={(event) => setRangeInput(event.currentTarget.value)} |
||||
onBlur={() => onChangeRangeInput(rangeInput)} |
||||
onKeyDown={(event) => |
||||
event.key === "Enter" && onChangeRangeInput(rangeInput) |
||||
} |
||||
aria-label="Range" |
||||
style={{ width: rangeInput.length + 3 + "ch" }} |
||||
/> |
||||
<ActionIcon |
||||
size="lg" |
||||
variant="subtle" |
||||
aria-label="Increase range" |
||||
onClick={increaseRange} |
||||
> |
||||
<IconPlus style={iconStyle} /> |
||||
</ActionIcon> |
||||
</Group> |
||||
); |
||||
}; |
||||
|
||||
export default RangeInput; |
||||
@ -0,0 +1,19 @@ |
||||
.metricName { |
||||
} |
||||
|
||||
.labelPair:hover { |
||||
--bg-expand: 4px; |
||||
background-color: #add6ffa0; |
||||
border-radius: 3px; |
||||
padding: var(--bg-expand); |
||||
margin: calc(-1 * var(--bg-expand)); |
||||
color: #495057; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
.labelName { |
||||
font-weight: 600; |
||||
} |
||||
|
||||
.labelValue { |
||||
} |
||||
@ -0,0 +1,77 @@ |
||||
import React, { FC } from "react"; |
||||
// import { useToastContext } from "../../contexts/ToastContext";
|
||||
import { formatSeries } from "../../lib/formatSeries"; |
||||
import classes from "./SeriesName.module.css"; |
||||
import { escapeString } from "../../lib/escapeString"; |
||||
import { useClipboard } from "@mantine/hooks"; |
||||
import { notifications } from "@mantine/notifications"; |
||||
|
||||
interface SeriesNameProps { |
||||
labels: { [key: string]: string } | null; |
||||
format: boolean; |
||||
} |
||||
|
||||
const SeriesName: FC<SeriesNameProps> = ({ labels, format }) => { |
||||
const clipboard = useClipboard(); |
||||
|
||||
const renderFormatted = (): React.ReactElement => { |
||||
const labelNodes: React.ReactElement[] = []; |
||||
let first = true; |
||||
for (const label in labels) { |
||||
if (label === "__name__") { |
||||
continue; |
||||
} |
||||
|
||||
labelNodes.push( |
||||
<span key={label}> |
||||
{!first && ", "} |
||||
<span |
||||
className={classes.labelPair} |
||||
onClick={(e) => { |
||||
const text = e.currentTarget.innerText; |
||||
clipboard.copy(text); |
||||
notifications.show({ |
||||
title: "Copied matcher!", |
||||
message: `Label matcher ${text} copied to clipboard`, |
||||
}); |
||||
}} |
||||
title="Click to copy label matcher" |
||||
> |
||||
<span className={classes.labelName}>{label}</span>= |
||||
<span className={classes.labelValue}> |
||||
"{escapeString(labels[label])}" |
||||
</span> |
||||
</span> |
||||
</span> |
||||
); |
||||
|
||||
if (first) { |
||||
first = false; |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<div> |
||||
<span className={classes.metricName}> |
||||
{labels ? labels.__name__ : ""} |
||||
</span> |
||||
{"{"} |
||||
{labelNodes} |
||||
{"}"} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
if (labels === null) { |
||||
return <>scalar</>; |
||||
} |
||||
|
||||
if (format) { |
||||
return renderFormatted(); |
||||
} |
||||
// Return a simple text node. This is much faster to scroll through
|
||||
// for longer lists (hundreds of items).
|
||||
return <>{formatSeries(labels)}</>; |
||||
}; |
||||
|
||||
export default SeriesName; |
||||
@ -0,0 +1,64 @@ |
||||
import { Group, ActionIcon } from "@mantine/core"; |
||||
import { DatesProvider, DateTimePicker } from "@mantine/dates"; |
||||
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; |
||||
import { FC } from "react"; |
||||
|
||||
interface TimeInputProps { |
||||
time: number | null; // Timestamp in milliseconds.
|
||||
range: number; // Range in seconds.
|
||||
description: string; |
||||
onChangeTime: (time: number | null) => void; |
||||
} |
||||
|
||||
const iconStyle = { width: "0.9rem", height: "0.9rem" }; |
||||
|
||||
const TimeInput: FC<TimeInputProps> = ({ |
||||
time, |
||||
range, |
||||
description, |
||||
onChangeTime, |
||||
}) => { |
||||
const baseTime = () => (time !== null ? time : Date.now().valueOf()); |
||||
|
||||
return ( |
||||
<Group gap={5}> |
||||
<ActionIcon |
||||
size="lg" |
||||
variant="subtle" |
||||
title="Decrease time" |
||||
aria-label="Decrease time" |
||||
onClick={() => onChangeTime(baseTime() - range / 2)} |
||||
> |
||||
<IconChevronLeft style={iconStyle} /> |
||||
</ActionIcon> |
||||
<DatesProvider settings={{ timezone: "UTC" }}> |
||||
<DateTimePicker |
||||
w={180} |
||||
valueFormat="YYYY-MM-DD HH:mm:ss" |
||||
withSeconds |
||||
clearable |
||||
value={time !== null ? new Date(time) : undefined} |
||||
onChange={(value) => onChangeTime(value ? value.getTime() : null)} |
||||
aria-label={description} |
||||
placeholder={description} |
||||
onClick={() => { |
||||
if (time === null) { |
||||
onChangeTime(baseTime()); |
||||
} |
||||
}} |
||||
/> |
||||
</DatesProvider> |
||||
<ActionIcon |
||||
size="lg" |
||||
variant="subtle" |
||||
title="Increase time" |
||||
aria-label="Increase time" |
||||
onClick={() => onChangeTime(baseTime() + range / 2)} |
||||
> |
||||
<IconChevronRight style={iconStyle} /> |
||||
</ActionIcon> |
||||
</Group> |
||||
); |
||||
}; |
||||
|
||||
export default TimeInput; |
||||
@ -1,3 +0,0 @@ |
||||
export default function ServiceDiscovery() { |
||||
return <>ServiceDiscovery page</>; |
||||
} |
||||
@ -1,3 +0,0 @@ |
||||
export default function Targets() { |
||||
return <>Targets page</>; |
||||
} |
||||
@ -0,0 +1,49 @@ |
||||
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; |
||||
import { ErrorAPIResponse, SuccessAPIResponse } from "../api/api"; |
||||
import { InstantQueryResult } from "../api/responseTypes/query"; |
||||
|
||||
// Define a service using a base URL and expected endpoints
|
||||
export const prometheusApi = createApi({ |
||||
reducerPath: "prometheusApi", |
||||
baseQuery: fetchBaseQuery({ baseUrl: "/api/v1/" }), |
||||
keepUnusedDataFor: 0, // Turn off caching.
|
||||
endpoints: (builder) => ({ |
||||
instantQuery: builder.query< |
||||
SuccessAPIResponse<InstantQueryResult>, |
||||
{ query: string; time: number } |
||||
>({ |
||||
query: ({ query, time }) => { |
||||
return { |
||||
url: `query`, |
||||
params: { |
||||
query, |
||||
time, |
||||
}, |
||||
}; |
||||
//`query?query=${encodeURIComponent(query)}&time=${time}`,
|
||||
}, |
||||
transformErrorResponse: (error): string => { |
||||
if (!error.data) { |
||||
return "Failed to fetch data"; |
||||
} |
||||
|
||||
return (error.data as ErrorAPIResponse).error; |
||||
}, |
||||
// transformResponse: (
|
||||
// response: APIResponse<InstantQueryResult>
|
||||
// ): SuccessAPIResponse<InstantQueryResult> => {
|
||||
// if (!response.status) {
|
||||
// throw new Error("Invalid response");
|
||||
// }
|
||||
// if (response.status === "error") {
|
||||
// throw new Error(response.error);
|
||||
// }
|
||||
// return response;
|
||||
// },
|
||||
}), |
||||
}), |
||||
}); |
||||
|
||||
// Export hooks for usage in functional components, which are
|
||||
// auto-generated based on the defined endpoints
|
||||
export const { useInstantQueryQuery, useLazyInstantQueryQuery } = prometheusApi; |
||||
@ -0,0 +1,6 @@ |
||||
import { useDispatch, useSelector } from "react-redux"; |
||||
import type { RootState, AppDispatch } from "./store"; |
||||
|
||||
// Use throughout your app instead of plain `useDispatch` and `useSelector`
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>(); |
||||
export const useAppSelector = useSelector.withTypes<RootState>(); |
||||
@ -0,0 +1,83 @@ |
||||
import { randomId } from "@mantine/hooks"; |
||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit"; |
||||
|
||||
export enum GraphDisplayMode { |
||||
Lines = "lines", |
||||
Stacked = "stacked", |
||||
Heatmap = "heatmap", |
||||
} |
||||
|
||||
// NOTE: This is not represented as a discriminated union type
|
||||
// because we want to preserve and partially share settings while
|
||||
// switching between display modes.
|
||||
export interface Visualizer { |
||||
activeTab: "table" | "graph" | "explain"; |
||||
endTime: number | null; // Timestamp in milliseconds.
|
||||
range: number; // Range in seconds.
|
||||
resolution: number | null; // Resolution step in seconds.
|
||||
displayMode: GraphDisplayMode; |
||||
showExemplars: boolean; |
||||
} |
||||
|
||||
export type Panel = { |
||||
// The id is helpful as a stable key for React.
|
||||
id: string; |
||||
expr: string; |
||||
exprStale: boolean; |
||||
showMetricsExplorer: boolean; |
||||
visualizer: Visualizer; |
||||
}; |
||||
|
||||
interface QueryPageState { |
||||
panels: Panel[]; |
||||
} |
||||
|
||||
const newDefaultPanel = (): Panel => ({ |
||||
id: randomId(), |
||||
expr: "", |
||||
exprStale: false, |
||||
showMetricsExplorer: false, |
||||
visualizer: { |
||||
activeTab: "table", |
||||
endTime: null, |
||||
// endTime: 1709414194000,
|
||||
range: 3600 * 1000, |
||||
resolution: null, |
||||
displayMode: GraphDisplayMode.Lines, |
||||
showExemplars: false, |
||||
}, |
||||
}); |
||||
|
||||
const initialState: QueryPageState = { |
||||
panels: [newDefaultPanel()], |
||||
}; |
||||
|
||||
export const queryPageSlice = createSlice({ |
||||
name: "queryPage", |
||||
initialState, |
||||
reducers: { |
||||
addPanel: (state) => { |
||||
state.panels.push(newDefaultPanel()); |
||||
}, |
||||
removePanel: (state, { payload }: PayloadAction<number>) => { |
||||
state.panels.splice(payload, 1); |
||||
}, |
||||
setExpr: ( |
||||
state, |
||||
{ payload }: PayloadAction<{ idx: number; expr: string }> |
||||
) => { |
||||
state.panels[payload.idx].expr = payload.expr; |
||||
}, |
||||
setVisualizer: ( |
||||
state, |
||||
{ payload }: PayloadAction<{ idx: number; visualizer: Visualizer }> |
||||
) => { |
||||
state.panels[payload.idx].visualizer = payload.visualizer; |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
export const { addPanel, removePanel, setExpr, setVisualizer } = |
||||
queryPageSlice.actions; |
||||
|
||||
export default queryPageSlice.reducer; |
||||
@ -0,0 +1,19 @@ |
||||
import { configureStore } from "@reduxjs/toolkit"; |
||||
import queryPageSlice from "./queryPageSlice"; |
||||
import { prometheusApi } from "./api"; |
||||
|
||||
const store = configureStore({ |
||||
reducer: { |
||||
queryPage: queryPageSlice, |
||||
[prometheusApi.reducerPath]: prometheusApi.reducer, |
||||
}, |
||||
middleware: (getDefaultMiddleware) => |
||||
getDefaultMiddleware().concat(prometheusApi.middleware), |
||||
}); |
||||
|
||||
// Infer the `RootState` and `AppDispatch` types from the store itself
|
||||
export type RootState = ReturnType<typeof store.getState>; |
||||
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
|
||||
export type AppDispatch = typeof store.dispatch; |
||||
|
||||
export default store; |
||||
@ -1,67 +0,0 @@ |
||||
import { |
||||
useMantineColorScheme, |
||||
Group, |
||||
SegmentedControl, |
||||
rem, |
||||
MantineColorScheme, |
||||
Tooltip, |
||||
} from "@mantine/core"; |
||||
import { |
||||
IconMoonFilled, |
||||
IconSunFilled, |
||||
IconUserFilled, |
||||
} from "@tabler/icons-react"; |
||||
import { FC } from "react"; |
||||
|
||||
export const ThemeSelector: FC = () => { |
||||
const { colorScheme, setColorScheme } = useMantineColorScheme(); |
||||
const iconProps = { |
||||
style: { width: rem(20), height: rem(20), display: "block" }, |
||||
stroke: 1.5, |
||||
}; |
||||
|
||||
return ( |
||||
<Group> |
||||
<SegmentedControl |
||||
color="gray.7" |
||||
size="xs" |
||||
// styles={{ root: { backgroundColor: "var(--mantine-color-gray-7)" } }}
|
||||
styles={{ |
||||
root: { |
||||
padding: 3, |
||||
backgroundColor: "var(--mantine-color-gray-6)", |
||||
}, |
||||
}} |
||||
withItemsBorders={false} |
||||
value={colorScheme} |
||||
onChange={(v) => setColorScheme(v as MantineColorScheme)} |
||||
data={[ |
||||
{ |
||||
value: "light", |
||||
label: ( |
||||
<Tooltip label="Use light theme" offset={15}> |
||||
<IconSunFilled {...iconProps} /> |
||||
</Tooltip> |
||||
), |
||||
}, |
||||
{ |
||||
value: "dark", |
||||
label: ( |
||||
<Tooltip label="Use dark theme" offset={15}> |
||||
<IconMoonFilled {...iconProps} /> |
||||
</Tooltip> |
||||
), |
||||
}, |
||||
{ |
||||
value: "auto", |
||||
label: ( |
||||
<Tooltip label="Use browser-preferred theme" offset={15}> |
||||
<IconUserFilled {...iconProps} /> |
||||
</Tooltip> |
||||
), |
||||
}, |
||||
]} |
||||
/> |
||||
</Group> |
||||
); |
||||
}; |
||||
Loading…
Reference in new issue