mirror of
https://github.com/prometheus/prometheus.git
synced 2024-11-09 23:24:05 -08:00
Build initial targets page
Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
parent
a699cbefae
commit
70221fc4a0
|
@ -31,6 +31,7 @@
|
|||
"dayjs": "^1.11.10",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-redux": "^9.1.0",
|
||||
"react-router-dom": "^6.22.1"
|
||||
},
|
||||
|
|
50
web/ui/mantine-ui/src/CustomInfiniteScroll.tsx
Normal file
50
web/ui/mantine-ui/src/CustomInfiniteScroll.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { ComponentType, useEffect, useState } from 'react';
|
||||
import InfiniteScroll from 'react-infinite-scroll-component';
|
||||
|
||||
const initialNumberOfItemsDisplayed = 50;
|
||||
|
||||
export interface InfiniteScrollItemsProps<T> {
|
||||
items: T[];
|
||||
}
|
||||
|
||||
interface CustomInfiniteScrollProps<T> {
|
||||
allItems: T[];
|
||||
child: ComponentType<InfiniteScrollItemsProps<T>>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
const CustomInfiniteScroll = <T,>({ allItems, child }: CustomInfiniteScrollProps<T>) => {
|
||||
const [items, setItems] = useState<T[]>(allItems.slice(0, 50));
|
||||
const [index, setIndex] = useState<number>(initialNumberOfItemsDisplayed);
|
||||
const [hasMore, setHasMore] = useState<boolean>(allItems.length > initialNumberOfItemsDisplayed);
|
||||
const Child = child;
|
||||
|
||||
useEffect(() => {
|
||||
setItems(allItems.slice(0, initialNumberOfItemsDisplayed));
|
||||
setHasMore(allItems.length > initialNumberOfItemsDisplayed);
|
||||
}, [allItems]);
|
||||
|
||||
const fetchMoreData = () => {
|
||||
if (items.length === allItems.length) {
|
||||
setHasMore(false);
|
||||
} else {
|
||||
const newIndex = index + initialNumberOfItemsDisplayed;
|
||||
setIndex(newIndex);
|
||||
setItems(allItems.slice(0, newIndex));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<InfiniteScroll
|
||||
next={fetchMoreData}
|
||||
hasMore={hasMore}
|
||||
loader={<h4>loading...</h4>}
|
||||
dataLength={items.length}
|
||||
height={items.length > 25 ? '75vh' : ''}
|
||||
>
|
||||
<Child items={items} />
|
||||
</InfiniteScroll>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomInfiniteScroll;
|
58
web/ui/mantine-ui/src/EndpointLink.tsx
Normal file
58
web/ui/mantine-ui/src/EndpointLink.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { Anchor, Badge, Group } from "@mantine/core";
|
||||
import React, { FC } from "react";
|
||||
|
||||
export interface EndpointLinkProps {
|
||||
endpoint: string;
|
||||
globalUrl: string;
|
||||
}
|
||||
|
||||
const EndpointLink: FC<EndpointLinkProps> = ({ endpoint, globalUrl }) => {
|
||||
let url: URL;
|
||||
let search = "";
|
||||
let invalidURL = false;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch (err: unknown) {
|
||||
// In cases of IPv6 addresses with a Zone ID, URL may not be parseable.
|
||||
// See https://github.com/prometheus/prometheus/issues/9760
|
||||
// In this case, we attempt to prepare a synthetic URL with the
|
||||
// same query parameters, for rendering purposes.
|
||||
invalidURL = true;
|
||||
if (endpoint.indexOf("?") > -1) {
|
||||
search = endpoint.substring(endpoint.indexOf("?"));
|
||||
}
|
||||
url = new URL("http://0.0.0.0" + search);
|
||||
}
|
||||
|
||||
const { host, pathname, protocol, searchParams }: URL = url;
|
||||
const params = Array.from(searchParams.entries());
|
||||
const displayLink = invalidURL
|
||||
? endpoint.replace(search, "")
|
||||
: `${protocol}//${host}${pathname}`;
|
||||
return (
|
||||
<>
|
||||
<Anchor size="sm" href={globalUrl}>
|
||||
{displayLink}
|
||||
</Anchor>
|
||||
{params.length > 0 && (
|
||||
<Group gap="xs" my="md">
|
||||
{params.map(([labelName, labelValue]: [string, string]) => {
|
||||
return (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="gray.9"
|
||||
key={`${labelName}/${labelValue}`}
|
||||
style={{ textTransform: "none" }}
|
||||
>
|
||||
{`${labelName}="${labelValue}"`}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointLink;
|
36
web/ui/mantine-ui/src/LabelBadges.tsx
Normal file
36
web/ui/mantine-ui/src/LabelBadges.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { Badge, BadgeVariant, Group, MantineColor } from "@mantine/core";
|
||||
import { FC } from "react";
|
||||
import { escapeString } from "./lib/escapeString";
|
||||
import badgeClasses from "./Badge.module.css";
|
||||
|
||||
export interface LabelBadgesProps {
|
||||
labels: Record<string, string>;
|
||||
variant?: BadgeVariant;
|
||||
color?: MantineColor;
|
||||
}
|
||||
|
||||
export const LabelBadges: FC<LabelBadgesProps> = ({
|
||||
labels,
|
||||
variant,
|
||||
color,
|
||||
}) => (
|
||||
<Group gap="xs">
|
||||
{Object.entries(labels).map(([k, v]) => {
|
||||
return (
|
||||
<Badge
|
||||
variant={variant ? variant : "light"}
|
||||
color={color ? color : undefined}
|
||||
className={badgeClasses.labelBadge}
|
||||
styles={{
|
||||
label: {
|
||||
textTransform: "none",
|
||||
},
|
||||
}}
|
||||
key={k}
|
||||
>
|
||||
{k}="{escapeString(v)}"
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
140
web/ui/mantine-ui/src/StateMultiSelect.tsx
Normal file
140
web/ui/mantine-ui/src/StateMultiSelect.tsx
Normal file
|
@ -0,0 +1,140 @@
|
|||
import { FC, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
CheckIcon,
|
||||
CloseButton,
|
||||
Combobox,
|
||||
ComboboxChevron,
|
||||
ComboboxClearButton,
|
||||
Group,
|
||||
Input,
|
||||
MantineColor,
|
||||
Pill,
|
||||
PillGroup,
|
||||
PillsInput,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { IconActivity, IconFilter } from "@tabler/icons-react";
|
||||
|
||||
interface StatePillProps extends React.ComponentPropsWithoutRef<"div"> {
|
||||
value: string;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export function StatePill({ value, onRemove, ...others }: StatePillProps) {
|
||||
return (
|
||||
<Pill
|
||||
fw={600}
|
||||
style={{ textTransform: "uppercase", fontWeight: 600 }}
|
||||
onRemove={onRemove}
|
||||
{...others}
|
||||
withRemoveButton={!!onRemove}
|
||||
>
|
||||
{value}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
|
||||
interface StateMultiSelectProps {
|
||||
options: string[];
|
||||
optionClass: (option: string) => string;
|
||||
placeholder: string;
|
||||
values: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
}
|
||||
|
||||
export const StateMultiSelect: FC<StateMultiSelectProps> = ({
|
||||
options,
|
||||
optionClass,
|
||||
placeholder,
|
||||
values,
|
||||
onChange,
|
||||
}) => {
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
|
||||
});
|
||||
|
||||
const handleValueSelect = (val: string) =>
|
||||
onChange(
|
||||
values.includes(val) ? values.filter((v) => v !== val) : [...values, val]
|
||||
);
|
||||
|
||||
const handleValueRemove = (val: string) =>
|
||||
onChange(values.filter((v) => v !== val));
|
||||
|
||||
const renderedValues = values.map((item) => (
|
||||
<StatePill
|
||||
value={item}
|
||||
className={optionClass(item)}
|
||||
onRemove={() => handleValueRemove(item)}
|
||||
key={item}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
onOptionSubmit={handleValueSelect}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput
|
||||
pointer
|
||||
onClick={() => combobox.toggleDropdown()}
|
||||
miw={200}
|
||||
leftSection={<IconActivity size={14} />}
|
||||
rightSection={
|
||||
values.length > 0 ? (
|
||||
<ComboboxClearButton onClear={() => onChange([])} />
|
||||
) : (
|
||||
<ComboboxChevron />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pill.Group>
|
||||
{renderedValues.length > 0 ? (
|
||||
renderedValues
|
||||
) : (
|
||||
<PillsInput.Field placeholder={placeholder} mt={1} />
|
||||
)}
|
||||
|
||||
<Combobox.EventsTarget>
|
||||
<PillsInput.Field
|
||||
type="hidden"
|
||||
onBlur={() => combobox.closeDropdown()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
handleValueRemove(values[values.length - 1]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Combobox.EventsTarget>
|
||||
</Pill.Group>
|
||||
</PillsInput>
|
||||
</Combobox.DropdownTarget>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{options.map((value) => {
|
||||
return (
|
||||
<Combobox.Option
|
||||
value={value}
|
||||
key={value}
|
||||
active={values.includes(value)}
|
||||
>
|
||||
<Group gap="sm">
|
||||
{values.includes(value) ? (
|
||||
<CheckIcon size={12} color="gray" />
|
||||
) : null}
|
||||
<StatePill value={value} className={optionClass(value)} />
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
);
|
||||
})}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
1
web/ui/mantine-ui/src/api/responseTypes/scrapePools.ts
Normal file
1
web/ui/mantine-ui/src/api/responseTypes/scrapePools.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export type ScrapePoolsResult = { scrapePools: string[] };
|
27
web/ui/mantine-ui/src/api/responseTypes/targets.ts
Normal file
27
web/ui/mantine-ui/src/api/responseTypes/targets.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
export interface Labels {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export type Target = {
|
||||
discoveredLabels: Labels;
|
||||
labels: Labels;
|
||||
scrapePool: string;
|
||||
scrapeUrl: string;
|
||||
globalUrl: string;
|
||||
lastError: string;
|
||||
lastScrape: string;
|
||||
lastScrapeDuration: number;
|
||||
health: string;
|
||||
scrapeInterval: string;
|
||||
scrapeTimeout: string;
|
||||
};
|
||||
|
||||
export interface DroppedTarget {
|
||||
discoveredLabels: Labels;
|
||||
}
|
||||
|
||||
export type TargetsResult = {
|
||||
activeTargets: Target[];
|
||||
droppedTargets: DroppedTarget[];
|
||||
droppedTargetCounts: Record<string, number>;
|
||||
};
|
|
@ -1,3 +1,318 @@
|
|||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Card,
|
||||
Group,
|
||||
Input,
|
||||
RingProgress,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconInfoCircle,
|
||||
IconLayoutNavbarCollapse,
|
||||
IconLayoutNavbarExpand,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import { StateMultiSelect } from "../StateMultiSelect";
|
||||
import { useSuspenseAPIQuery } from "../api/api";
|
||||
import { ScrapePoolsResult } from "../api/responseTypes/scrapePools";
|
||||
import { Target, TargetsResult } from "../api/responseTypes/targets";
|
||||
import React from "react";
|
||||
import badgeClasses from "../Badge.module.css";
|
||||
import {
|
||||
formatPrometheusDuration,
|
||||
humanizeDurationRelative,
|
||||
humanizeDuration,
|
||||
now,
|
||||
} from "../lib/formatTime";
|
||||
import { LabelBadges } from "../LabelBadges";
|
||||
import { useAppDispatch, useAppSelector } from "../state/hooks";
|
||||
import {
|
||||
setCollapsedPools,
|
||||
updateTargetFilters,
|
||||
} from "../state/targetsPageSlice";
|
||||
import EndpointLink from "../EndpointLink";
|
||||
import CustomInfiniteScroll from "../CustomInfiniteScroll";
|
||||
|
||||
type ScrapePool = {
|
||||
targets: Target[];
|
||||
upCount: number;
|
||||
downCount: number;
|
||||
unknownCount: number;
|
||||
};
|
||||
|
||||
type ScrapePools = {
|
||||
[scrapePool: string]: ScrapePool;
|
||||
};
|
||||
|
||||
const healthBadgeClass = (state: string) => {
|
||||
switch (state.toLowerCase()) {
|
||||
case "up":
|
||||
return badgeClasses.healthOk;
|
||||
case "down":
|
||||
return badgeClasses.healthErr;
|
||||
case "unknown":
|
||||
return badgeClasses.healthUnknown;
|
||||
default:
|
||||
return badgeClasses.warn;
|
||||
}
|
||||
};
|
||||
|
||||
const groupTargets = (targets: Target[]): ScrapePools => {
|
||||
const pools: ScrapePools = {};
|
||||
targets.forEach((target) => {
|
||||
if (!pools[target.scrapePool]) {
|
||||
pools[target.scrapePool] = {
|
||||
targets: [],
|
||||
upCount: 0,
|
||||
downCount: 0,
|
||||
unknownCount: 0,
|
||||
};
|
||||
}
|
||||
pools[target.scrapePool].targets.push(target);
|
||||
switch (target.health.toLowerCase()) {
|
||||
case "up":
|
||||
pools[target.scrapePool].upCount++;
|
||||
break;
|
||||
case "down":
|
||||
pools[target.scrapePool].downCount++;
|
||||
break;
|
||||
case "unknown":
|
||||
pools[target.scrapePool].unknownCount++;
|
||||
break;
|
||||
}
|
||||
});
|
||||
return pools;
|
||||
};
|
||||
|
||||
export default function TargetsPage() {
|
||||
return <>Targets page</>;
|
||||
const {
|
||||
data: {
|
||||
data: { scrapePools },
|
||||
},
|
||||
} = useSuspenseAPIQuery<ScrapePoolsResult>({
|
||||
path: `/scrape_pools`,
|
||||
});
|
||||
|
||||
const {
|
||||
data: {
|
||||
data: { activeTargets },
|
||||
},
|
||||
} = useSuspenseAPIQuery<TargetsResult>({
|
||||
path: `/targets`,
|
||||
params: {
|
||||
state: "active",
|
||||
},
|
||||
});
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const filters = useAppSelector((state) => state.targetsPage.filters);
|
||||
const collapsedPools = useAppSelector(
|
||||
(state) => state.targetsPage.collapsedPools
|
||||
);
|
||||
|
||||
const allPools = groupTargets(activeTargets);
|
||||
const allPoolNames = Object.keys(allPools);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group mb="md" mt="xs">
|
||||
<Select
|
||||
placeholder="Select scrape pool"
|
||||
data={["All pools", ...scrapePools]}
|
||||
/>
|
||||
<StateMultiSelect
|
||||
options={["unknown", "up", "down"]}
|
||||
optionClass={(o) =>
|
||||
o === "unknown"
|
||||
? badgeClasses.healthUnknown
|
||||
: o === "up"
|
||||
? badgeClasses.healthOk
|
||||
: badgeClasses.healthErr
|
||||
}
|
||||
placeholder="Filter by target state"
|
||||
values={filters.health}
|
||||
onChange={(values) =>
|
||||
dispatch(updateTargetFilters({ health: values }))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
flex={1}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
placeholder="Filter by endpoint or labels"
|
||||
></Input>
|
||||
<ActionIcon
|
||||
size="input-sm"
|
||||
title="Expand all pools"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
setCollapsedPools(collapsedPools.length > 0 ? [] : allPoolNames)
|
||||
)
|
||||
}
|
||||
>
|
||||
{collapsedPools.length > 0 ? (
|
||||
<IconLayoutNavbarExpand size={16} />
|
||||
) : (
|
||||
<IconLayoutNavbarCollapse size={16} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Stack>
|
||||
{allPoolNames.length === 0 && (
|
||||
<Alert
|
||||
title="No matching targets"
|
||||
icon={<IconInfoCircle size={14} />}
|
||||
>
|
||||
No targets found that match your filter criteria.
|
||||
</Alert>
|
||||
)}
|
||||
<Accordion
|
||||
multiple
|
||||
variant="separated"
|
||||
value={allPoolNames.filter((p) => !collapsedPools.includes(p))}
|
||||
onChange={(value) =>
|
||||
dispatch(
|
||||
setCollapsedPools(allPoolNames.filter((p) => !value.includes(p)))
|
||||
)
|
||||
}
|
||||
>
|
||||
{allPoolNames.map((poolName) => {
|
||||
const pool = allPools[poolName];
|
||||
return (
|
||||
<Accordion.Item
|
||||
key={poolName}
|
||||
value={poolName}
|
||||
style={{
|
||||
borderLeft:
|
||||
pool.upCount === 0
|
||||
? "5px solid var(--mantine-color-red-4)"
|
||||
: pool.upCount !== pool.targets.length
|
||||
? "5px solid var(--mantine-color-orange-5)"
|
||||
: "5px solid var(--mantine-color-green-4)",
|
||||
}}
|
||||
>
|
||||
<Accordion.Control>
|
||||
<Group wrap="nowrap" justify="space-between" mr="lg">
|
||||
<Text>{poolName}</Text>
|
||||
<Group gap="xs">
|
||||
<Text c="gray.6">
|
||||
{pool.upCount} / {pool.targets.length} up
|
||||
</Text>
|
||||
<RingProgress
|
||||
size={25}
|
||||
thickness={5}
|
||||
sections={[
|
||||
{
|
||||
value: (pool.upCount / pool.targets.length) * 100,
|
||||
// value: pool.upCount,
|
||||
color: "green.4",
|
||||
},
|
||||
{
|
||||
value: (pool.downCount / pool.targets.length) * 100,
|
||||
color: "red.5",
|
||||
},
|
||||
{
|
||||
value:
|
||||
(pool.unknownCount / pool.targets.length) * 100,
|
||||
color: "gray.4",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<CustomInfiniteScroll
|
||||
allItems={pool.targets.filter(
|
||||
(t) =>
|
||||
filters.health.length === 0 ||
|
||||
filters.health.includes(t.health.toLowerCase())
|
||||
)}
|
||||
child={({ items }) => (
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w="30%">Endpoint</Table.Th>
|
||||
<Table.Th w={100}>State</Table.Th>
|
||||
<Table.Th>Labels</Table.Th>
|
||||
<Table.Th w="10%">Last scrape</Table.Th>
|
||||
<Table.Th w="10%">Scrape duration</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((target, i) => (
|
||||
// TODO: Find a stable and definitely unique key.
|
||||
<React.Fragment key={i}>
|
||||
<Table.Tr
|
||||
style={{
|
||||
borderBottom: target.lastError
|
||||
? "none"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Table.Td>
|
||||
{/* TODO: Process target URL like in old UI */}
|
||||
<EndpointLink
|
||||
endpoint={target.scrapeUrl}
|
||||
globalUrl={target.globalUrl}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
className={healthBadgeClass(target.health)}
|
||||
>
|
||||
{target.health}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<LabelBadges labels={target.labels} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{humanizeDurationRelative(
|
||||
target.lastScrape,
|
||||
now()
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{humanizeDuration(
|
||||
target.lastScrapeDuration * 1000
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{target.lastError && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Alert
|
||||
color="red"
|
||||
mb="sm"
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
>
|
||||
<strong>Error scraping target:</strong>{" "}
|
||||
{target.lastError}
|
||||
</Alert>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
14
web/ui/mantine-ui/src/state/initializeFromLocalStorage.ts
Normal file
14
web/ui/mantine-ui/src/state/initializeFromLocalStorage.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
// This has to live in its own file since including it from
|
||||
// localStorageMiddleware.ts causes startup issues, as the
|
||||
// listener setup there accesses an action creator before Redux
|
||||
// has been initialized.
|
||||
export const initializeFromLocalStorage = <T>(
|
||||
key: string,
|
||||
defaultValue: T
|
||||
): T => {
|
||||
const value = localStorage.getItem(key);
|
||||
if (value === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
return JSON.parse(value);
|
||||
};
|
33
web/ui/mantine-ui/src/state/localStorageMiddleware.ts
Normal file
33
web/ui/mantine-ui/src/state/localStorageMiddleware.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { createListenerMiddleware } from "@reduxjs/toolkit";
|
||||
import { AppDispatch, RootState } from "./store";
|
||||
import {
|
||||
localStorageKeyCollapsedPools,
|
||||
localStorageKeyTargetFilters,
|
||||
setCollapsedPools,
|
||||
updateTargetFilters,
|
||||
} from "./targetsPageSlice";
|
||||
|
||||
const persistToLocalStorage = <T>(key: string, value: T) => {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
};
|
||||
|
||||
export const localStorageMiddleware = createListenerMiddleware();
|
||||
|
||||
const startAppListening = localStorageMiddleware.startListening.withTypes<
|
||||
RootState,
|
||||
AppDispatch
|
||||
>();
|
||||
|
||||
startAppListening({
|
||||
actionCreator: setCollapsedPools,
|
||||
effect: ({ payload }) => {
|
||||
persistToLocalStorage(localStorageKeyCollapsedPools, payload);
|
||||
},
|
||||
});
|
||||
|
||||
startAppListening({
|
||||
actionCreator: updateTargetFilters,
|
||||
effect: ({ payload }) => {
|
||||
persistToLocalStorage(localStorageKeyTargetFilters, payload);
|
||||
},
|
||||
});
|
|
@ -2,15 +2,22 @@ import { configureStore } from "@reduxjs/toolkit";
|
|||
import queryPageSlice from "./queryPageSlice";
|
||||
import { prometheusApi } from "./api";
|
||||
import settingsSlice from "./settingsSlice";
|
||||
import targetsPageSlice from "./targetsPageSlice";
|
||||
import alertsPageSlice from "./alertsPageSlice";
|
||||
import { localStorageMiddleware } from "./localStorageMiddleware";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
settings: settingsSlice,
|
||||
queryPage: queryPageSlice,
|
||||
targetsPage: targetsPageSlice,
|
||||
alertsPage: alertsPageSlice,
|
||||
[prometheusApi.reducerPath]: prometheusApi.reducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat(prometheusApi.middleware),
|
||||
getDefaultMiddleware()
|
||||
.prepend(localStorageMiddleware.middleware)
|
||||
.concat(prometheusApi.middleware),
|
||||
});
|
||||
|
||||
// Infer the `RootState` and `AppDispatch` types from the store itself
|
||||
|
|
50
web/ui/mantine-ui/src/state/targetsPageSlice.ts
Normal file
50
web/ui/mantine-ui/src/state/targetsPageSlice.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||
import { initializeFromLocalStorage } from "./initializeFromLocalStorage";
|
||||
|
||||
export const localStorageKeyCollapsedPools = "targetsPage.collapsedPools";
|
||||
export const localStorageKeyTargetFilters = "targetsPage.filters";
|
||||
|
||||
interface TargetFilters {
|
||||
scrapePool: string | null;
|
||||
health: string[];
|
||||
}
|
||||
|
||||
interface TargetsPage {
|
||||
filters: TargetFilters;
|
||||
collapsedPools: string[];
|
||||
}
|
||||
|
||||
const initialState: TargetsPage = {
|
||||
filters: initializeFromLocalStorage<TargetFilters>(
|
||||
localStorageKeyTargetFilters,
|
||||
{
|
||||
scrapePool: null,
|
||||
health: [],
|
||||
}
|
||||
),
|
||||
collapsedPools: initializeFromLocalStorage<string[]>(
|
||||
localStorageKeyCollapsedPools,
|
||||
[]
|
||||
),
|
||||
};
|
||||
|
||||
export const targetsPageSlice = createSlice({
|
||||
name: "targetsPage",
|
||||
initialState,
|
||||
reducers: {
|
||||
updateTargetFilters: (
|
||||
state,
|
||||
{ payload }: PayloadAction<Partial<TargetFilters>>
|
||||
) => {
|
||||
Object.assign(state.filters, payload);
|
||||
},
|
||||
setCollapsedPools: (state, { payload }: PayloadAction<string[]>) => {
|
||||
state.collapsedPools = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { updateTargetFilters, setCollapsedPools } =
|
||||
targetsPageSlice.actions;
|
||||
|
||||
export default targetsPageSlice.reducer;
|
20
web/ui/package-lock.json
generated
20
web/ui/package-lock.json
generated
|
@ -128,6 +128,7 @@
|
|||
"dayjs": "^1.11.10",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-redux": "^9.1.0",
|
||||
"react-router-dom": "^6.22.1"
|
||||
},
|
||||
|
@ -6150,6 +6151,17 @@
|
|||
"react": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-infinite-scroll-component": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz",
|
||||
"integrity": "sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==",
|
||||
"dependencies": {
|
||||
"throttle-debounce": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
@ -6795,6 +6807,14 @@
|
|||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/throttle-debounce": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.3.0.tgz",
|
||||
"integrity": "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tmpl": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||
|
|
Loading…
Reference in a new issue