|
|
|
@ -8,24 +8,133 @@ import {
|
|
|
|
|
Tooltip,
|
|
|
|
|
Box,
|
|
|
|
|
Stack,
|
|
|
|
|
Input,
|
|
|
|
|
Alert,
|
|
|
|
|
TextInput,
|
|
|
|
|
Anchor,
|
|
|
|
|
} from "@mantine/core";
|
|
|
|
|
import { useSuspenseAPIQuery } from "../api/api";
|
|
|
|
|
import { AlertingRulesResult } from "../api/responseTypes/rules";
|
|
|
|
|
import { AlertingRule, AlertingRulesResult } from "../api/responseTypes/rules";
|
|
|
|
|
import badgeClasses from "../Badge.module.css";
|
|
|
|
|
import panelClasses from "../Panel.module.css";
|
|
|
|
|
import RuleDefinition from "../components/RuleDefinition";
|
|
|
|
|
import { humanizeDurationRelative, now } from "../lib/formatTime";
|
|
|
|
|
import { Fragment } from "react";
|
|
|
|
|
import { Fragment, useMemo } from "react";
|
|
|
|
|
import { StateMultiSelect } from "../components/StateMultiSelect";
|
|
|
|
|
import { useAppDispatch, useAppSelector } from "../state/hooks";
|
|
|
|
|
import { IconInfoCircle, IconSearch } from "@tabler/icons-react";
|
|
|
|
|
import { LabelBadges } from "../components/LabelBadges";
|
|
|
|
|
import { updateAlertFilters } from "../state/alertsPageSlice";
|
|
|
|
|
import { useSettings } from "../state/settingsSlice";
|
|
|
|
|
import {
|
|
|
|
|
ArrayParam,
|
|
|
|
|
BooleanParam,
|
|
|
|
|
StringParam,
|
|
|
|
|
useQueryParam,
|
|
|
|
|
withDefault,
|
|
|
|
|
} from "use-query-params";
|
|
|
|
|
import { useDebouncedValue } from "@mantine/hooks";
|
|
|
|
|
import { KVSearch } from "@nexucis/kvsearch";
|
|
|
|
|
|
|
|
|
|
type AlertsPageData = {
|
|
|
|
|
// How many rules are in each state across all groups.
|
|
|
|
|
globalCounts: {
|
|
|
|
|
inactive: number;
|
|
|
|
|
pending: number;
|
|
|
|
|
firing: number;
|
|
|
|
|
};
|
|
|
|
|
groups: {
|
|
|
|
|
name: string;
|
|
|
|
|
file: string;
|
|
|
|
|
// How many rules are in each state for this group.
|
|
|
|
|
counts: {
|
|
|
|
|
total: number;
|
|
|
|
|
inactive: number;
|
|
|
|
|
pending: number;
|
|
|
|
|
firing: number;
|
|
|
|
|
};
|
|
|
|
|
rules: {
|
|
|
|
|
rule: AlertingRule;
|
|
|
|
|
// How many alerts are in each state for this rule.
|
|
|
|
|
counts: {
|
|
|
|
|
firing: number;
|
|
|
|
|
pending: number;
|
|
|
|
|
};
|
|
|
|
|
}[];
|
|
|
|
|
}[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const kvSearch = new KVSearch<AlertingRule>({
|
|
|
|
|
shouldSort: true,
|
|
|
|
|
indexedKeys: ["name", "labels", ["labels", /.*/]],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const buildAlertsPageData = (
|
|
|
|
|
data: AlertingRulesResult,
|
|
|
|
|
debouncedSearch: string,
|
|
|
|
|
stateFilter: (string | null)[]
|
|
|
|
|
) => {
|
|
|
|
|
const pageData: AlertsPageData = {
|
|
|
|
|
globalCounts: {
|
|
|
|
|
inactive: 0,
|
|
|
|
|
pending: 0,
|
|
|
|
|
firing: 0,
|
|
|
|
|
},
|
|
|
|
|
groups: [],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (const group of data.groups) {
|
|
|
|
|
const groupCounts = {
|
|
|
|
|
total: 0,
|
|
|
|
|
inactive: 0,
|
|
|
|
|
pending: 0,
|
|
|
|
|
firing: 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (const r of group.rules) {
|
|
|
|
|
groupCounts.total++;
|
|
|
|
|
switch (r.state) {
|
|
|
|
|
case "inactive":
|
|
|
|
|
pageData.globalCounts.inactive++;
|
|
|
|
|
groupCounts.inactive++;
|
|
|
|
|
break;
|
|
|
|
|
case "firing":
|
|
|
|
|
pageData.globalCounts.firing++;
|
|
|
|
|
groupCounts.firing++;
|
|
|
|
|
break;
|
|
|
|
|
case "pending":
|
|
|
|
|
pageData.globalCounts.pending++;
|
|
|
|
|
groupCounts.pending++;
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
throw new Error(`Unknown rule state: ${r.state}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const filteredRules: AlertingRule[] = (
|
|
|
|
|
debouncedSearch === ""
|
|
|
|
|
? group.rules
|
|
|
|
|
: kvSearch
|
|
|
|
|
.filter(debouncedSearch, group.rules)
|
|
|
|
|
.map((value) => value.original)
|
|
|
|
|
).filter((r) => stateFilter.length === 0 || stateFilter.includes(r.state));
|
|
|
|
|
|
|
|
|
|
pageData.groups.push({
|
|
|
|
|
name: group.name,
|
|
|
|
|
file: group.file,
|
|
|
|
|
counts: groupCounts,
|
|
|
|
|
rules: filteredRules.map((r) => ({
|
|
|
|
|
rule: r,
|
|
|
|
|
counts: {
|
|
|
|
|
firing: r.alerts.filter((a) => a.state === "firing").length,
|
|
|
|
|
pending: r.alerts.filter((a) => a.state === "pending").length,
|
|
|
|
|
},
|
|
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return pageData;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default function AlertsPage() {
|
|
|
|
|
// Fetch the alerting rules data.
|
|
|
|
|
const { data } = useSuspenseAPIQuery<AlertingRulesResult>({
|
|
|
|
|
path: `/rules`,
|
|
|
|
|
params: {
|
|
|
|
@ -33,23 +142,36 @@ export default function AlertsPage() {
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const dispatch = useAppDispatch();
|
|
|
|
|
const { showAnnotations } = useSettings();
|
|
|
|
|
const filters = useAppSelector((state) => state.alertsPage.filters);
|
|
|
|
|
|
|
|
|
|
const ruleStatsCount = {
|
|
|
|
|
inactive: 0,
|
|
|
|
|
pending: 0,
|
|
|
|
|
firing: 0,
|
|
|
|
|
};
|
|
|
|
|
// Define URL query params.
|
|
|
|
|
const [stateFilter, setStateFilter] = useQueryParam(
|
|
|
|
|
"state",
|
|
|
|
|
withDefault(ArrayParam, [])
|
|
|
|
|
);
|
|
|
|
|
const [searchFilter, setSearchFilter] = useQueryParam(
|
|
|
|
|
"search",
|
|
|
|
|
withDefault(StringParam, "")
|
|
|
|
|
);
|
|
|
|
|
const [debouncedSearch] = useDebouncedValue<string>(searchFilter.trim(), 250);
|
|
|
|
|
const [showEmptyGroups, setShowEmptyGroups] = useQueryParam(
|
|
|
|
|
"showEmptyGroups",
|
|
|
|
|
withDefault(BooleanParam, true)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
data.data.groups.forEach((el) =>
|
|
|
|
|
el.rules.forEach((r) => ruleStatsCount[r.state]++)
|
|
|
|
|
// Update the page data whenever the fetched data or filters change.
|
|
|
|
|
const alertsPageData: AlertsPageData = useMemo(
|
|
|
|
|
() => buildAlertsPageData(data.data, debouncedSearch, stateFilter),
|
|
|
|
|
[data, stateFilter, debouncedSearch]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const shownGroups = showEmptyGroups
|
|
|
|
|
? alertsPageData.groups
|
|
|
|
|
: alertsPageData.groups.filter((g) => g.rules.length > 0);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<Group mb="md" mt="xs">
|
|
|
|
|
<Stack mt="xs">
|
|
|
|
|
<Group>
|
|
|
|
|
<StateMultiSelect
|
|
|
|
|
options={["inactive", "pending", "firing"]}
|
|
|
|
|
optionClass={(o) =>
|
|
|
|
@ -59,21 +181,46 @@ export default function AlertsPage() {
|
|
|
|
|
? badgeClasses.healthWarn
|
|
|
|
|
: badgeClasses.healthErr
|
|
|
|
|
}
|
|
|
|
|
placeholder="Filter by alert state"
|
|
|
|
|
values={filters.state}
|
|
|
|
|
onChange={(values) => dispatch(updateAlertFilters({ state: values }))}
|
|
|
|
|
optionCount={(o) =>
|
|
|
|
|
alertsPageData.globalCounts[
|
|
|
|
|
o as keyof typeof alertsPageData.globalCounts
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
placeholder="Filter by rule state"
|
|
|
|
|
values={(stateFilter?.filter((v) => v !== null) as string[]) || []}
|
|
|
|
|
onChange={(values) => setStateFilter(values)}
|
|
|
|
|
/>
|
|
|
|
|
<Input
|
|
|
|
|
<TextInput
|
|
|
|
|
flex={1}
|
|
|
|
|
leftSection={<IconSearch size={14} />}
|
|
|
|
|
placeholder="Filter by alert name or labels"
|
|
|
|
|
></Input>
|
|
|
|
|
placeholder="Filter by rule name or labels"
|
|
|
|
|
value={searchFilter || ""}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
setSearchFilter(event.currentTarget.value || null)
|
|
|
|
|
}
|
|
|
|
|
></TextInput>
|
|
|
|
|
</Group>
|
|
|
|
|
{alertsPageData.groups.length === 0 ? (
|
|
|
|
|
<Alert title="No rules found" icon={<IconInfoCircle size={14} />}>
|
|
|
|
|
No rules found.
|
|
|
|
|
</Alert>
|
|
|
|
|
) : (
|
|
|
|
|
!showEmptyGroups &&
|
|
|
|
|
alertsPageData.groups.length !== shownGroups.length && (
|
|
|
|
|
<Alert
|
|
|
|
|
title="Hiding groups with no matching rules"
|
|
|
|
|
icon={<IconInfoCircle size={14} />}
|
|
|
|
|
>
|
|
|
|
|
Hiding {alertsPageData.groups.length - shownGroups.length} empty
|
|
|
|
|
groups due to filters or no rules.
|
|
|
|
|
<Anchor ml="md" fz="1em" onClick={() => setShowEmptyGroups(true)}>
|
|
|
|
|
Show empty groups
|
|
|
|
|
</Anchor>
|
|
|
|
|
</Alert>
|
|
|
|
|
)
|
|
|
|
|
)}
|
|
|
|
|
<Stack>
|
|
|
|
|
{data.data.groups.map((g, i) => {
|
|
|
|
|
const filteredRules = g.rules.filter(
|
|
|
|
|
(r) => filters.state.length === 0 || filters.state.includes(r.state)
|
|
|
|
|
);
|
|
|
|
|
{shownGroups.map((g, i) => {
|
|
|
|
|
return (
|
|
|
|
|
<Card
|
|
|
|
|
shadow="xs"
|
|
|
|
@ -94,138 +241,165 @@ export default function AlertsPage() {
|
|
|
|
|
{g.file}
|
|
|
|
|
</Text>
|
|
|
|
|
</Group>
|
|
|
|
|
<Group>
|
|
|
|
|
{g.counts.firing > 0 && (
|
|
|
|
|
<Badge className={badgeClasses.healthErr}>
|
|
|
|
|
firing ({g.counts.firing})
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{g.counts.pending > 0 && (
|
|
|
|
|
<Badge className={badgeClasses.healthWarn}>
|
|
|
|
|
pending ({g.counts.pending})
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{g.counts.inactive > 0 && (
|
|
|
|
|
<Badge className={badgeClasses.healthOk}>
|
|
|
|
|
inactive ({g.counts.inactive})
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</Group>
|
|
|
|
|
</Group>
|
|
|
|
|
{filteredRules.length === 0 && (
|
|
|
|
|
<Alert
|
|
|
|
|
title="No matching rules"
|
|
|
|
|
icon={<IconInfoCircle size={14} />}
|
|
|
|
|
>
|
|
|
|
|
No rules found that match your filter criteria.
|
|
|
|
|
{g.counts.total === 0 ? (
|
|
|
|
|
<Alert title="No rules" icon={<IconInfoCircle />}>
|
|
|
|
|
No rules in this group.
|
|
|
|
|
<Anchor
|
|
|
|
|
ml="md"
|
|
|
|
|
fz="1em"
|
|
|
|
|
onClick={() => setShowEmptyGroups(false)}
|
|
|
|
|
>
|
|
|
|
|
Hide empty groups
|
|
|
|
|
</Anchor>
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
|
|
|
|
<Accordion multiple variant="separated">
|
|
|
|
|
{filteredRules.map((r, j) => {
|
|
|
|
|
const numFiring = r.alerts.filter(
|
|
|
|
|
(a) => a.state === "firing"
|
|
|
|
|
).length;
|
|
|
|
|
const numPending = r.alerts.filter(
|
|
|
|
|
(a) => a.state === "pending"
|
|
|
|
|
).length;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Accordion.Item
|
|
|
|
|
styles={{
|
|
|
|
|
item: {
|
|
|
|
|
// TODO: This transparency hack is an OK workaround to make the collapsed items
|
|
|
|
|
// have a different background color than their surrounding group card in dark mode,
|
|
|
|
|
// but it would be better to use CSS to override the light/dark colors for
|
|
|
|
|
// collapsed/expanded accordion items.
|
|
|
|
|
backgroundColor: "#c0c0c015",
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
key={j}
|
|
|
|
|
value={j.toString()}
|
|
|
|
|
className={
|
|
|
|
|
numFiring > 0
|
|
|
|
|
? panelClasses.panelHealthErr
|
|
|
|
|
: numPending > 0
|
|
|
|
|
? panelClasses.panelHealthWarn
|
|
|
|
|
: panelClasses.panelHealthOk
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<Accordion.Control>
|
|
|
|
|
<Group wrap="nowrap" justify="space-between" mr="lg">
|
|
|
|
|
<Text>{r.name}</Text>
|
|
|
|
|
<Group gap="xs">
|
|
|
|
|
{numFiring > 0 && (
|
|
|
|
|
<Badge className={badgeClasses.healthErr}>
|
|
|
|
|
firing ({numFiring})
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{numPending > 0 && (
|
|
|
|
|
<Badge className={badgeClasses.healthWarn}>
|
|
|
|
|
pending ({numPending})
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
) : g.rules.length === 0 ? (
|
|
|
|
|
<Alert title="No matching rules" icon={<IconInfoCircle />}>
|
|
|
|
|
No rules in this group match your filter criteria (omitted{" "}
|
|
|
|
|
{g.counts.total} filtered rules).
|
|
|
|
|
<Anchor
|
|
|
|
|
ml="md"
|
|
|
|
|
fz="1em"
|
|
|
|
|
onClick={() => setShowEmptyGroups(false)}
|
|
|
|
|
>
|
|
|
|
|
Hide empty groups
|
|
|
|
|
</Anchor>
|
|
|
|
|
</Alert>
|
|
|
|
|
) : (
|
|
|
|
|
<Accordion multiple variant="separated">
|
|
|
|
|
{g.rules.map((r, j) => {
|
|
|
|
|
return (
|
|
|
|
|
<Accordion.Item
|
|
|
|
|
styles={{
|
|
|
|
|
item: {
|
|
|
|
|
// TODO: This transparency hack is an OK workaround to make the collapsed items
|
|
|
|
|
// have a different background color than their surrounding group card in dark mode,
|
|
|
|
|
// but it would be better to use CSS to override the light/dark colors for
|
|
|
|
|
// collapsed/expanded accordion items.
|
|
|
|
|
backgroundColor: "#c0c0c015",
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
key={j}
|
|
|
|
|
value={j.toString()}
|
|
|
|
|
className={
|
|
|
|
|
r.counts.firing > 0
|
|
|
|
|
? panelClasses.panelHealthErr
|
|
|
|
|
: r.counts.pending > 0
|
|
|
|
|
? panelClasses.panelHealthWarn
|
|
|
|
|
: panelClasses.panelHealthOk
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<Accordion.Control>
|
|
|
|
|
<Group wrap="nowrap" justify="space-between" mr="lg">
|
|
|
|
|
<Text>{r.rule.name}</Text>
|
|
|
|
|
<Group gap="xs">
|
|
|
|
|
{r.counts.firing > 0 && (
|
|
|
|
|
<Badge className={badgeClasses.healthErr}>
|
|
|
|
|
firing ({r.counts.firing})
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{r.counts.pending > 0 && (
|
|
|
|
|
<Badge className={badgeClasses.healthWarn}>
|
|
|
|
|
pending ({r.counts.pending})
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</Group>
|
|
|
|
|
</Group>
|
|
|
|
|
</Group>
|
|
|
|
|
</Accordion.Control>
|
|
|
|
|
<Accordion.Panel>
|
|
|
|
|
<RuleDefinition rule={r} />
|
|
|
|
|
{r.alerts.length > 0 && (
|
|
|
|
|
<Table mt="lg">
|
|
|
|
|
<Table.Thead>
|
|
|
|
|
<Table.Tr>
|
|
|
|
|
<Table.Th>Alert labels</Table.Th>
|
|
|
|
|
<Table.Th>State</Table.Th>
|
|
|
|
|
<Table.Th>Active Since</Table.Th>
|
|
|
|
|
<Table.Th>Value</Table.Th>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
</Table.Thead>
|
|
|
|
|
<Table.Tbody>
|
|
|
|
|
{r.type === "alerting" &&
|
|
|
|
|
r.alerts.map((a, k) => (
|
|
|
|
|
<Fragment key={k}>
|
|
|
|
|
<Table.Tr>
|
|
|
|
|
<Table.Td>
|
|
|
|
|
<LabelBadges labels={a.labels} />
|
|
|
|
|
</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>
|
|
|
|
|
{humanizeDurationRelative(
|
|
|
|
|
a.activeAt,
|
|
|
|
|
now(),
|
|
|
|
|
""
|
|
|
|
|
)}
|
|
|
|
|
</Box>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</Table.Td>
|
|
|
|
|
<Table.Td>{a.value}</Table.Td>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
{showAnnotations && (
|
|
|
|
|
</Accordion.Control>
|
|
|
|
|
<Accordion.Panel>
|
|
|
|
|
<RuleDefinition rule={r.rule} />
|
|
|
|
|
{r.rule.alerts.length > 0 && (
|
|
|
|
|
<Table mt="lg">
|
|
|
|
|
<Table.Thead>
|
|
|
|
|
<Table.Tr>
|
|
|
|
|
<Table.Th>Alert labels</Table.Th>
|
|
|
|
|
<Table.Th>State</Table.Th>
|
|
|
|
|
<Table.Th>Active Since</Table.Th>
|
|
|
|
|
<Table.Th>Value</Table.Th>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
</Table.Thead>
|
|
|
|
|
<Table.Tbody>
|
|
|
|
|
{r.rule.type === "alerting" &&
|
|
|
|
|
r.rule.alerts.map((a, k) => (
|
|
|
|
|
<Fragment key={k}>
|
|
|
|
|
<Table.Tr>
|
|
|
|
|
<Table.Td colSpan={4}>
|
|
|
|
|
<Table mt="md" mb="xl">
|
|
|
|
|
<Table.Tbody>
|
|
|
|
|
{Object.entries(
|
|
|
|
|
a.annotations
|
|
|
|
|
).map(([k, v]) => (
|
|
|
|
|
<Table.Tr key={k}>
|
|
|
|
|
<Table.Th>{k}</Table.Th>
|
|
|
|
|
<Table.Td>{v}</Table.Td>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
))}
|
|
|
|
|
</Table.Tbody>
|
|
|
|
|
</Table>
|
|
|
|
|
<Table.Td>
|
|
|
|
|
<LabelBadges labels={a.labels} />
|
|
|
|
|
</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>
|
|
|
|
|
{humanizeDurationRelative(
|
|
|
|
|
a.activeAt,
|
|
|
|
|
now(),
|
|
|
|
|
""
|
|
|
|
|
)}
|
|
|
|
|
</Box>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</Table.Td>
|
|
|
|
|
<Table.Td>{a.value}</Table.Td>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
)}
|
|
|
|
|
</Fragment>
|
|
|
|
|
))}
|
|
|
|
|
</Table.Tbody>
|
|
|
|
|
</Table>
|
|
|
|
|
)}
|
|
|
|
|
</Accordion.Panel>
|
|
|
|
|
</Accordion.Item>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</Accordion>
|
|
|
|
|
{showAnnotations && (
|
|
|
|
|
<Table.Tr>
|
|
|
|
|
<Table.Td colSpan={4}>
|
|
|
|
|
<Table mt="md" mb="xl">
|
|
|
|
|
<Table.Tbody>
|
|
|
|
|
{Object.entries(
|
|
|
|
|
a.annotations
|
|
|
|
|
).map(([k, v]) => (
|
|
|
|
|
<Table.Tr key={k}>
|
|
|
|
|
<Table.Th>{k}</Table.Th>
|
|
|
|
|
<Table.Td>{v}</Table.Td>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
))}
|
|
|
|
|
</Table.Tbody>
|
|
|
|
|
</Table>
|
|
|
|
|
</Table.Td>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
)}
|
|
|
|
|
</Fragment>
|
|
|
|
|
))}
|
|
|
|
|
</Table.Tbody>
|
|
|
|
|
</Table>
|
|
|
|
|
)}
|
|
|
|
|
</Accordion.Panel>
|
|
|
|
|
</Accordion.Item>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</Accordion>
|
|
|
|
|
)}
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</Stack>
|
|
|
|
|
</>
|
|
|
|
|
</Stack>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|