|
|
|
@ -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> |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|