feat(client): add the card picker and the remaining catalogue cards

Completes the catalogue at 17 cards and adds the picker that chooses
between them.

New cards: Slowest monitors, Lowest uptime, Monitors by group, Busiest
servers, Incident stats, Notification channels, Maintenance, Status
pages, Checks on schedule, Recent errors, Team.

Data caveats honoured from the specs:

- Rankings exclude paused monitors. A paused ping monitor reads 0% uptime
  and would otherwise rank beside a genuinely down one; neither is a live
  problem. Rankings also hide below two entries — a ranking of one is not
  a ranking.
- Busiest servers reads cpu/memory/disk from the newest recentChecks
  entry (snake_case, 0-1 fractions) and renders a dash for a down server
  rather than 0%.
- Maintenance compares start/end client-side: `active` is a user on/off
  flag, not 'running now', and repeat > 0 means end may sit in the past
  for a live window.
- Notification channels renders only the type. The response also carries
  accessToken and accountSid; those must never reach the DOM.
- Recent errors is worded 'recent' because /logs is a 1000-entry
  in-memory ring buffer that rotates fast and is wiped on restart.

Also fixes a layout gap the six default cards never exposed: a 6-wide
card followed by an 8-wide one left a lone card stretched to 8 in a
middle row, with 4 empty columns beside it. Mid-page rows now always fill
to 12; only the final row may end short, and the cap applies only there,
so a lone trailing 4-wide card does not become a banner.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
This commit is contained in:
gorkem-bwl committed 2026-08-29 09:37:34 -04:00
1 parent 23b00401ca
commit f86ead0806
10 files changed
+1300 -11

No files matched your search

@@ -0,0 +1,102 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import FormControlLabel from "@mui/material/FormControlLabel";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
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";
interface CardPickerProps {
open: boolean;
available: CardDefinition[];
selected: CardId[];
onAdd: (id: CardId) => void;
onRemove: (id: CardId) => void;
onReset: () => void;
onClose: () => void;
}
/**
* Cards are grouped by sidebar section, not by load cost: you look for a card
* where you look for the feature it belongs to. Admin-only cards never reach
* `available` for a non-admin, so nobody is offered a card they cannot load.
*/
export const CardPicker = ({
open,
available,
selected,
onAdd,
onRemove,
onReset,
onClose,
}: CardPickerProps) => {
const theme = useTheme();
const { t } = useTranslation();
const grouped = useMemo(() => {
return CardGroups.map((group: CardGroup) => ({
group,
cards: available.filter((card) => card.group === group),
})).filter((entry) => entry.cards.length > 0);
}, [available]);
const chosen = new Set(selected);
return (
<Dialog
open={open}
maxWidth="sm"
fullWidth
title={t("pages.dashboard.picker.title")}
onCancel={onClose}
onConfirm={onReset}
cancelText={t("pages.dashboard.picker.done")}
confirmText={t("pages.dashboard.picker.reset")}
>
<Stack
gap={theme.spacing(LAYOUT.MD)}
pt={theme.spacing(LAYOUT.SM)}
>
{grouped.map(({ group, cards }) => (
<Stack
key={group}
gap={theme.spacing(LAYOUT.XXS)}
>
<Typography
variant="eyebrow"
fontSize={typographyLevels.s}
color={theme.palette.text.disabled}
>
{t(`components.sidebar.menu.${group}`, { defaultValue: group })}
</Typography>
{cards.map((card) => (
<FormControlLabel
key={card.id}
label={
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
>
{t(`pages.dashboard.cards.${card.key}.title`)}
</Typography>
}
control={
<Checkbox
checked={chosen.has(card.id)}
onChange={() =>
chosen.has(card.id) ? onRemove(card.id) : onAdd(card.id)
}
/>
}
/>
))}
</Stack>
))}
</Stack>
</Dialog>
);
};
@@ -0,0 +1,256 @@
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 { useGet } from "@/Hooks/UseApi";
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 type { QueueMetrics } from "@/Types/Queue";
import type { Log } from "@/Types/Log";
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.s}
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();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useGet<QueueMetrics>(
"/queue/metrics",
{},
{ refreshInterval: 30000 }
);
const healthy = data?.failingJobs === 0;
return (
<DashboardCard
title={t("pages.dashboard.cards.checksOnSchedule.title")}
to="/logs"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{data && (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{healthy ? (
<CardMessage
text={t("pages.dashboard.cards.checksOnSchedule.healthy", {
jobs: data.jobs,
workers: data.workers.length,
})}
/>
) : (
<>
<CardFigure
value={String(data.failingJobs)}
caption={t("pages.dashboard.cards.checksOnSchedule.failing")}
color={theme.palette.error.main}
/>
<Stack gap={theme.spacing(LAYOUT.XS)}>
{data.jobsWithFailures.slice(0, MAX_ERROR_ROWS).map((job) => (
<CardRow key={String(job.monitorId)}>
<CardRowLabel
primary={job.monitorUrl ?? String(job.monitorId)}
secondary={job.failReason}
/>
<Typography
fontSize={typographyLevels.s}
color={theme.palette.error.main}
flexShrink={0}
>
{job.failCount}
</Typography>
</CardRow>
))}
</Stack>
</>
)}
<StatLine
label={t("pages.dashboard.cards.checksOnSchedule.scheduled")}
value={String(data.jobs)}
/>
<StatLine
label={t("pages.dashboard.cards.checksOnSchedule.runningNow")}
value={String(data.activeJobs)}
/>
</Stack>
)}
</DashboardCard>
);
};
/**
* /logs is an in-memory ring buffer capped at 1000 entries, wiped on restart,
* and it ignores page/rowsPerPage. It rotates fast, so this is deliberately
* worded "recent" rather than "all".
*/
export const RecentErrorsCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useGet<Log[]>(
"/logs",
{},
{ refreshInterval: 60000 }
);
const { errors, warnings, newest } = useMemo(() => {
const relevant = (data ?? []).filter(
(entry) => entry.level === "error" || entry.level === "warn"
);
const sorted = [...relevant].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
return {
errors: relevant.filter((entry) => entry.level === "error").length,
warnings: relevant.filter((entry) => entry.level === "warn").length,
newest: sorted.slice(0, MAX_ERROR_ROWS),
};
}, [data]);
return (
<DashboardCard
title={t("pages.dashboard.cards.recentErrors.title")}
to="/logs"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
action={
errors > 0 ? (
<Typography
fontSize={typographyLevels.s}
color={theme.palette.error.main}
>
{t("pages.dashboard.cards.recentErrors.count", { count: errors })}
</Typography>
) : null
}
>
{newest.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.recentErrors.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
<StatLine
label={t("pages.dashboard.cards.recentErrors.warnings")}
value={String(warnings)}
/>
<Stack gap={theme.spacing(LAYOUT.XS)}>
{newest.map((entry, index) => (
<CardRow key={`${entry.timestamp}-${index}`}>
<CardRowLabel
primary={entry.message}
secondary={[entry.service, entry.method].filter(Boolean).join(" · ")}
/>
<Typography
fontSize={typographyLevels.s}
color={
entry.level === "error"
? theme.palette.error.main
: theme.palette.warning.main
}
flexShrink={0}
>
{formatTimestamp(entry.timestamp)}
</Typography>
</CardRow>
))}
</Stack>
</Stack>
)}
</DashboardCard>
);
};
/** Never empty — at minimum the current user exists. */
export const TeamCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useGet<User[]>("/auth/users");
const { total, byRole, unverified } = useMemo(() => {
const users = data ?? [];
const roles = new Map<string, number>();
for (const user of users) {
for (const role of user.role ?? []) {
roles.set(role, (roles.get(role) ?? 0) + 1);
}
}
return {
total: users.length,
byRole: [...roles.entries()].sort((a, b) => b[1] - a[1]),
unverified: users.filter((user) => !user.isVerified).length,
};
}, [data]);
return (
<DashboardCard
title={t("pages.dashboard.cards.team.title")}
to="/account/team"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{data && (
<Stack gap={theme.spacing(LAYOUT.SM)}>
<CardFigure
value={String(total)}
caption={t("pages.dashboard.cards.team.members", { count: total })}
/>
{byRole.map(([role, count]) => (
<StatLine
key={role}
label={t(`common.auth.roles.${role}`, { defaultValue: role })}
value={String(count)}
/>
))}
{unverified > 0 && (
<StatLine
label={t("pages.dashboard.cards.team.unverified")}
value={String(unverified)}
color={theme.palette.warning.main}
/>
)}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,152 @@
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, CardRowLabel } from "../CardPrimitives";
import { useMonitors } from "../../useDashboardData";
import type { Monitor } from "@/Types/Monitor";
// <60% fine, 60-85% warm, above that hot.
const WARM = 60;
const HOT = 85;
interface ServerUsage {
monitor: Monitor;
cpu: number | null;
memory: number | null;
disk: number | null;
peak: number;
}
const Metric = ({ label, value }: { label: string; value: number | null }) => {
const theme = useTheme();
const color =
value === null
? theme.palette.text.secondary
: value > HOT
? theme.palette.error.main
: value > WARM
? theme.palette.warning.main
: theme.palette.primary.main;
return (
<Stack
flex={1}
minWidth={0}
gap={theme.spacing(LAYOUT.XXS)}
>
<Stack
direction="row"
alignItems="baseline"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.XS)}
>
<Typography
fontSize={typographyLevels.s}
color={theme.palette.text.secondary}
>
{label}
</Typography>
<Typography
fontSize={typographyLevels.s}
color={color}
>
{/* A down server reports nothing — show a dash, never 0%. */}
{value === null ? "—" : `${value.toFixed(1)}%`}
</Typography>
</Stack>
<CardBar
value={value ?? 0}
color={color}
/>
</Stack>
);
};
export const BusiestServersCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useMonitors();
const servers = useMemo<ServerUsage[]>(() => {
return (data?.monitors ?? [])
.filter((monitor) => monitor.type === "hardware")
.map((monitor) => {
const checks = monitor.recentChecks ?? [];
// recentChecks is oldest-first: the newest snapshot is last.
const latest = checks[checks.length - 1];
// Note snake_case here, unlike camelCase in HardwareCheckStats.
// These are 0-1 fractions, so scale to percent.
const toPercent = (value: number | undefined) =>
typeof value === "number" ? value * 100 : null;
const cpu = toPercent(latest?.cpu?.usage_percent);
const memory = toPercent(latest?.memory?.usage_percent);
const disks = (latest?.disk ?? [])
.map((entry) => entry.usage_percent)
.filter((value): value is number => typeof value === "number");
const disk = disks.length > 0 ? Math.max(...disks) * 100 : null;
return {
monitor,
cpu,
memory,
disk,
peak: Math.max(cpu ?? 0, memory ?? 0, disk ?? 0),
};
})
.sort((a, b) => b.peak - a.peak);
}, [data]);
return (
<DashboardCard
title={t("pages.dashboard.cards.busiestServers.title")}
to="/infrastructure"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{servers.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.busiestServers.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.MD)}>
{servers.map(({ monitor, cpu, memory, disk }) => (
<CardRow
key={monitor.id}
to={`/infrastructure/${monitor.id}`}
>
<Stack
flex={1}
minWidth={0}
gap={theme.spacing(LAYOUT.XS)}
>
<CardRowLabel primary={monitor.name} />
<Stack
direction="row"
gap={theme.spacing(LAYOUT.MD)}
>
<Metric
label={t("pages.dashboard.cards.busiestServers.cpu")}
value={cpu}
/>
<Metric
label={t("pages.dashboard.cards.busiestServers.memory")}
value={memory}
/>
<Metric
label={t("pages.dashboard.cards.busiestServers.disk")}
value={disk}
/>
</Stack>
</Stack>
</CardRow>
))}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,120 @@
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 { 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>();
for (const monitor of data?.monitors ?? []) {
if (!monitor.group) {
continue;
}
const bucket = byGroup.get(monitor.group) ?? {
group: monitor.group,
total: 0,
down: 0,
};
bucket.total += 1;
if (monitor.status === "down" || monitor.status === "breached") {
bucket.down += 1;
}
byGroup.set(monitor.group, bucket);
}
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")}
to="/uptime"
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.s}
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>
);
};
@@ -0,0 +1,201 @@
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 { getMonitorPath, getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { CardBar, CardRow, CardRowLabel } from "../CardPrimitives";
import { useMonitors } from "../../useDashboardData";
import type { Monitor } from "@/Types/Monitor";
const TOP_N = 5;
// A ranking of one is not a ranking.
const MIN_RANKABLE = 2;
const averageResponseTime = (monitor: Monitor): number | null => {
const times = (monitor.recentChecks ?? [])
.map((check) => check.responseTime)
.filter((value): value is number => typeof value === "number");
if (times.length === 0) {
return null;
}
return times.reduce((sum, value) => sum + value, 0) / times.length;
};
/** Header reads "top 5 of 12" so a truncated ranking says so. */
const RankingCount = ({ shown, total }: { shown: number; total: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<Typography
fontSize={typographyLevels.s}
color={theme.palette.text.secondary}
>
{t("pages.dashboard.cards.ranking.topOf", { shown, total })}
</Typography>
);
};
export const SlowestMonitorsCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useMonitors();
const ranked = useMemo(() => {
return (data?.monitors ?? [])
.filter((monitor) => monitor.status !== "paused")
.map((monitor) => ({ monitor, average: averageResponseTime(monitor) }))
.filter(
(entry): entry is { monitor: Monitor; average: number } => entry.average !== null
)
.sort((a, b) => b.average - a.average);
}, [data]);
const slowest = ranked[0]?.average ?? 0;
return (
<DashboardCard
title={t("pages.dashboard.cards.slowestMonitors.title")}
to="/uptime"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
action={
ranked.length >= MIN_RANKABLE ? (
<RankingCount
shown={Math.min(TOP_N, ranked.length)}
total={ranked.length}
/>
) : null
}
>
{ranked.length < MIN_RANKABLE ? (
<CardMessage text={t("pages.dashboard.cards.ranking.notEnough")} />
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{ranked.slice(0, TOP_N).map(({ monitor, average }) => (
<CardRow
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} />
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
flexShrink={0}
>
{Math.round(average)} ms
</Typography>
</Stack>
<CardBar
value={average}
max={slowest}
color={theme.palette.primary.main}
/>
</Stack>
</CardRow>
))}
</Stack>
)}
</DashboardCard>
);
};
export const LowestUptimeCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useMonitors();
const ranked = useMemo(() => {
return (
(data?.monitors ?? [])
// Paused monitors keep whatever uptime they had, or zero — a paused
// ping monitor reads 0% and would rank beside a genuinely down one.
// Neither is a live problem, so neither belongs in a worst-uptime list.
.filter((monitor) => monitor.status !== "paused")
.filter(
(monitor): monitor is Monitor & { uptimePercentage: number } =>
typeof monitor.uptimePercentage === "number"
)
.sort((a, b) => a.uptimePercentage - b.uptimePercentage)
);
}, [data]);
return (
<DashboardCard
title={t("pages.dashboard.cards.lowestUptime.title")}
to="/uptime"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
action={
ranked.length >= MIN_RANKABLE ? (
<RankingCount
shown={Math.min(TOP_N, ranked.length)}
total={ranked.length}
/>
) : null
}
>
{ranked.length < MIN_RANKABLE ? (
<CardMessage text={t("pages.dashboard.cards.ranking.notEnough")} />
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{ranked.slice(0, TOP_N).map((monitor) => {
// uptimePercentage is a 0-1 fraction, not 0-100.
const percentage = monitor.uptimePercentage * 100;
const palette = getUptimePercentageColor(monitor.uptimePercentage);
return (
<CardRow
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} />
<Typography
fontSize={typographyLevels.m}
color={theme.palette[palette].main}
flexShrink={0}
>
{percentage.toFixed(1)}%
</Typography>
</Stack>
<CardBar
value={percentage}
color={theme.palette[palette].main}
/>
</Stack>
</CardRow>
);
})}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,235 @@
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 { useGet } from "@/Hooks/UseApi";
import { Dot } from "@/Components/design-elements";
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 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.s}
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();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useGet<IncidentSummary>(
"/incidents/team/summary"
);
return (
<DashboardCard
title={t("pages.dashboard.cards.incidentStats.title")}
to="/incidents"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{data && (
<Stack gap={theme.spacing(LAYOUT.SM)}>
<CardFigure
value={String(data.totalActive)}
caption={t("pages.dashboard.cards.incidentStats.openNow")}
color={
data.totalActive > 0 ? theme.palette.error.main : theme.palette.text.primary
}
/>
<StatLine
label={t("pages.dashboard.cards.incidentStats.avgResolution")}
value={t("pages.dashboard.cards.incidentStats.hours", {
hours: data.avgResolutionTimeHours.toFixed(1),
})}
/>
{data.topMonitor?.monitorName && (
<StatLine
label={t("pages.dashboard.cards.incidentStats.mostIncidents")}
value={`${data.topMonitor.monitorName} (${data.topMonitor.incidentCount})`}
/>
)}
</Stack>
)}
</DashboardCard>
);
};
export const NotificationChannelsCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } =
useGet<Notification[]>("/notifications/team");
// The response also carries accessToken and accountSid. Only `type` is read
// here — credential fields must never reach the DOM.
const byType = useMemo(() => {
const counts = new Map<string, number>();
for (const notification of data ?? []) {
counts.set(notification.type, (counts.get(notification.type) ?? 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
}, [data]);
return (
<DashboardCard
title={t("pages.dashboard.cards.notificationChannels.title")}
to="/notifications"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{byType.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.notificationChannels.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.XS)}>
{byType.map(([type, count]) => (
<StatLine
key={type}
label={type}
value={String(count)}
/>
))}
</Stack>
)}
</DashboardCard>
);
};
export const MaintenanceCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useGet<{
maintenanceWindows: MaintenanceWindow[];
maintenanceWindowCount: number;
}>("/maintenance-window/team?page=0&rowsPerPage=0");
// `active` is a user on/off flag, not "running now" — compare the window
// client-side. repeat > 0 means recurring, so `end` may sit in the past for
// a window that is currently live.
const { running, next } = useMemo(() => {
const now = Date.now();
const windows = (data?.maintenanceWindows ?? []).filter((entry) => entry.active);
const live = windows.filter((entry) => {
const start = new Date(entry.start).getTime();
const end = new Date(entry.end).getTime();
return start <= now && (now <= end || entry.repeat > 0);
});
const upcoming = windows
.filter((entry) => new Date(entry.start).getTime() > now)
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
return { running: live.length, next: upcoming[0] ?? null };
}, [data]);
return (
<DashboardCard
title={t("pages.dashboard.cards.maintenance.title")}
to="/maintenance"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{!data || data.maintenanceWindows.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.maintenance.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
<CardFigure
value={String(running)}
caption={t("pages.dashboard.cards.maintenance.runningNow")}
/>
{next && (
<StatLine
label={t("pages.dashboard.cards.maintenance.next")}
value={formatTimestamp(next.start)}
/>
)}
</Stack>
)}
</DashboardCard>
);
};
export const StatusPagesCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } =
useGet<StatusPage[]>("/status-page/team");
return (
<DashboardCard
title={t("pages.dashboard.cards.statusPages.title")}
to="/status"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{!data || data.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.statusPages.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.XS)}>
{data.map((page) => (
<CardRow
key={page.id}
to={`/status/${page.url}`}
>
<Dot
color={
page.isPublished
? theme.palette.success.light
: theme.palette.text.secondary
}
size="md"
/>
<CardRowLabel
primary={page.companyName}
secondary={t("pages.dashboard.cards.statusPages.monitorCount", {
count: page.monitors.length,
})}
/>
<Typography
fontSize={typographyLevels.s}
color={theme.palette.text.secondary}
flexShrink={0}
>
{page.isPublished
? t("pages.dashboard.cards.statusPages.published")
: t("pages.dashboard.cards.statusPages.draft")}
</Typography>
</CardRow>
))}
</Stack>
)}
</DashboardCard>
);
};
+54 -3
View File
@@ -1,5 +1,14 @@
import { BasePage } from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { LayoutGrid } from "lucide-react";
import { BasePage, EmptyState } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { CardGrid } from "./Components/CardGrid";
import { CardPicker } from "./Components/CardPicker";
import { MonitorsContext, useMonitorsRequest } from "./useDashboardData";
import { useCardSelection } from "./useCardSelection";
@@ -8,13 +17,55 @@ import { useCardSelection } from "./useCardSelection";
* the question no single-type page can answer. Problems first and biggest.
*/
const Dashboard = () => {
const theme = useTheme();
const { t } = useTranslation();
const monitorsRequest = useMonitorsRequest();
const { cards } = useCardSelection();
const { cards, available, selected, addCard, removeCard, resetCards } =
useCardSelection();
const [isPickerOpen, setIsPickerOpen] = useState(false);
return (
<MonitorsContext.Provider value={monitorsRequest}>
<BasePage headerKey="dashboard">
<CardGrid cards={cards} />
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
variant="outlined"
color="secondary"
onClick={() => setIsPickerOpen(true)}
startIcon={
<LayoutGrid
size={14}
strokeWidth={1.6}
/>
}
>
{t("pages.dashboard.picker.trigger")}
</Button>
</Stack>
{cards.length === 0 ? (
<EmptyState
title={t("pages.dashboard.empty.title")}
description={t("pages.dashboard.empty.description")}
actionText={t("pages.dashboard.picker.trigger")}
onAction={() => setIsPickerOpen(true)}
/>
) : (
<CardGrid cards={cards} />
)}
</Stack>
<CardPicker
open={isPickerOpen}
available={available}
selected={selected}
onAdd={addCard}
onRemove={removeCard}
onReset={resetCards}
onClose={() => setIsPickerOpen(false)}
/>
</BasePage>
</MonitorsContext.Provider>
);
+11 -8
View File
@@ -8,8 +8,13 @@ export interface PlacedCard {
/**
* Packs cards into rows of GRID_COLUMNS. Declared width is a minimum; when a
* row ends short, its last card absorbs the remainder, capped at
* STRETCH_MAX_WIDTH so one card never balloons across a near-empty row.
* row ends short, its last card absorbs the remainder.
*
* 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.
*
* `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.
@@ -26,12 +31,10 @@ export const layoutCards = (cards: CardDefinition[]): PlacedCard[] => {
const remainder = GRID_COLUMNS - used;
if (remainder > 0) {
const last = row[row.length - 1];
// A full-width card already spans the row; only grow a short one, and
// never past the cap.
const grown = Math.min(last.renderedWidth + remainder, STRETCH_MAX_WIDTH);
if (grown > last.renderedWidth && !(isFinalRow && row.length === 1)) {
last.renderedWidth = grown;
}
// 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 = [];
+96
View File
@@ -3,12 +3,28 @@ import { CurrentlyDownCard } from "./Components/Cards/CurrentlyDownCard";
import { FailedChecksCard } from "./Components/Cards/FailedChecksCard";
import { RecentIncidentsCard } from "./Components/Cards/RecentIncidentsCard";
import { MonitorsByTypeCard } from "./Components/Cards/MonitorsByTypeCard";
import { MonitorsByGroupCard } from "./Components/Cards/MonitorsByGroupCard";
import { CheckmateServerCard } from "./Components/Cards/CheckmateServerCard";
import { BusiestServersCard } from "./Components/Cards/BusiestServersCard";
import { SlowestMonitorsCard, LowestUptimeCard } from "./Components/Cards/RankingCards";
import {
IncidentStatsCard,
NotificationChannelsCard,
MaintenanceCard,
StatusPagesCard,
} from "./Components/Cards/SummaryCards";
import {
ChecksOnScheduleCard,
RecentErrorsCard,
TeamCard,
} from "./Components/Cards/AdminCards";
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 order the picker lists them in, grouped by sidebar section.
export const CARD_REGISTRY: CardDefinition[] = [
{
id: "monitorStatus",
@@ -24,6 +40,20 @@ export const CARD_REGISTRY: CardDefinition[] = [
width: 12,
component: CurrentlyDownCard,
},
{
id: "slowestMonitors",
key: "slowestMonitors",
group: "uptime",
width: 6,
component: SlowestMonitorsCard,
},
{
id: "lowestUptime",
key: "lowestUptime",
group: "uptime",
width: 6,
component: LowestUptimeCard,
},
{
id: "monitorsByType",
key: "monitorsByType",
@@ -31,6 +61,20 @@ export const CARD_REGISTRY: CardDefinition[] = [
width: 6,
component: MonitorsByTypeCard,
},
{
id: "monitorsByGroup",
key: "monitorsByGroup",
group: "uptime",
width: 6,
component: MonitorsByGroupCard,
},
{
id: "busiestServers",
key: "busiestServers",
group: "infrastructure",
width: 6,
component: BusiestServersCard,
},
{
id: "recentIncidents",
key: "recentIncidents",
@@ -38,6 +82,34 @@ export const CARD_REGISTRY: CardDefinition[] = [
width: 8,
component: RecentIncidentsCard,
},
{
id: "incidentStats",
key: "incidentStats",
group: "incidents",
width: 4,
component: IncidentStatsCard,
},
{
id: "notificationChannels",
key: "notificationChannels",
group: "notifications",
width: 4,
component: NotificationChannelsCard,
},
{
id: "maintenance",
key: "maintenance",
group: "maintenance",
width: 4,
component: MaintenanceCard,
},
{
id: "statusPages",
key: "statusPages",
group: "statusPages",
width: 4,
component: StatusPagesCard,
},
{
id: "failedChecks",
key: "failedChecks",
@@ -53,6 +125,30 @@ export const CARD_REGISTRY: CardDefinition[] = [
adminOnly: true,
component: CheckmateServerCard,
},
{
id: "checksOnSchedule",
key: "checksOnSchedule",
group: "logs",
width: 4,
adminOnly: true,
component: ChecksOnScheduleCard,
},
{
id: "recentErrors",
key: "recentErrors",
group: "logs",
width: 6,
adminOnly: true,
component: RecentErrorsCard,
},
{
id: "team",
key: "team",
group: "settings",
width: 4,
adminOnly: true,
component: TeamCard,
},
];
const BY_ID = new Map<CardId, CardDefinition>(
+73
View File
@@ -289,7 +289,80 @@
"uptime": "Uptime",
"database": "Database",
"readsWrites": "{{reads}} reads/s · {{writes}} writes/s"
},
"ranking": {
"topOf": "Top {{shown}} of {{total}}",
"notEnough": "Not enough monitors to rank yet."
},
"slowestMonitors": {
"title": "Slowest monitors"
},
"lowestUptime": {
"title": "Lowest uptime"
},
"monitorsByGroup": {
"title": "Monitors by group",
"empty": "No groups yet. Groups are set per monitor."
},
"busiestServers": {
"title": "Busiest servers",
"empty": "No infrastructure monitors yet.",
"cpu": "CPU",
"memory": "Memory",
"disk": "Disk"
},
"incidentStats": {
"title": "Incident stats",
"openNow": "Open now",
"avgResolution": "Average time to resolve",
"hours": "{{hours}} h",
"mostIncidents": "Most incidents"
},
"notificationChannels": {
"title": "Notification channels",
"empty": "No channels yet."
},
"maintenance": {
"title": "Maintenance",
"empty": "No maintenance scheduled.",
"runningNow": "Running now",
"next": "Next window"
},
"statusPages": {
"title": "Status pages",
"empty": "No status pages yet.",
"monitorCount": "{{count}} monitors",
"published": "Published",
"draft": "Draft"
},
"checksOnSchedule": {
"title": "Checks on schedule",
"healthy": "All {{jobs}} jobs on schedule across {{workers}} worker(s).",
"failing": "Failing jobs",
"scheduled": "Scheduled jobs",
"runningNow": "Running now"
},
"recentErrors": {
"title": "Recent errors",
"empty": "No errors logged.",
"count": "{{count}} errors",
"warnings": "Warnings"
},
"team": {
"title": "Team",
"members": "{{count}} members",
"unverified": "Unverified"
}
},
"picker": {
"trigger": "Edit cards",
"title": "Dashboard cards",
"done": "Done",
"reset": "Reset to defaults"
},
"empty": {
"title": "No cards on your dashboard",
"description": "Choose the cards you want to see. Your selection is remembered in this browser."
}
},
"proxies": {