refactor(client): uniform card widths, shared primitives, clearer titles
Four widths (4/6/8/12) put four different card sizes on one page, and the 4-vs-6 difference read as inconsistency rather than intent. There are now two: every card is half-width and pairs down the page, and only Monitor status is full-width because it lays six counts across a single row. Currently down is half-width too. Seven columns would crush the monitor name there, so its rows are now two lines — name and downtime lead, with type, status code and the sparkline in a quieter line beneath. No information is lost. This collapses the layout logic: two halves fill a row exactly and a full card fills its own, so nothing is ever stretched to close a gap. The only remaining decision is a lone trailing half, promoted to full so the page does not end ragged. Verified across the catalogue, the non-admin subset, the defaults and single-card cases: no mid-page gaps anywhere. Componentization — there was real duplication: - StatLine was defined twice and MetricLine was a third copy of the same thing. All three are now CardStatLine. - A label + value + bar row appeared five times across the breakdown and ranking cards. It is now CardMeterRow. - Monitors by type and by group were the same card over different keys, so they share a BreakdownCard shell. Between them they drop from 232 lines to 83. Titles, checked against what each card actually renders: - Checks on schedule -> Check queue. It reports queue jobs and workers, not the checks themselves. - Maintenance -> Maintenance windows, Team -> Team members. Bare nouns read as navigation rather than as what the card reports. - The picker no longer borrows the sidebar's group labels. The Logs page hosts logs, queue and diagnostics tabs, so "Logs" misdescribed the three cards under it; that group is now "System". Checkmate server keeps its name — it does report on the server process. Picker buttons: Reset to defaults moves to the left, away from Done, since a destructive action should not sit next to the one people reach for by reflex. Done becomes the primary contained button. Dialog gains an optional cancelSx to allow that placement. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
This commit is contained in:
1 parent
c46ead515b
commit
e4718b814f
15 files changed
+405
-460
No files matched your search
@@ -9,6 +9,7 @@ import { Button } from "@/Components/inputs";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SxProps } from "@mui/material/styles";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const DialogInput = ({
|
||||
@@ -25,6 +26,7 @@ export const DialogInput = ({
|
||||
maxWidth,
|
||||
fullWidth = false,
|
||||
additionalButtons,
|
||||
cancelSx,
|
||||
}: {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
@@ -39,6 +41,8 @@ export const DialogInput = ({
|
||||
maxWidth?: DialogProps["maxWidth"];
|
||||
fullWidth?: boolean;
|
||||
additionalButtons?: ReactNode;
|
||||
/** Styles the cancel button — e.g. an auto margin to push it left. */
|
||||
cancelSx?: SxProps;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
@@ -94,6 +98,7 @@ export const DialogInput = ({
|
||||
loading={loading}
|
||||
variant="outlined"
|
||||
onClick={onCancel}
|
||||
sx={cancelSx}
|
||||
>
|
||||
{cancelText ?? t("common.buttons.cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Dialog, Checkbox, Button } from "@/Components/inputs";
|
||||
import { Dialog, Checkbox } from "@/Components/inputs";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { CardGroups, type CardDefinition, type CardGroup, type CardId } from "../cards";
|
||||
@@ -52,21 +52,17 @@ export const CardPicker = ({
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
title={t("pages.dashboard.picker.title")}
|
||||
// Every change applies immediately, so there is nothing to confirm and
|
||||
// no `onConfirm` — which also keeps the prominent contained button out
|
||||
// of the dialog entirely. Reset is destructive and stays quiet beside
|
||||
// the close action rather than being the loudest thing on screen.
|
||||
onCancel={onClose}
|
||||
cancelText={t("pages.dashboard.picker.done")}
|
||||
additionalButtons={
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onReset}
|
||||
>
|
||||
{t("pages.dashboard.picker.reset")}
|
||||
</Button>
|
||||
}
|
||||
// The dialog lays its actions out as cancel → additional → confirm, all
|
||||
// right-aligned. Reset takes the cancel slot with an auto right margin
|
||||
// so it sits alone on the left, away from Done — a destructive action
|
||||
// should not be adjacent to the one people reach for by reflex.
|
||||
onCancel={onReset}
|
||||
cancelText={t("pages.dashboard.picker.reset")}
|
||||
cancelSx={{ marginRight: "auto" }}
|
||||
// Changes apply immediately, so Done only dismisses; it is the primary
|
||||
// action here and carries the usual contained styling.
|
||||
onConfirm={onClose}
|
||||
confirmText={t("pages.dashboard.picker.done")}
|
||||
>
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
@@ -82,7 +78,14 @@ export const CardPicker = ({
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.disabled}
|
||||
>
|
||||
{t(`components.sidebar.menu.${group}`, { defaultValue: group })}
|
||||
{/*
|
||||
* The picker has its own group labels rather than reusing the
|
||||
* sidebar's: the Logs page hosts logs, queue and diagnostics
|
||||
* tabs, so "Logs" would misdescribe the three cards under it.
|
||||
*/}
|
||||
{t(`pages.dashboard.picker.groups.${group}`, {
|
||||
defaultValue: group,
|
||||
})}
|
||||
</Typography>
|
||||
{cards.map((card) => (
|
||||
<FormControlLabel
|
||||
|
||||
@@ -141,3 +141,98 @@ export const CardFigure = ({
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A label with its value on the right — the shape almost every stat card
|
||||
* repeats. `color` highlights the value alone, leaving the label quiet.
|
||||
*/
|
||||
export const CardStatLine = ({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
color?: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={color ?? theme.palette.text.primary}
|
||||
noWrap
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A CardStatLine with a proportional bar beneath it — the ranking and
|
||||
* breakdown row. `max` defaults to a percentage scale; pass the largest value
|
||||
* in the set to make bars relative to each other instead.
|
||||
*
|
||||
* `label` may be a node so callers can put a "N down" flag beside the name.
|
||||
*/
|
||||
export const CardMeterRow = ({
|
||||
label,
|
||||
value,
|
||||
max,
|
||||
color,
|
||||
trailing,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
value: number;
|
||||
max?: number;
|
||||
color: string;
|
||||
trailing?: ReactNode;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
flex={1}
|
||||
minWidth={0}
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
{typeof label === "string" ? (
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.primary}
|
||||
noWrap
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
{trailing}
|
||||
</Stack>
|
||||
<CardBar
|
||||
value={value}
|
||||
max={max}
|
||||
color={color}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { formatTimestamp } from "@/Utils/TimeUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardFigure, CardRow, CardRowLabel } from "../CardPrimitives";
|
||||
import { CardFigure, CardRow, CardRowLabel, CardStatLine } from "../CardPrimitives";
|
||||
|
||||
import { REFRESH_INTERVAL_MS, SLOW_REFRESH_INTERVAL_MS } from "../../cards";
|
||||
|
||||
@@ -19,39 +19,6 @@ import type { User } from "@/Types/User";
|
||||
|
||||
const MAX_ERROR_ROWS = 5;
|
||||
|
||||
const StatLine = ({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
color?: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={color ?? theme.palette.text.primary}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/** Reads as a single reassurance line while nothing is failing. */
|
||||
export const ChecksOnScheduleCard = () => {
|
||||
const theme = useTheme();
|
||||
@@ -107,11 +74,11 @@ export const ChecksOnScheduleCard = () => {
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
<StatLine
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checksOnSchedule.scheduled")}
|
||||
value={String(data.jobs)}
|
||||
/>
|
||||
<StatLine
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checksOnSchedule.runningNow")}
|
||||
value={String(data.activeJobs)}
|
||||
/>
|
||||
@@ -171,7 +138,7 @@ export const RecentErrorsCard = () => {
|
||||
<CardMessage text={t("pages.dashboard.cards.recentErrors.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
<StatLine
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.recentErrors.warnings")}
|
||||
value={String(warnings)}
|
||||
/>
|
||||
@@ -238,14 +205,14 @@ export const TeamCard = () => {
|
||||
caption={t("pages.dashboard.cards.team.members", { count: total })}
|
||||
/>
|
||||
{byRole.map(([role, count]) => (
|
||||
<StatLine
|
||||
<CardStatLine
|
||||
key={role}
|
||||
label={t(`common.auth.roles.${role}`, { defaultValue: role })}
|
||||
value={String(count)}
|
||||
/>
|
||||
))}
|
||||
{unverified > 0 && (
|
||||
<StatLine
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.team.unverified")}
|
||||
value={String(unverified)}
|
||||
color={theme.palette.warning.main}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardMeterRow, CardRow } from "../CardPrimitives";
|
||||
|
||||
export interface BreakdownBucket {
|
||||
/** Stable key and displayed label. */
|
||||
key: string;
|
||||
label: string;
|
||||
total: number;
|
||||
down: number;
|
||||
/** Where the row links to, if anywhere. */
|
||||
to?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "How the fleet splits by X" — one proportional row per bucket, flagging any
|
||||
* bucket with something down. Monitors by type and Monitors by group are the
|
||||
* same card over different keys, so they share this shell.
|
||||
*/
|
||||
export const BreakdownCard = ({
|
||||
titleKey,
|
||||
emptyKey,
|
||||
to,
|
||||
buckets,
|
||||
isLoading,
|
||||
isStale,
|
||||
error,
|
||||
}: {
|
||||
titleKey: string;
|
||||
emptyKey: string;
|
||||
to?: string;
|
||||
buckets: BreakdownBucket[];
|
||||
isLoading?: boolean;
|
||||
isStale?: boolean;
|
||||
error?: unknown;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Bars are relative to the largest bucket, not to the total, so a fleet of
|
||||
// one dominant type still shows readable proportions.
|
||||
const max = buckets[0]?.total ?? 0;
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t(titleKey)}
|
||||
to={to}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
isStale={isStale}
|
||||
>
|
||||
{buckets.length === 0 ? (
|
||||
<CardMessage text={t(emptyKey)} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
{buckets.map((bucket) => (
|
||||
<CardRow
|
||||
key={bucket.key}
|
||||
to={bucket.to}
|
||||
>
|
||||
<CardMeterRow
|
||||
label={bucket.label}
|
||||
value={bucket.total}
|
||||
max={max}
|
||||
color={
|
||||
bucket.down > 0 ? theme.palette.error.main : theme.palette.primary.main
|
||||
}
|
||||
trailing={
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{bucket.down > 0 && (
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.error.main}
|
||||
>
|
||||
{t("pages.dashboard.cards.monitorsByType.down", {
|
||||
count: bucket.down,
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{bucket.total}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,12 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { formatDuration } from "@/Utils/TimeUtils";
|
||||
import { DashboardCard } from "../DashboardCard";
|
||||
import { CardBar } from "../CardPrimitives";
|
||||
import { CardBar, CardStatLine } from "../CardPrimitives";
|
||||
|
||||
import { REFRESH_INTERVAL_MS } from "../../cards";
|
||||
|
||||
@@ -22,39 +20,6 @@ const EVENT_LOOP_WARM_MS = 50;
|
||||
const EVENT_LOOP_HOT_MS = 200;
|
||||
const HEAP_WARM_RATIO = 0.8;
|
||||
|
||||
const MetricLine = ({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
color?: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={color ?? theme.palette.text.primary}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The one card that is never empty — it reports on Checkmate itself, so it
|
||||
* works on a fresh install with zero monitors. Admin only.
|
||||
@@ -93,7 +58,7 @@ export const CheckmateServerCard = () => {
|
||||
{data && (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
||||
<MetricLine
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checkmateServer.memory")}
|
||||
value={`${Math.round(usedHeap / BYTES_PER_MB)} / ${Math.round(
|
||||
heapLimit / BYTES_PER_MB
|
||||
@@ -105,16 +70,16 @@ export const CheckmateServerCard = () => {
|
||||
color={heapColor}
|
||||
/>
|
||||
</Stack>
|
||||
<MetricLine
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checkmateServer.eventLoop")}
|
||||
value={`${eventLoopMs.toFixed(1)} ms`}
|
||||
color={eventLoopColor}
|
||||
/>
|
||||
<MetricLine
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checkmateServer.uptime")}
|
||||
value={formatDuration(data.uptimeMs)}
|
||||
/>
|
||||
<MetricLine
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checkmateServer.database")}
|
||||
value={t("pages.dashboard.cards.checkmateServer.readsWrites", {
|
||||
reads: data.mongoStats.readsPerSecond.toFixed(1),
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getStatusColor, getMonitorPath } from "@/Utils/MonitorUtils";
|
||||
import { formatStatusCode } from "@/Utils/statusCode";
|
||||
import { formatDuration } from "@/Utils/TimeUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardRow, CardRowLabel } from "../CardPrimitives";
|
||||
import { CardRow } from "../CardPrimitives";
|
||||
import { Sparkline } from "../Sparkline";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
@@ -24,10 +24,6 @@ import type { Monitor, MonitorStatus } from "@/Types/Monitor";
|
||||
// `initializing` is deliberately excluded: on a fresh install every new monitor
|
||||
// starts there, and counting it would open the card red claiming a fleet-wide
|
||||
// outage that does not exist. It is "not checked yet", not "broken".
|
||||
// Fixed column widths keep the right-hand figures aligned down the list
|
||||
// instead of jittering with each row's content.
|
||||
const DURATION_COLUMN_WIDTH = 64;
|
||||
const STATUS_CODE_COLUMN_WIDTH = 32;
|
||||
const SPARKLINE_WIDTH = 80;
|
||||
|
||||
const STATUS_RANK: Partial<Record<MonitorStatus, number>> = {
|
||||
@@ -115,53 +111,70 @@ export const CurrentlyDownCard = () => {
|
||||
color={getStatusColor(monitor.status, theme)}
|
||||
size="md"
|
||||
/>
|
||||
<CardRowLabel
|
||||
primary={monitor.name}
|
||||
secondary={monitor.url}
|
||||
/>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.secondary}
|
||||
flexShrink={0}
|
||||
>
|
||||
{monitor.type}
|
||||
</Typography>
|
||||
{since && (
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.error.main}
|
||||
flexShrink={0}
|
||||
minWidth={DURATION_COLUMN_WIDTH}
|
||||
textAlign="right"
|
||||
>
|
||||
{formatDuration(Date.now() - new Date(since).getTime())}
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
width={SPARKLINE_WIDTH}
|
||||
flexShrink={0}
|
||||
display={{ xs: "none", md: "block" }}
|
||||
>
|
||||
<Sparkline
|
||||
checks={monitor.recentChecks ?? []}
|
||||
color={theme.palette.error.main}
|
||||
/>
|
||||
</Box>
|
||||
{/*
|
||||
* Plain text rather than StatusCodeLabel: that component wraps
|
||||
* its body in a Tooltip, and a focusable element nested inside
|
||||
* the row's link is unreachable by keyboard — tabbing lands on
|
||||
* the link and activating it navigates away.
|
||||
* Two lines rather than one wide row: at half width seven
|
||||
* columns would crush the monitor name, so the name and how long
|
||||
* it has been down lead, and the supporting detail sits beneath
|
||||
* in a quieter line.
|
||||
*/}
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.secondary}
|
||||
flexShrink={0}
|
||||
minWidth={STATUS_CODE_COLUMN_WIDTH}
|
||||
textAlign="right"
|
||||
<Stack
|
||||
flex={1}
|
||||
minWidth={0}
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
>
|
||||
{formatStatusCode(latestCheck(monitor)?.statusCode, t)}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.primary}
|
||||
noWrap
|
||||
>
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
{since && (
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.error.main}
|
||||
flexShrink={0}
|
||||
>
|
||||
{formatDuration(Date.now() - new Date(since).getTime())}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
{/*
|
||||
* Plain text rather than StatusCodeLabel: that component
|
||||
* wraps its body in a Tooltip, and a focusable element
|
||||
* inside the row's link is unreachable by keyboard.
|
||||
*/}
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.secondary}
|
||||
noWrap
|
||||
>
|
||||
{monitor.type} ·{" "}
|
||||
{formatStatusCode(latestCheck(monitor)?.statusCode, t)}
|
||||
</Typography>
|
||||
<Box
|
||||
width={SPARKLINE_WIDTH}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Sparkline
|
||||
checks={monitor.recentChecks ?? []}
|
||||
color={theme.palette.error.main}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardBar, CardRow } from "../CardPrimitives";
|
||||
import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
interface GroupBucket {
|
||||
group: string;
|
||||
total: number;
|
||||
down: number;
|
||||
}
|
||||
|
||||
export const MonitorsByGroupCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
|
||||
// `group` is a plain string | null on the monitor — there is no groups
|
||||
// endpoint, so the set is whatever monitors actually declare.
|
||||
const buckets = useMemo<GroupBucket[]>(() => {
|
||||
const byGroup = new Map<string, GroupBucket>();
|
||||
const buckets = useMemo<BreakdownBucket[]>(() => {
|
||||
const byGroup = new Map<string, BreakdownBucket>();
|
||||
for (const monitor of data?.monitors ?? []) {
|
||||
if (!monitor.group) {
|
||||
continue;
|
||||
}
|
||||
const bucket = byGroup.get(monitor.group) ?? {
|
||||
group: monitor.group,
|
||||
key: monitor.group,
|
||||
label: monitor.group,
|
||||
total: 0,
|
||||
down: 0,
|
||||
};
|
||||
@@ -43,78 +29,15 @@ export const MonitorsByGroupCard = () => {
|
||||
return [...byGroup.values()].sort((a, b) => b.total - a.total);
|
||||
}, [data]);
|
||||
|
||||
const max = buckets[0]?.total ?? 0;
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.monitorsByGroup.title")}
|
||||
<BreakdownCard
|
||||
titleKey="pages.dashboard.cards.monitorsByGroup.title"
|
||||
emptyKey="pages.dashboard.cards.monitorsByGroup.empty"
|
||||
to="/uptime"
|
||||
buckets={buckets}
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{buckets.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.monitorsByGroup.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
{buckets.map((bucket) => (
|
||||
<CardRow key={bucket.group}>
|
||||
<Stack
|
||||
flex={1}
|
||||
minWidth={0}
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.primary}
|
||||
noWrap
|
||||
>
|
||||
{bucket.group}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{bucket.down > 0 && (
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.error.main}
|
||||
>
|
||||
{t("pages.dashboard.cards.monitorsByType.down", {
|
||||
count: bucket.down,
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{bucket.total}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<CardBar
|
||||
value={bucket.total}
|
||||
max={max}
|
||||
color={
|
||||
bucket.down > 0
|
||||
? theme.palette.error.main
|
||||
: theme.palette.primary.main
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,37 +1,23 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { getMonitorPath } from "@/Utils/MonitorUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardBar, CardRow } from "../CardPrimitives";
|
||||
import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
import type { MonitorType } from "@/Types/Monitor";
|
||||
|
||||
interface TypeBucket {
|
||||
type: MonitorType;
|
||||
total: number;
|
||||
down: number;
|
||||
}
|
||||
|
||||
export const MonitorsByTypeCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
|
||||
const buckets = useMemo<TypeBucket[]>(() => {
|
||||
const byType = new Map<MonitorType, TypeBucket>();
|
||||
const buckets = useMemo<BreakdownBucket[]>(() => {
|
||||
const byType = new Map<MonitorType, BreakdownBucket>();
|
||||
for (const monitor of data?.monitors ?? []) {
|
||||
const bucket = byType.get(monitor.type) ?? {
|
||||
type: monitor.type,
|
||||
key: monitor.type,
|
||||
label: monitor.type,
|
||||
total: 0,
|
||||
down: 0,
|
||||
to: `/${getMonitorPath(monitor.type)}`,
|
||||
};
|
||||
bucket.total += 1;
|
||||
if (monitor.status === "down" || monitor.status === "breached") {
|
||||
@@ -42,81 +28,15 @@ export const MonitorsByTypeCard = () => {
|
||||
return [...byType.values()].sort((a, b) => b.total - a.total);
|
||||
}, [data]);
|
||||
|
||||
const max = buckets[0]?.total ?? 0;
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.monitorsByType.title")}
|
||||
<BreakdownCard
|
||||
titleKey="pages.dashboard.cards.monitorsByType.title"
|
||||
emptyKey="pages.dashboard.cards.monitorsByType.empty"
|
||||
to="/uptime"
|
||||
buckets={buckets}
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{buckets.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.monitorsByType.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
{buckets.map((bucket) => (
|
||||
<CardRow
|
||||
key={bucket.type}
|
||||
to={`/${getMonitorPath(bucket.type)}`}
|
||||
>
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
flex={1}
|
||||
minWidth={0}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{bucket.type}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
{bucket.down > 0 && (
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.error.main}
|
||||
>
|
||||
{t("pages.dashboard.cards.monitorsByType.down", {
|
||||
count: bucket.down,
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{bucket.total}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box>
|
||||
<CardBar
|
||||
value={bucket.total}
|
||||
max={max}
|
||||
color={
|
||||
bucket.down > 0
|
||||
? theme.palette.error.main
|
||||
: theme.palette.primary.main
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { getMonitorPath, getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardBar, CardRow, CardRowLabel } from "../CardPrimitives";
|
||||
import { CardMeterRow, CardRow, CardRowLabel } from "../CardPrimitives";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
@@ -83,18 +83,12 @@ export const SlowestMonitorsCard = () => {
|
||||
key={monitor.id}
|
||||
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
|
||||
>
|
||||
<Stack
|
||||
flex={1}
|
||||
minWidth={0}
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<CardRowLabel primary={monitor.name} />
|
||||
<CardMeterRow
|
||||
label={<CardRowLabel primary={monitor.name} />}
|
||||
value={average}
|
||||
max={slowest}
|
||||
color={theme.palette.primary.main}
|
||||
trailing={
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.secondary}
|
||||
@@ -102,13 +96,8 @@ export const SlowestMonitorsCard = () => {
|
||||
>
|
||||
{Math.round(average)} ms
|
||||
</Typography>
|
||||
</Stack>
|
||||
<CardBar
|
||||
value={average}
|
||||
max={slowest}
|
||||
color={theme.palette.primary.main}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -166,18 +155,11 @@ export const LowestUptimeCard = () => {
|
||||
key={monitor.id}
|
||||
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
|
||||
>
|
||||
<Stack
|
||||
flex={1}
|
||||
minWidth={0}
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<CardRowLabel primary={monitor.name} />
|
||||
<CardMeterRow
|
||||
label={<CardRowLabel primary={monitor.name} />}
|
||||
value={percentage}
|
||||
color={theme.palette[palette].main}
|
||||
trailing={
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette[palette].main}
|
||||
@@ -185,12 +167,8 @@ export const LowestUptimeCard = () => {
|
||||
>
|
||||
{percentage.toFixed(1)}%
|
||||
</Typography>
|
||||
</Stack>
|
||||
<CardBar
|
||||
value={percentage}
|
||||
color={theme.palette[palette].main}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
</CardRow>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -10,39 +10,13 @@ import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { formatTimestamp } from "@/Utils/TimeUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardFigure, CardRow, CardRowLabel } from "../CardPrimitives";
|
||||
import { CardFigure, CardRow, CardRowLabel, CardStatLine } from "../CardPrimitives";
|
||||
|
||||
import type { IncidentSummary } from "@/Types/Incident";
|
||||
import type { Notification } from "@/Types/Notification";
|
||||
import type { MaintenanceWindow } from "@/Types/MaintenanceWindow";
|
||||
import type { StatusPage } from "@/Types/StatusPage";
|
||||
|
||||
const StatLine = ({ label, value }: { label: string; value: string }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
fontSize={typographyLevels.m}
|
||||
color={theme.palette.text.primary}
|
||||
noWrap
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/** Free when Recent incidents is present — same summary call, shared by SWR. */
|
||||
export const IncidentStatsCard = () => {
|
||||
const theme = useTheme();
|
||||
@@ -68,14 +42,14 @@ export const IncidentStatsCard = () => {
|
||||
data.totalActive > 0 ? theme.palette.error.main : theme.palette.text.primary
|
||||
}
|
||||
/>
|
||||
<StatLine
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.incidentStats.avgResolution")}
|
||||
value={t("pages.dashboard.cards.incidentStats.hours", {
|
||||
hours: data.avgResolutionTimeHours.toFixed(1),
|
||||
})}
|
||||
/>
|
||||
{data.topMonitor?.monitorName && (
|
||||
<StatLine
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.incidentStats.mostIncidents")}
|
||||
value={`${data.topMonitor.monitorName} (${data.topMonitor.incidentCount})`}
|
||||
/>
|
||||
@@ -115,7 +89,7 @@ export const NotificationChannelsCard = () => {
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
{byType.map(([type, count]) => (
|
||||
<StatLine
|
||||
<CardStatLine
|
||||
key={type}
|
||||
label={type}
|
||||
value={String(count)}
|
||||
@@ -169,7 +143,7 @@ export const MaintenanceCard = () => {
|
||||
caption={t("pages.dashboard.cards.maintenance.runningNow")}
|
||||
/>
|
||||
{next && (
|
||||
<StatLine
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.maintenance.next")}
|
||||
value={formatTimestamp(next.start)}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
// The 12-column grid the dashboard lays cards out on. A card declares the
|
||||
// minimum number of columns it needs; the grid may stretch the last card in a
|
||||
// short row up to STRETCH_MAX_WIDTH. Declared width is never a user setting.
|
||||
export const CardWidths = [4, 6, 8, 12] as const;
|
||||
// The 12-column grid the dashboard lays cards out on.
|
||||
//
|
||||
// Only two widths exist. Cards are half-width by default and pair up down the
|
||||
// page; a card is full-width only when its content genuinely needs the room,
|
||||
// which makes the wide cards read as a deliberate signal rather than as an
|
||||
// inconsistent grid. Width is declared per card type in code and is never a
|
||||
// user setting.
|
||||
export const CardWidths = [6, 12] as const;
|
||||
export type CardWidth = (typeof CardWidths)[number];
|
||||
|
||||
export const GRID_COLUMNS = 12;
|
||||
export const STRETCH_MAX_WIDTH = 8;
|
||||
export const HALF_WIDTH: CardWidth = 6;
|
||||
|
||||
// Picker groups mirror the sidebar sections: you look for a card where you
|
||||
// look for the feature it belongs to.
|
||||
|
||||
@@ -1,57 +1,40 @@
|
||||
import { GRID_COLUMNS, STRETCH_MAX_WIDTH, type CardDefinition } from "./cards";
|
||||
import { GRID_COLUMNS, HALF_WIDTH, type CardDefinition } from "./cards";
|
||||
|
||||
export interface PlacedCard {
|
||||
card: CardDefinition;
|
||||
/** Columns actually rendered — the declared width, or more if stretched. */
|
||||
/** Columns rendered. Equal to the declared width except for a lone half. */
|
||||
renderedWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Packs cards into rows of GRID_COLUMNS. Declared width is a minimum; when a
|
||||
* row ends short, its last card absorbs the remainder.
|
||||
* Packs cards into rows of GRID_COLUMNS.
|
||||
*
|
||||
* A row in the middle of the page always fills completely — leaving a hole
|
||||
* there reads as a rendering fault. Only the final row may end short, and only
|
||||
* when a single card sits in it: stretching a lone trailing card to full width
|
||||
* makes a 4-column card look like a banner. There, STRETCH_MAX_WIDTH caps the
|
||||
* growth instead.
|
||||
* With only two widths this is nearly trivial: two halves fill a row exactly
|
||||
* and a full-width card fills its own, so no card is ever stretched to close a
|
||||
* gap. The one case needing a decision is a half-width card left alone at the
|
||||
* end of the page — it is promoted to full width so the page does not end on a
|
||||
* ragged half.
|
||||
*
|
||||
* `grid-auto-flow: dense` was tried instead and does not help: no later card is
|
||||
* small enough to backfill the gaps that occur in practice.
|
||||
* `grid-auto-flow: dense` was tried and does not help: with a single body width
|
||||
* there is never a smaller card available to backfill.
|
||||
*/
|
||||
export const layoutCards = (cards: CardDefinition[]): PlacedCard[] => {
|
||||
const placed: PlacedCard[] = [];
|
||||
let row: PlacedCard[] = [];
|
||||
let used = 0;
|
||||
const placed: PlacedCard[] = cards.map((card) => ({
|
||||
card,
|
||||
renderedWidth: card.width,
|
||||
}));
|
||||
|
||||
const flush = (isFinalRow: boolean) => {
|
||||
if (row.length === 0) {
|
||||
return;
|
||||
}
|
||||
const remainder = GRID_COLUMNS - used;
|
||||
if (remainder > 0) {
|
||||
const last = row[row.length - 1];
|
||||
// A lone card ending the page grows only to the cap; every other short
|
||||
// row is filled completely so no gap is left mid-page.
|
||||
const ceiling = isFinalRow && row.length === 1 ? STRETCH_MAX_WIDTH : GRID_COLUMNS;
|
||||
last.renderedWidth = Math.min(last.renderedWidth + remainder, ceiling);
|
||||
}
|
||||
placed.push(...row);
|
||||
row = [];
|
||||
used = 0;
|
||||
};
|
||||
// Columns occupied in the row still open once every card is placed. A
|
||||
// trailing half-width card leaves exactly HALF_WIDTH here.
|
||||
const trailing = placed.reduce(
|
||||
(used, entry) => (used + entry.renderedWidth) % GRID_COLUMNS,
|
||||
0
|
||||
);
|
||||
|
||||
for (const card of cards) {
|
||||
if (used + card.width > GRID_COLUMNS) {
|
||||
flush(false);
|
||||
}
|
||||
row.push({ card, renderedWidth: card.width });
|
||||
used += card.width;
|
||||
if (used === GRID_COLUMNS) {
|
||||
flush(false);
|
||||
}
|
||||
const last = placed[placed.length - 1];
|
||||
if (last && trailing === HALF_WIDTH) {
|
||||
last.renderedWidth = GRID_COLUMNS;
|
||||
}
|
||||
flush(true);
|
||||
|
||||
return placed;
|
||||
};
|
||||
@@ -24,10 +24,10 @@ import type { CardDefinition, CardId } from "./cards";
|
||||
// Declared width is the minimum number of columns a card needs. The grid may
|
||||
// stretch the last card of a short row; nothing here is a user setting.
|
||||
//
|
||||
// Order is the render order, and the order the picker lists them in. Cards are
|
||||
// sequenced so their declared widths pair into full rows: a card left alone in
|
||||
// a row gets stretched to fill it, so an 8-wide card is kept next to a 4-wide
|
||||
// one rather than being widened back to 12.
|
||||
// Order is the render order, and the order the picker lists them in.
|
||||
//
|
||||
// Full width is reserved for Monitor status alone, which lays six counts across
|
||||
// a single row. Every other card is half-width and pairs up down the page.
|
||||
export const CARD_REGISTRY: CardDefinition[] = [
|
||||
{
|
||||
id: "monitorStatus",
|
||||
@@ -40,16 +40,14 @@ export const CARD_REGISTRY: CardDefinition[] = [
|
||||
id: "currentlyDown",
|
||||
key: "currentlyDown",
|
||||
group: "uptime",
|
||||
// Six columns per row — the densest card — but it does not need the
|
||||
// full width, and 8 leaves room for a 4-wide card beside it.
|
||||
width: 8,
|
||||
width: 6,
|
||||
component: CurrentlyDownCard,
|
||||
},
|
||||
{
|
||||
id: "failedChecks",
|
||||
key: "failedChecks",
|
||||
group: "checks",
|
||||
width: 4,
|
||||
width: 6,
|
||||
component: FailedChecksCard,
|
||||
},
|
||||
{
|
||||
@@ -91,42 +89,42 @@ export const CARD_REGISTRY: CardDefinition[] = [
|
||||
id: "recentIncidents",
|
||||
key: "recentIncidents",
|
||||
group: "incidents",
|
||||
width: 4,
|
||||
width: 6,
|
||||
component: RecentIncidentsCard,
|
||||
},
|
||||
{
|
||||
id: "incidentStats",
|
||||
key: "incidentStats",
|
||||
group: "incidents",
|
||||
width: 4,
|
||||
width: 6,
|
||||
component: IncidentStatsCard,
|
||||
},
|
||||
{
|
||||
id: "notificationChannels",
|
||||
key: "notificationChannels",
|
||||
group: "notifications",
|
||||
width: 4,
|
||||
width: 6,
|
||||
component: NotificationChannelsCard,
|
||||
},
|
||||
{
|
||||
id: "maintenance",
|
||||
key: "maintenance",
|
||||
group: "maintenance",
|
||||
width: 4,
|
||||
width: 6,
|
||||
component: MaintenanceCard,
|
||||
},
|
||||
{
|
||||
id: "statusPages",
|
||||
key: "statusPages",
|
||||
group: "statusPages",
|
||||
width: 4,
|
||||
width: 6,
|
||||
component: StatusPagesCard,
|
||||
},
|
||||
{
|
||||
id: "checkmateServer",
|
||||
key: "checkmateServer",
|
||||
group: "logs",
|
||||
width: 4,
|
||||
width: 6,
|
||||
adminOnly: true,
|
||||
component: CheckmateServerCard,
|
||||
},
|
||||
@@ -134,7 +132,7 @@ export const CARD_REGISTRY: CardDefinition[] = [
|
||||
id: "checksOnSchedule",
|
||||
key: "checksOnSchedule",
|
||||
group: "logs",
|
||||
width: 4,
|
||||
width: 6,
|
||||
adminOnly: true,
|
||||
component: ChecksOnScheduleCard,
|
||||
},
|
||||
@@ -142,7 +140,7 @@ export const CARD_REGISTRY: CardDefinition[] = [
|
||||
id: "recentErrors",
|
||||
key: "recentErrors",
|
||||
group: "logs",
|
||||
width: 4,
|
||||
width: 6,
|
||||
adminOnly: true,
|
||||
component: RecentErrorsCard,
|
||||
},
|
||||
@@ -150,7 +148,7 @@ export const CARD_REGISTRY: CardDefinition[] = [
|
||||
id: "team",
|
||||
key: "team",
|
||||
group: "settings",
|
||||
width: 4,
|
||||
width: 6,
|
||||
adminOnly: true,
|
||||
component: TeamCard,
|
||||
},
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
"empty": "No channels yet."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Maintenance",
|
||||
"title": "Maintenance windows",
|
||||
"empty": "No maintenance scheduled.",
|
||||
"runningNow": "Running now",
|
||||
"next": "Next window"
|
||||
@@ -336,7 +336,7 @@
|
||||
"draft": "Draft"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "Checks on schedule",
|
||||
"title": "Check queue",
|
||||
"healthy": "All {{jobs}} jobs on schedule across {{workers}} worker(s).",
|
||||
"failing": "Failing jobs",
|
||||
"scheduled": "Scheduled jobs",
|
||||
@@ -349,7 +349,7 @@
|
||||
"warnings": "Warnings"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"title": "Team members",
|
||||
"members": "{{count}} members",
|
||||
"unverified": "Unverified"
|
||||
}
|
||||
@@ -358,7 +358,18 @@
|
||||
"trigger": "Edit cards",
|
||||
"title": "Dashboard cards",
|
||||
"done": "Done",
|
||||
"reset": "Reset to defaults"
|
||||
"reset": "Reset to defaults",
|
||||
"groups": {
|
||||
"uptime": "Uptime",
|
||||
"infrastructure": "Infrastructure",
|
||||
"incidents": "Incidents",
|
||||
"notifications": "Notifications",
|
||||
"maintenance": "Maintenance",
|
||||
"statusPages": "Status pages",
|
||||
"checks": "Checks",
|
||||
"logs": "System",
|
||||
"settings": "Settings"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "No cards on your dashboard",
|
||||
|
||||
Reference in new issue
Block a user