diff --git a/client/src/Pages/Dashboard/Components/CardGrid.tsx b/client/src/Pages/Dashboard/Components/CardGrid.tsx
index 569700834..000b94a80 100644
--- a/client/src/Pages/Dashboard/Components/CardGrid.tsx
+++ b/client/src/Pages/Dashboard/Components/CardGrid.tsx
@@ -3,10 +3,18 @@ import { useTheme } from "@mui/material/styles";
import { useMemo } from "react";
import { LAYOUT } from "@/Utils/Theme/constants";
-import { GRID_COLUMNS, type CardDefinition } from "../cards";
+import { GRID_COLUMNS, type CardDefinition, type CardId } from "../cards";
import { layoutCards } from "../layout";
+import { CardReorderControls } from "./CardReorderControls";
+import { CardSlotContext } from "./CardSlotContext";
-export const CardGrid = ({ cards }: { cards: CardDefinition[] }) => {
+export const CardGrid = ({
+ cards,
+ onMove,
+}: {
+ cards: CardDefinition[];
+ onMove: (id: CardId, delta: -1 | 1) => void;
+}) => {
const theme = useTheme();
const placed = useMemo(() => layoutCards(cards), [cards]);
@@ -22,7 +30,7 @@ export const CardGrid = ({ cards }: { cards: CardDefinition[] }) => {
alignItems: "stretch",
}}
>
- {placed.map(({ card, renderedWidth }) => {
+ {placed.map(({ card, renderedWidth }, index) => {
const Component = card.component;
return (
{
minWidth={0}
gridColumn={{ xs: "auto", md: `span ${renderedWidth}` }}
>
-
+ {/*
+ * The controls go through context rather than a prop so the 17
+ * card components stay unaware of their own position.
+ */}
+ 0}
+ canMoveDown={index < placed.length - 1}
+ onMoveUp={() => onMove(card.id, -1)}
+ onMoveDown={() => onMove(card.id, 1)}
+ />
+ }
+ >
+
+
);
})}
diff --git a/client/src/Pages/Dashboard/Components/CardPicker.tsx b/client/src/Pages/Dashboard/Components/CardPicker.tsx
index f99e346a1..ac5508135 100644
--- a/client/src/Pages/Dashboard/Components/CardPicker.tsx
+++ b/client/src/Pages/Dashboard/Components/CardPicker.tsx
@@ -5,7 +5,7 @@ import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
-import { Dialog, Checkbox } from "@/Components/inputs";
+import { Dialog, Checkbox, Button } 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,10 +52,21 @@ 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}
- onConfirm={onReset}
cancelText={t("pages.dashboard.picker.done")}
- confirmText={t("pages.dashboard.picker.reset")}
+ additionalButtons={
+
+ }
>
void;
+ onMoveDown: () => void;
+ canMoveUp: boolean;
+ canMoveDown: boolean;
+}) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+
+ const buttonSx = {
+ width: CONTROL_SIZE,
+ height: CONTROL_SIZE,
+ borderRadius: theme.shape.borderRadius,
+ color: theme.palette.text.secondary,
+ "&:hover": {
+ backgroundColor: theme.palette.action.hover,
+ color: theme.palette.text.primary,
+ },
+ "&.Mui-disabled": { color: theme.palette.text.disabled },
+ };
+
+ return (
+
+
+ {/* span keeps the tooltip working while the button is disabled */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/Pages/Dashboard/Components/CardSlotContext.tsx b/client/src/Pages/Dashboard/Components/CardSlotContext.tsx
new file mode 100644
index 000000000..ef164295c
--- /dev/null
+++ b/client/src/Pages/Dashboard/Components/CardSlotContext.tsx
@@ -0,0 +1,12 @@
+import { createContext, useContext } from "react";
+
+import type { ReactNode } from "react";
+
+/**
+ * Lets the grid inject per-card header controls without every card component
+ * having to accept and forward a prop it does not care about. The grid provides
+ * the node; DashboardCard renders it in the header.
+ */
+export const CardSlotContext = createContext(null);
+
+export const useCardSlot = (): ReactNode => useContext(CardSlotContext);
diff --git a/client/src/Pages/Dashboard/Components/Cards/AdminCards.tsx b/client/src/Pages/Dashboard/Components/Cards/AdminCards.tsx
index 949d4cf4e..632a0b612 100644
--- a/client/src/Pages/Dashboard/Components/Cards/AdminCards.tsx
+++ b/client/src/Pages/Dashboard/Components/Cards/AdminCards.tsx
@@ -11,6 +11,8 @@ import { formatTimestamp } from "@/Utils/TimeUtils";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { CardFigure, CardRow, CardRowLabel } from "../CardPrimitives";
+import { REFRESH_INTERVAL_MS, SLOW_REFRESH_INTERVAL_MS } from "../../cards";
+
import type { QueueMetrics } from "@/Types/Queue";
import type { Log } from "@/Types/Log";
import type { User } from "@/Types/User";
@@ -57,7 +59,7 @@ export const ChecksOnScheduleCard = () => {
const { data, isLoading, isValidating, error } = useGet(
"/queue/metrics",
{},
- { refreshInterval: 30000 }
+ { refreshInterval: REFRESH_INTERVAL_MS }
);
const healthy = data?.failingJobs === 0;
@@ -130,7 +132,7 @@ export const RecentErrorsCard = () => {
const { data, isLoading, isValidating, error } = useGet(
"/logs",
{},
- { refreshInterval: 60000 }
+ { refreshInterval: SLOW_REFRESH_INTERVAL_MS }
);
const { errors, warnings, newest } = useMemo(() => {
diff --git a/client/src/Pages/Dashboard/Components/Cards/CheckmateServerCard.tsx b/client/src/Pages/Dashboard/Components/Cards/CheckmateServerCard.tsx
index d4fd4bc58..b7716f7c9 100644
--- a/client/src/Pages/Dashboard/Components/Cards/CheckmateServerCard.tsx
+++ b/client/src/Pages/Dashboard/Components/Cards/CheckmateServerCard.tsx
@@ -10,6 +10,8 @@ import { formatDuration } from "@/Utils/TimeUtils";
import { DashboardCard } from "../DashboardCard";
import { CardBar } from "../CardPrimitives";
+import { REFRESH_INTERVAL_MS } from "../../cards";
+
import type { Diagnostics } from "@/Types/Diagnostics";
const BYTES_PER_MB = 1024 * 1024;
@@ -63,7 +65,7 @@ export const CheckmateServerCard = () => {
const { data, isLoading, isValidating, error } = useGet(
"/diagnostic/system",
{},
- { refreshInterval: 30000 }
+ { refreshInterval: REFRESH_INTERVAL_MS }
);
const usedHeap = data?.v8HeapStats.usedHeapSizeBytes ?? 0;
diff --git a/client/src/Pages/Dashboard/Components/Cards/CurrentlyDownCard.tsx b/client/src/Pages/Dashboard/Components/Cards/CurrentlyDownCard.tsx
index 1974a056d..21dca5153 100644
--- a/client/src/Pages/Dashboard/Components/Cards/CurrentlyDownCard.tsx
+++ b/client/src/Pages/Dashboard/Components/Cards/CurrentlyDownCard.tsx
@@ -24,6 +24,12 @@ 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> = {
down: 1,
breached: 2,
@@ -125,14 +131,14 @@ export const CurrentlyDownCard = () => {
fontSize={typographyLevels.s}
color={theme.palette.error.main}
flexShrink={0}
- minWidth={64}
+ minWidth={DURATION_COLUMN_WIDTH}
textAlign="right"
>
{formatDuration(Date.now() - new Date(since).getTime())}
)}
@@ -151,7 +157,7 @@ export const CurrentlyDownCard = () => {
fontSize={typographyLevels.s}
color={theme.palette.text.secondary}
flexShrink={0}
- minWidth={32}
+ minWidth={STATUS_CODE_COLUMN_WIDTH}
textAlign="right"
>
{formatStatusCode(latestCheck(monitor)?.statusCode, t)}
diff --git a/client/src/Pages/Dashboard/Components/Cards/MonitorStatusCard.tsx b/client/src/Pages/Dashboard/Components/Cards/MonitorStatusCard.tsx
index 8c02f9df0..b345224cd 100644
--- a/client/src/Pages/Dashboard/Components/Cards/MonitorStatusCard.tsx
+++ b/client/src/Pages/Dashboard/Components/Cards/MonitorStatusCard.tsx
@@ -13,6 +13,9 @@ import { useMonitors } from "../../useDashboardData";
import type { MonitorStatus, MonitorsSummary } from "@/Types/Monitor";
+// Wide enough for a three-digit count beside its label before the tiles wrap.
+const TILE_MIN_WIDTH = 96;
+
// Every state the summary reports, in the order they read: healthy first, then
// what is wrong, then what is not being checked. All six are listed so the
// tiles always sum to totalMonitors — omitting one leaves an unexplained gap.
@@ -57,7 +60,7 @@ export const MonitorStatusCard = () => {
to="/uptime"
gap={theme.spacing(LAYOUT.XXS)}
flex="1 1 0"
- minWidth={96}
+ minWidth={TILE_MIN_WIDTH}
px={theme.spacing(LAYOUT.SM)}
py={theme.spacing(LAYOUT.XS)}
borderRadius={theme.shape.borderRadius}
diff --git a/client/src/Pages/Dashboard/Components/DashboardCard.tsx b/client/src/Pages/Dashboard/Components/DashboardCard.tsx
index c53323e85..6b9af8455 100644
--- a/client/src/Pages/Dashboard/Components/DashboardCard.tsx
+++ b/client/src/Pages/Dashboard/Components/DashboardCard.tsx
@@ -10,9 +10,15 @@ import { useTranslation } from "react-i18next";
import { BaseBox } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
+import { useCardSlot } from "./CardSlotContext";
import type { ReactNode } from "react";
+// Matches the 34px control height plus the header's vertical padding, so a card
+// with no action still lines up with one that has a button in its header.
+const HEADER_MIN_HEIGHT = 44;
+const SKELETON_ROW_HEIGHT = 20;
+
interface DashboardCardProps {
title: string;
/** Where the title links to — the page this card belongs to. */
@@ -37,6 +43,7 @@ export const DashboardCard = ({
}: DashboardCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
+ const slot = useCardSlot();
return (
{isStale && (
)}
{action}
+ {slot && (
+
+ {slot}
+
+ )}
{
))}
diff --git a/client/src/Pages/Dashboard/Components/Sparkline.tsx b/client/src/Pages/Dashboard/Components/Sparkline.tsx
index 8fecb791d..348eba589 100644
--- a/client/src/Pages/Dashboard/Components/Sparkline.tsx
+++ b/client/src/Pages/Dashboard/Components/Sparkline.tsx
@@ -4,7 +4,10 @@ import { useMemo } from "react";
import type { CheckSnapshot } from "@/Types/Check";
const VIEWBOX_WIDTH = 100;
+// The svg renders at its viewBox height, so one constant serves both.
const VIEWBOX_HEIGHT = 24;
+// Matches the 1.5 stroke the shared Icon component uses.
+const STROKE_WIDTH = 1.5;
/**
* Response times as a bare polyline. Deliberately axis-free and label-free —
@@ -52,14 +55,14 @@ export const Sparkline = ({
preserveAspectRatio="none"
aria-hidden="true"
width="100%"
- height={24}
+ height={VIEWBOX_HEIGHT}
display="block"
>
{
const theme = useTheme();
const { t } = useTranslation();
const monitorsRequest = useMonitorsRequest();
- const { cards, available, selected, addCard, removeCard, resetCards } =
+ const { cards, available, selected, addCard, removeCard, moveCard, resetCards } =
useCardSelection();
const [isPickerOpen, setIsPickerOpen] = useState(false);
@@ -37,9 +37,9 @@ const Dashboard = () => {
color="secondary"
onClick={() => setIsPickerOpen(true)}
startIcon={
-
}
>
@@ -54,7 +54,10 @@ const Dashboard = () => {
onAction={() => setIsPickerOpen(true)}
/>
) : (
-
+
)}
{
const resetCards = useCallback(() => setSelected(DEFAULT_CARD_IDS), []);
- return { cards, available, selected, addCard, removeCard, resetCards };
+ /**
+ * Swaps a card with its neighbour. `delta` is -1 for up, +1 for down.
+ *
+ * Positions are resolved against the *visible* order, not the stored array:
+ * a non-admin never sees the admin cards, so moving a card past one of them
+ * would otherwise appear to do nothing.
+ */
+ const moveCard = useCallback(
+ (id: CardId, delta: -1 | 1) => {
+ setSelected((current) => {
+ const visible = current.filter((cardId) => {
+ const card = getCard(cardId);
+ return card !== undefined && isVisible(card);
+ });
+ const from = visible.indexOf(id);
+ const to = from + delta;
+ if (from === -1 || to < 0 || to >= visible.length) {
+ return current;
+ }
+ // Swap in the visible order, then write that order back over the
+ // stored positions the visible cards occupy, leaving hidden cards
+ // exactly where they were.
+ const reordered = [...visible];
+ [reordered[from], reordered[to]] = [reordered[to], reordered[from]];
+
+ let next = 0;
+ return current.map((cardId) =>
+ visible.includes(cardId) ? reordered[next++] : cardId
+ );
+ });
+ },
+ [isVisible]
+ );
+
+ return {
+ cards,
+ available,
+ selected,
+ addCard,
+ removeCard,
+ moveCard,
+ resetCards,
+ };
};
diff --git a/client/src/Pages/Dashboard/useDashboardData.ts b/client/src/Pages/Dashboard/useDashboardData.ts
index b6136b3d6..959c5684b 100644
--- a/client/src/Pages/Dashboard/useDashboardData.ts
+++ b/client/src/Pages/Dashboard/useDashboardData.ts
@@ -1,8 +1,7 @@
import { createContext, useContext } from "react";
import { useGet } from "@/Hooks/UseApi";
import { MonitorTypes, type MonitorsWithChecksResponse } from "@/Types/Monitor";
-
-const REFRESH_INTERVAL = 30000;
+import { REFRESH_INTERVAL_MS } from "./cards";
// One request feeds every monitor-derived card. Cards read it from context so
// adding a monitor card costs no extra call.
@@ -22,7 +21,7 @@ export const useMonitorsRequest = () =>
useGet(
buildMonitorsUrl(),
{},
- { refreshInterval: REFRESH_INTERVAL, keepPreviousData: true }
+ { refreshInterval: REFRESH_INTERVAL_MS, keepPreviousData: true }
);
export type MonitorsRequest = ReturnType;
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index 10fd07e8f..9ea65b301 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -363,6 +363,10 @@
"empty": {
"title": "No cards on your dashboard",
"description": "Choose the cards you want to see. Your selection is remembered in this browser."
+ },
+ "reorder": {
+ "moveUp": "Move up",
+ "moveDown": "Move down"
}
},
"proxies": {