Selection never showed. getRowSx set a background on the <tr>, but every <td> paints background.paper over it, and the hover rule targets cells - so hover won and selection was invisible. Selection and hover are now both cell-level rules in DataTable, with selection first so hover layers on top, and rows carry an `is-selected` class from a new isRowSelected prop. Uptime, Docker and Infrastructure tables all had the same broken copy of this; they now share the one implementation, and any future table gets it for free. action.selected/selectedHover are defined explicitly and tinted with the brand, so a selected row reads as chosen rather than dirty. Kept low (6-11%) so the highlight does not swamp the row, especially in dark mode. From code review: - Dropped the Google Fonts @import at the top of index.css. Nothing has used Inter since the switch to Geist, and it was a render-blocking third-party request on every load - exactly what self-hosting the font was meant to avoid on air-gapped installs. - TextInput's autofill override painted background.default, which used to equal paper and no longer does, so Chrome-autofilled fields showed a grey block on white cards. Now paper. - Sidebar background moved from sx to the bgcolor prop (convention rule 1, and it was a new lint warning), and the backdropFilter dropped - the background is opaque, so the blur only cost a compositing layer. - Tab no longer bolds the selected label. That is the same reflow the ToggleButton change removed, and worse here because the indicator animates toward a target that moves as the label widens. - Corrected the StatBox gradient comment: contrastText is white in light mode but near-black in dark, so the constraint is staying near `main`, not staying dark. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
367 lines
9.1 KiB
TypeScript
367 lines
9.1 KiB
TypeScript
import Stack from "@mui/material/Stack";
|
|
import Box from "@mui/material/Box";
|
|
import Typography from "@mui/material/Typography";
|
|
import {
|
|
Table,
|
|
type Header,
|
|
Pagination,
|
|
StatusLabel,
|
|
ColoredLabel,
|
|
} from "@/Components/design-elements";
|
|
import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common";
|
|
import { ActionsMenu } from "@/Components/actions-menu";
|
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
|
|
|
import { useTranslation } from "react-i18next";
|
|
import { useTheme } from "@mui/material/styles";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { usePost } from "@/Hooks/UseApi";
|
|
import { useSelector } from "react-redux";
|
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
|
|
import type { Monitor } from "@/Types/Monitor";
|
|
import type { ActionMenuItem } from "@/Components/actions-menu";
|
|
import type { RootState } from "@/Types/state";
|
|
import { Checkbox } from "@/Components/inputs";
|
|
import type { Tag } from "@/Types/Tag";
|
|
import { SPACING } from "@/Utils/Theme/constants";
|
|
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
|
import { formatPercentage } from "@/Utils/FormatUtils";
|
|
|
|
interface MonitorTableProps {
|
|
monitors: Monitor[];
|
|
tags: Tag[];
|
|
refetch: () => void;
|
|
setSelectedMonitor: (monitor: Monitor | null) => void;
|
|
sortField: string;
|
|
setSortField: (field: string) => void;
|
|
sortOrder: "asc" | "desc";
|
|
setSortOrder: (order: "asc" | "desc") => void;
|
|
count: number;
|
|
page: number;
|
|
setPage: (page: number) => void;
|
|
rowsPerPage: number;
|
|
setRowsPerPage: (rowsPerPage: number) => void;
|
|
selectedRows?: string[];
|
|
onSelectionChange?: (selected: string[]) => void;
|
|
}
|
|
|
|
export const MonitorTable = ({
|
|
monitors,
|
|
tags,
|
|
refetch,
|
|
setSelectedMonitor,
|
|
sortField,
|
|
setSortField,
|
|
sortOrder,
|
|
setSortOrder,
|
|
count,
|
|
page,
|
|
setPage,
|
|
rowsPerPage,
|
|
setRowsPerPage,
|
|
selectedRows = [],
|
|
onSelectionChange,
|
|
}: MonitorTableProps) => {
|
|
const { t } = useTranslation();
|
|
const theme = useTheme();
|
|
const navigate = useNavigate();
|
|
const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram");
|
|
const { post } = usePost<Record<string, never>, Monitor>();
|
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
|
|
|
const selectedSet = new Set(selectedRows);
|
|
const isAllSelected =
|
|
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
|
|
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
|
|
|
|
const handleSelectAll = (checked: boolean) => {
|
|
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
|
|
};
|
|
|
|
const handleSelectRow = (id: string, checked: boolean) => {
|
|
if (!onSelectionChange) return;
|
|
onSelectionChange(
|
|
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
|
|
);
|
|
};
|
|
|
|
const isRowSelected = (id: string) => selectedSet.has(id);
|
|
|
|
const handleSort = (e: React.MouseEvent, field: string) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (sortField === field) {
|
|
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
|
setSortOrder(newOrder);
|
|
} else {
|
|
setSortField(field);
|
|
setSortOrder("asc");
|
|
}
|
|
refetch();
|
|
};
|
|
|
|
const isBrowserOpenable = (monitor: Monitor): boolean => {
|
|
if (monitor.type !== "http" && monitor.type !== "pagespeed") return false;
|
|
return /^https?:\/\//i.test(monitor.url ?? "");
|
|
};
|
|
|
|
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
|
const actions: ActionMenuItem[] = [];
|
|
if (isBrowserOpenable(monitor)) {
|
|
actions.push({
|
|
id: 1,
|
|
label: t("pages.common.monitors.actions.openSite"),
|
|
action: () => {
|
|
window.open(monitor.url, "_blank", "noreferrer");
|
|
},
|
|
closeMenu: true,
|
|
});
|
|
}
|
|
actions.push(
|
|
{
|
|
id: 2,
|
|
label: t("pages.common.monitors.actions.details"),
|
|
action: () => {
|
|
navigate(`${monitor.id}`);
|
|
},
|
|
},
|
|
{
|
|
id: 3,
|
|
label: t("pages.common.monitors.actions.incidents"),
|
|
action: () => {
|
|
navigate(`/incidents?monitorId=${monitor.id}`);
|
|
},
|
|
},
|
|
{
|
|
id: 4,
|
|
label: t("pages.common.monitors.actions.configure"),
|
|
action: () => {
|
|
navigate(`/uptime/configure/${monitor.id}`);
|
|
},
|
|
},
|
|
{
|
|
id: 6,
|
|
label:
|
|
monitor.isActive === false
|
|
? t("pages.common.monitors.actions.resume")
|
|
: t("pages.common.monitors.actions.pause"),
|
|
action: async () => {
|
|
await post(`/monitors/pause/${monitor.id}`, {});
|
|
refetch();
|
|
},
|
|
closeMenu: true,
|
|
},
|
|
{
|
|
id: 7,
|
|
label: (
|
|
<Typography color={theme.palette.error.main}>
|
|
{t("pages.common.monitors.actions.delete")}
|
|
</Typography>
|
|
),
|
|
action: () => setSelectedMonitor(monitor),
|
|
closeMenu: true,
|
|
}
|
|
);
|
|
return actions;
|
|
};
|
|
|
|
const getHeaders = (chartType: string) => {
|
|
const renderSortIcon = (isActive: boolean) => (
|
|
<Box
|
|
width={16}
|
|
display="inline-flex"
|
|
justifyContent="center"
|
|
>
|
|
{isActive ? (
|
|
sortOrder === "asc" ? (
|
|
<ArrowUp size={16} />
|
|
) : (
|
|
<ArrowDown size={16} />
|
|
)
|
|
) : null}
|
|
</Box>
|
|
);
|
|
const headers: Header<Monitor>[] = [
|
|
{
|
|
id: "selection",
|
|
content: (
|
|
<Checkbox
|
|
aria-label={t("pages.common.monitors.actions.selectAll")}
|
|
indeterminate={isSomeSelected}
|
|
checked={isAllSelected}
|
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
|
/>
|
|
),
|
|
hideMobileLabel: true,
|
|
render: (row) => (
|
|
<Checkbox
|
|
aria-label={t("pages.common.monitors.actions.selectMonitor", {
|
|
name: row.name,
|
|
})}
|
|
checked={isRowSelected(row.id)}
|
|
onChange={(e) => {
|
|
handleSelectRow(row.id, e.target.checked);
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
),
|
|
onClick: (e) => e?.stopPropagation(),
|
|
},
|
|
{
|
|
id: "name",
|
|
align: "left",
|
|
mobileLabel: t("common.table.headers.name"),
|
|
content: (
|
|
<Stack
|
|
gap={theme.spacing(4)}
|
|
direction={"row"}
|
|
alignItems={"center"}
|
|
onClick={(e) => handleSort(e, "name")}
|
|
sx={{ cursor: "pointer" }}
|
|
>
|
|
{t("common.table.headers.name")}
|
|
{renderSortIcon(sortField === "name")}
|
|
</Stack>
|
|
),
|
|
|
|
render: (row) => {
|
|
return row?.name;
|
|
},
|
|
},
|
|
{
|
|
id: "status",
|
|
mobileLabel: t("common.table.headers.status"),
|
|
content: (
|
|
<Stack
|
|
gap={theme.spacing(4)}
|
|
direction={"row"}
|
|
justifyContent={"center"}
|
|
alignItems={"center"}
|
|
onClick={(e) => handleSort(e, "status")}
|
|
sx={{ cursor: "pointer" }}
|
|
>
|
|
<Box width={theme.spacing(8)} />
|
|
{t("common.table.headers.status")}
|
|
{renderSortIcon(sortField === "status")}
|
|
</Stack>
|
|
),
|
|
render: (row) => {
|
|
return <StatusLabel status={row.status} />;
|
|
},
|
|
},
|
|
{
|
|
id: "histogram",
|
|
content: t("pages.uptime.table.headers.responseTime"),
|
|
render: (row) => {
|
|
if (chartType === "histogram") {
|
|
return <HistogramResponseTime checks={row.recentChecks} />;
|
|
} else {
|
|
return <HeatmapResponseTime checks={row.recentChecks} />;
|
|
}
|
|
},
|
|
},
|
|
{
|
|
id: "uptime",
|
|
content: t("pages.uptime.table.headers.uptime"),
|
|
render: (row) => {
|
|
const uptimePercentage = row.uptimePercentage ?? 0;
|
|
const formattedPercentage = formatPercentage(uptimePercentage);
|
|
const paletteKey = getUptimePercentageColor(uptimePercentage);
|
|
const color = theme.palette[paletteKey].main;
|
|
return (
|
|
<Stack alignItems={"center"}>
|
|
<ColoredLabel
|
|
text={formattedPercentage}
|
|
color={color}
|
|
/>
|
|
</Stack>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "tags",
|
|
content: t("common.table.headers.tags"),
|
|
render: (row) => {
|
|
if (row.tags.length === 0) return "-";
|
|
|
|
return (
|
|
<Stack
|
|
justifyContent={"center"}
|
|
direction={isSmall ? "column" : "row"}
|
|
gap={theme.spacing(SPACING.LG)}
|
|
>
|
|
{row.tags.map((tag) => {
|
|
const fullTag = tags.find((t) => t.id === tag);
|
|
if (!fullTag) return null;
|
|
return (
|
|
<ColoredLabel
|
|
key={fullTag.id}
|
|
text={fullTag.name}
|
|
color={fullTag.color}
|
|
/>
|
|
);
|
|
})}
|
|
</Stack>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "type",
|
|
mobileLabel: t("common.table.headers.type"),
|
|
content: (
|
|
<Stack
|
|
gap={theme.spacing(4)}
|
|
direction={"row"}
|
|
justifyContent={"center"}
|
|
alignItems={"center"}
|
|
onClick={(e) => handleSort(e, "type")}
|
|
sx={{ cursor: "pointer" }}
|
|
>
|
|
<Box width={theme.spacing(8)} />
|
|
{t("common.table.headers.type")}
|
|
{renderSortIcon(sortField === "type")}
|
|
</Stack>
|
|
),
|
|
render: (row) => {
|
|
return row.type === "http" && row.method
|
|
? `${row.type} (${row.method})`
|
|
: row.type;
|
|
},
|
|
},
|
|
{
|
|
id: "actions",
|
|
content: t("common.table.headers.actions"),
|
|
render: (row) => {
|
|
return <ActionsMenu items={getActions(row)} />;
|
|
},
|
|
},
|
|
];
|
|
return headers;
|
|
};
|
|
|
|
const headers = getHeaders(chartType);
|
|
|
|
return (
|
|
<Box>
|
|
<Table
|
|
headers={headers}
|
|
data={monitors}
|
|
onRowClick={(row) => {
|
|
navigate(`/uptime/${row.id}`);
|
|
}}
|
|
isRowSelected={(row) => isRowSelected(row.id)}
|
|
/>
|
|
<Pagination
|
|
component="div"
|
|
count={count}
|
|
page={page}
|
|
rowsPerPage={rowsPerPage}
|
|
onPageChange={(_e, newPage) => setPage(newPage)}
|
|
onRowsPerPageChange={(e) => setRowsPerPage(Number(e.target.value))}
|
|
itemsOnPage={monitors.length}
|
|
/>
|
|
</Box>
|
|
);
|
|
};
|