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": {