Add state and label/alertname filter mockups to Alerts page

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2024-03-14 12:18:03 +01:00
parent 2f95bbe570
commit 627826783c
2 changed files with 311 additions and 158 deletions

View file

@ -0,0 +1,135 @@
import { useState } from "react";
import {
Badge,
CheckIcon,
CloseButton,
Combobox,
ComboboxChevron,
ComboboxClearButton,
Group,
Input,
Pill,
PillGroup,
PillsInput,
useCombobox,
} from "@mantine/core";
import badgeClasses from "../Badge.module.css";
import { IconFilter } from "@tabler/icons-react";
import { IconFilterSearch } 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 }}
className={
value === "inactive"
? badgeClasses.healthOk
: value === "pending"
? badgeClasses.healthWarn
: badgeClasses.healthErr
}
onRemove={onRemove}
{...others}
withRemoveButton={!!onRemove}
>
{value}
</Pill>
);
}
export function AlertStateMultiSelect() {
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
});
const [values, setValues] = useState<string[]>([]);
const handleValueSelect = (val: string) =>
setValues((current) =>
current.includes(val)
? current.filter((v) => v !== val)
: [...current, val]
);
const handleValueRemove = (val: string) =>
setValues((current) => current.filter((v) => v !== val));
const renderedValues = values.map((item) => (
<StatePill
value={item}
onRemove={() => handleValueRemove(item)}
key={item}
/>
));
const options = ["inactive", "pending", "firing"].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} />
</Group>
</Combobox.Option>
);
});
return (
<Combobox
store={combobox}
onOptionSubmit={handleValueSelect}
withinPortal={false}
>
<Combobox.DropdownTarget>
<PillsInput
pointer
onClick={() => combobox.toggleDropdown()}
miw={200}
leftSection={<IconFilter size={14} />}
rightSection={
values.length > 0 ? (
<ComboboxClearButton onClear={() => setValues([])} />
) : (
<ComboboxChevron />
)
}
>
<Pill.Group>
{renderedValues.length > 0 ? (
renderedValues
) : (
<Input.Placeholder>Filter by alert state</Input.Placeholder>
)}
<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}</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
}

View file

@ -7,14 +7,24 @@ import {
Badge, Badge,
Tooltip, Tooltip,
Box, Box,
Switch, Divider,
Button,
Fieldset,
Checkbox,
MultiSelect,
Pill,
Stack,
Input,
} from "@mantine/core"; } from "@mantine/core";
import { useSuspenseAPIQuery } from "../api/api"; import { useSuspenseAPIQuery } from "../api/api";
import { AlertingRulesMap } from "../api/responseTypes/rules"; import { AlertingRulesMap } from "../api/responseTypes/rules";
import badgeClasses from "../Badge.module.css"; import badgeClasses from "../Badge.module.css";
import RuleDefinition from "../RuleDefinition"; import RuleDefinition from "../RuleDefinition";
import { formatRelative, now } from "../lib/formatTime"; import { formatRelative, now } from "../lib/formatTime";
import { Fragment, useState } from "react"; import { Fragment } from "react";
import { AlertStateMultiSelect } from "./AlertStateMultiSelect";
import { useAppSelector } from "../state/hooks";
import { IconSearch } from "@tabler/icons-react";
export default function AlertsPage() { export default function AlertsPage() {
const { data } = useSuspenseAPIQuery<AlertingRulesMap>({ const { data } = useSuspenseAPIQuery<AlertingRulesMap>({
@ -23,7 +33,9 @@ export default function AlertsPage() {
type: "alert", type: "alert",
}, },
}); });
const [showAnnotations, setShowAnnotations] = useState(false); const showAnnotations = useAppSelector(
(state) => state.settings.showAnnotations
);
const ruleStatsCount = { const ruleStatsCount = {
inactive: 0, inactive: 0,
@ -37,167 +49,173 @@ export default function AlertsPage() {
return ( return (
<> <>
<Switch <Group mb="md" mt="xs">
checked={showAnnotations} <AlertStateMultiSelect />
label="Show annotations" <Input
onChange={(event) => setShowAnnotations(event.currentTarget.checked)} flex={1}
mb="md" leftSection={<IconSearch size={14} />}
/> placeholder="Filter by alert name or labels"
{data.data.groups.map((g, i) => ( ></Input>
<Card </Group>
shadow="xs" <Stack>
withBorder {data.data.groups.map((g, i) => (
radius="md" <Card
p="md" shadow="xs"
mb="md" withBorder
key={i} // TODO: Find a stable and definitely unique key. p="md"
> key={i} // TODO: Find a stable and definitely unique key.
<Group mb="md" mt="xs" ml="xs" justify="space-between"> >
<Group align="baseline"> <Group mb="md" mt="xs" ml="xs" justify="space-between">
<Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)"> <Group align="baseline">
{g.name} <Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)">
</Text> {g.name}
<Text fz="sm" c="gray.6"> </Text>
{g.file} <Text fz="sm" c="gray.6">
</Text> {g.file}
</Text>
</Group>
</Group> </Group>
</Group> <Accordion multiple variant="separated">
<Accordion multiple variant="separated"> {g.rules.map((r, j) => {
{g.rules.map((r, j) => { const numFiring = r.alerts.filter(
const numFiring = r.alerts.filter( (a) => a.state === "firing"
(a) => a.state === "firing" ).length;
).length; const numPending = r.alerts.filter(
const numPending = r.alerts.filter( (a) => a.state === "pending"
(a) => a.state === "pending" ).length;
).length;
return ( return (
<Accordion.Item <Accordion.Item
key={j} key={j}
value={j.toString()} value={j.toString()}
style={{ style={{
borderLeft: borderLeft:
numFiring > 0 numFiring > 0
? "5px solid var(--mantine-color-red-4)" ? "5px solid var(--mantine-color-red-4)"
: numPending > 0 : numPending > 0
? "5px solid var(--mantine-color-orange-5)" ? "5px solid var(--mantine-color-orange-5)"
: "5px solid var(--mantine-color-green-4)", : "5px solid var(--mantine-color-green-4)",
}} }}
> >
<Accordion.Control> <Accordion.Control>
<Group wrap="nowrap" justify="space-between" mr="lg"> <Group wrap="nowrap" justify="space-between" mr="lg">
<Text>{r.name}</Text> <Text>{r.name}</Text>
<Group gap="xs"> <Group gap="xs">
{numFiring > 0 && ( {numFiring > 0 && (
<Badge className={badgeClasses.healthErr}> <Badge className={badgeClasses.healthErr}>
firing ({numFiring}) firing ({numFiring})
</Badge> </Badge>
)} )}
{numPending > 0 && ( {numPending > 0 && (
<Badge className={badgeClasses.healthWarn}> <Badge className={badgeClasses.healthWarn}>
pending ({numPending}) pending ({numPending})
</Badge> </Badge>
)} )}
{/* {numFiring === 0 && numPending === 0 && ( {/* {numFiring === 0 && numPending === 0 && (
<Badge className={badgeClasses.healthOk}> <Badge className={badgeClasses.healthOk}>
inactive inactive
</Badge> </Badge>
)} */} )} */}
</Group>
</Group> </Group>
</Group> </Accordion.Control>
</Accordion.Control> <Accordion.Panel>
<Accordion.Panel> <RuleDefinition rule={r} />
<RuleDefinition rule={r} /> {r.alerts.length > 0 && (
{r.alerts.length > 0 && ( <Table mt="lg">
<Table mt="lg"> <Table.Thead>
<Table.Thead> <Table.Tr>
<Table.Tr> <Table.Th>Alert labels</Table.Th>
<Table.Th>Alert labels</Table.Th> <Table.Th>State</Table.Th>
<Table.Th>State</Table.Th> <Table.Th>Active Since</Table.Th>
<Table.Th>Active Since</Table.Th> <Table.Th>Value</Table.Th>
<Table.Th>Value</Table.Th> </Table.Tr>
</Table.Tr> </Table.Thead>
</Table.Thead> <Table.Tbody>
<Table.Tbody> {r.type === "alerting" &&
{r.type === "alerting" && r.alerts.map((a, k) => (
r.alerts.map((a, k) => ( <Fragment key={k}>
<Fragment key={k}>
<Table.Tr>
<Table.Td>
<Group gap="xs">
{Object.entries(a.labels).map(
([k, v]) => {
return (
<Badge
variant="light"
className={
badgeClasses.labelBadge
}
styles={{
label: {
textTransform: "none",
},
}}
key={k}
>
{/* TODO: Proper quote escaping */}
{k}="{v}"
</Badge>
);
}
)}
</Group>
</Table.Td>
<Table.Td>
<Badge
className={
a.state === "firing"
? badgeClasses.healthErr
: badgeClasses.healthWarn
}
>
{a.state}
</Badge>
</Table.Td>
<Table.Td>
<Tooltip label={a.activeAt}>
<Box>
{formatRelative(a.activeAt, now(), "")}
</Box>
</Tooltip>
</Table.Td>
<Table.Td>{a.value}</Table.Td>
</Table.Tr>
{showAnnotations && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={4}> <Table.Td>
<Table mt="md" mb="xl"> <Group gap="xs">
<Table.Tbody> {Object.entries(a.labels).map(
{Object.entries(a.annotations).map( ([k, v]) => {
([k, v]) => ( return (
<Table.Tr key={k}> <Badge
<Table.Th>{k}</Table.Th> variant="light"
<Table.Td>{v}</Table.Td> className={
</Table.Tr> badgeClasses.labelBadge
) }
)} styles={{
</Table.Tbody> label: {
</Table> textTransform: "none",
},
}}
key={k}
>
{/* TODO: Proper quote escaping */}
{k}="{v}"
</Badge>
);
}
)}
</Group>
</Table.Td> </Table.Td>
<Table.Td>
<Badge
className={
a.state === "firing"
? badgeClasses.healthErr
: badgeClasses.healthWarn
}
>
{a.state}
</Badge>
</Table.Td>
<Table.Td>
<Tooltip label={a.activeAt}>
<Box>
{formatRelative(
a.activeAt,
now(),
""
)}
</Box>
</Tooltip>
</Table.Td>
<Table.Td>{a.value}</Table.Td>
</Table.Tr> </Table.Tr>
)} {showAnnotations && (
</Fragment> <Table.Tr>
))} <Table.Td colSpan={4}>
</Table.Tbody> <Table mt="md" mb="xl">
</Table> <Table.Tbody>
)} {Object.entries(a.annotations).map(
</Accordion.Panel> ([k, v]) => (
</Accordion.Item> <Table.Tr key={k}>
); <Table.Th>{k}</Table.Th>
})} <Table.Td>{v}</Table.Td>
</Accordion> </Table.Tr>
</Card> )
))} )}
</Table.Tbody>
</Table>
</Table.Td>
</Table.Tr>
)}
</Fragment>
))}
</Table.Tbody>
</Table>
)}
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
</Card>
))}
</Stack>
</> </>
); );
} }