feat(client): reorder cards on hover, and tidy dashboard styling
Adds move-up / move-down controls to each card, revealed on hover or when something inside the card takes focus, so a settled dashboard stays quiet but the controls are still reachable by keyboard. The controls reach the card header through context rather than a prop, so none of the 17 card components has to know its own position — the grid owns ordering. Positions resolve 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. Hidden cards keep their stored positions. Styling fixes: - 'Reset to defaults' no longer renders as the prominent green contained button. It is a destructive secondary action, so it moves to the quiet outlined slot beside the close action. The dialog now has no confirm button at all, since every change applies immediately and there is nothing to confirm. - Icons go through the shared Icon component at the codebase's usual 16px instead of ad-hoc 14px with a one-off stroke width, and the header spinner matches at 16px. - Named the remaining magic numbers — header height, bar thickness, sparkline and column widths, status tile width, control sizes — so they read as decisions rather than arbitrary pixels. There is no size-token scale in the theme (LAYOUT is spacing only) and peer components hardcode their own dimensions, so module constants match the house pattern rather than inventing a scale. - Centralised the two poll rates instead of repeating 30000 and 60000 across cards, and hoisted a stray sx flexShrink onto the component. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
This commit is contained in:
1 parent
f86ead0806
commit
49af07009d
16 files changed
+266
-31
No files matched your search
@@ -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 (
|
||||
<Box
|
||||
@@ -30,7 +38,22 @@ export const CardGrid = ({ cards }: { cards: CardDefinition[] }) => {
|
||||
minWidth={0}
|
||||
gridColumn={{ xs: "auto", md: `span ${renderedWidth}` }}
|
||||
>
|
||||
<Component />
|
||||
{/*
|
||||
* The controls go through context rather than a prop so the 17
|
||||
* card components stay unaware of their own position.
|
||||
*/}
|
||||
<CardSlotContext.Provider
|
||||
value={
|
||||
<CardReorderControls
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < placed.length - 1}
|
||||
onMoveUp={() => onMove(card.id, -1)}
|
||||
onMoveDown={() => onMove(card.id, 1)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Component />
|
||||
</CardSlotContext.Provider>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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={
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onReset}
|
||||
>
|
||||
{t("pages.dashboard.picker.reset")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
|
||||
@@ -9,6 +9,9 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Thin enough to read as a proportion indicator, not a progress bar. */
|
||||
const BAR_HEIGHT = 4;
|
||||
|
||||
/**
|
||||
* A proportional bar. `value` and `max` share whatever unit the caller uses;
|
||||
* percentages come in as 0-100, not as the API's 0-1 fractions.
|
||||
@@ -28,7 +31,7 @@ export const CardBar = ({
|
||||
return (
|
||||
<Box
|
||||
width="100%"
|
||||
height={4}
|
||||
height={BAR_HEIGHT}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
bgcolor={theme.palette.action.hover}
|
||||
sx={{ overflow: "hidden" }}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronUp, ChevronDown } from "lucide-react";
|
||||
|
||||
import { Icon, Tooltip } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
// Snug around the 16px icon without inflating the card header.
|
||||
const CONTROL_SIZE = 24;
|
||||
const ICON_SIZE = 16;
|
||||
|
||||
/**
|
||||
* Move-up / move-down controls, revealed on hover over the card they belong to.
|
||||
* Kept out of DashboardCard so the 17 card components stay unaware of their own
|
||||
* position — the grid owns ordering.
|
||||
*/
|
||||
export const CardReorderControls = ({
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
}: {
|
||||
onMoveUp: () => 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 (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
p={theme.spacing(LAYOUT.XXS)}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
bgcolor={theme.palette.background.paper}
|
||||
border={`1px solid ${theme.palette.divider}`}
|
||||
>
|
||||
<Tooltip title={t("pages.dashboard.reorder.moveUp")}>
|
||||
{/* span keeps the tooltip working while the button is disabled */}
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={!canMoveUp}
|
||||
onClick={onMoveUp}
|
||||
aria-label={t("pages.dashboard.reorder.moveUp")}
|
||||
sx={buttonSx}
|
||||
>
|
||||
<Icon
|
||||
icon={ChevronUp}
|
||||
size={ICON_SIZE}
|
||||
/>
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("pages.dashboard.reorder.moveDown")}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={!canMoveDown}
|
||||
onClick={onMoveDown}
|
||||
aria-label={t("pages.dashboard.reorder.moveDown")}
|
||||
sx={buttonSx}
|
||||
>
|
||||
<Icon
|
||||
icon={ChevronDown}
|
||||
size={ICON_SIZE}
|
||||
/>
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -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<ReactNode>(null);
|
||||
|
||||
export const useCardSlot = (): ReactNode => useContext(CardSlotContext);
|
||||
@@ -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<QueueMetrics>(
|
||||
"/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<Log[]>(
|
||||
"/logs",
|
||||
{},
|
||||
{ refreshInterval: 60000 }
|
||||
{ refreshInterval: SLOW_REFRESH_INTERVAL_MS }
|
||||
);
|
||||
|
||||
const { errors, warnings, newest } = useMemo(() => {
|
||||
|
||||
@@ -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<Diagnostics>(
|
||||
"/diagnostic/system",
|
||||
{},
|
||||
{ refreshInterval: 30000 }
|
||||
{ refreshInterval: REFRESH_INTERVAL_MS }
|
||||
);
|
||||
|
||||
const usedHeap = data?.v8HeapStats.usedHeapSizeBytes ?? 0;
|
||||
|
||||
@@ -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<Record<MonitorStatus, number>> = {
|
||||
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())}
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
width={80}
|
||||
width={SPARKLINE_WIDTH}
|
||||
flexShrink={0}
|
||||
display={{ xs: "none", md: "block" }}
|
||||
>
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
<BaseBox
|
||||
@@ -45,6 +52,12 @@ export const DashboardCard = ({
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
// The header controls stay invisible until the card is hovered or
|
||||
// something inside it takes focus, so a settled dashboard is quiet.
|
||||
"&:hover .dashboard-card-slot, &:focus-within .dashboard-card-slot": {
|
||||
opacity: 1,
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
@@ -55,7 +68,7 @@ export const DashboardCard = ({
|
||||
px={theme.spacing(LAYOUT.MD)}
|
||||
py={theme.spacing(LAYOUT.SM)}
|
||||
borderBottom={`1px solid ${theme.palette.divider}`}
|
||||
minHeight={44}
|
||||
minHeight={HEADER_MIN_HEIGHT}
|
||||
>
|
||||
<Typography
|
||||
{...(to ? { component: RouterLink, to } : {})}
|
||||
@@ -75,15 +88,27 @@ export const DashboardCard = ({
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
sx={{ flexShrink: 0 }}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isStale && (
|
||||
<CircularProgress
|
||||
size={12}
|
||||
size={16}
|
||||
color="primary"
|
||||
/>
|
||||
)}
|
||||
{action}
|
||||
{slot && (
|
||||
<Box
|
||||
className="dashboard-card-slot"
|
||||
sx={{
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
transition: "opacity 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{slot}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box
|
||||
@@ -114,7 +139,7 @@ export const CardSkeleton = ({ rows = 3 }: { rows?: number }) => {
|
||||
<Skeleton
|
||||
key={index}
|
||||
variant="rounded"
|
||||
height={20}
|
||||
height={SKELETON_ROW_HEIGHT}
|
||||
width={index === rows - 1 ? "60%" : "100%"}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
|
||||
@@ -71,3 +71,10 @@ export const DEFAULT_CARD_IDS: CardId[] = [
|
||||
];
|
||||
|
||||
export const DASHBOARD_CARDS_STORAGE_KEY = "checkmate-dashboard-cards";
|
||||
|
||||
/**
|
||||
* Poll rates, in ms. Monitor and server figures move continuously; the log ring
|
||||
* buffer rotates fast but is expensive to re-read, so it is polled slowly.
|
||||
*/
|
||||
export const REFRESH_INTERVAL_MS = 30000;
|
||||
export const SLOW_REFRESH_INTERVAL_MS = 60000;
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
|
||||
import { BasePage, EmptyState } from "@/Components/design-elements";
|
||||
import { BasePage, EmptyState, Icon } from "@/Components/design-elements";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { CardGrid } from "./Components/CardGrid";
|
||||
@@ -20,7 +20,7 @@ const Dashboard = () => {
|
||||
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={
|
||||
<LayoutGrid
|
||||
size={14}
|
||||
strokeWidth={1.6}
|
||||
<Icon
|
||||
icon={LayoutGrid}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -54,7 +54,10 @@ const Dashboard = () => {
|
||||
onAction={() => setIsPickerOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<CardGrid cards={cards} />
|
||||
<CardGrid
|
||||
cards={cards}
|
||||
onMove={moveCard}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<CardPicker
|
||||
|
||||
@@ -88,5 +88,47 @@ export const useCardSelection = () => {
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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<MonitorsWithChecksResponse>(
|
||||
buildMonitorsUrl(),
|
||||
{},
|
||||
{ refreshInterval: REFRESH_INTERVAL, keepPreviousData: true }
|
||||
{ refreshInterval: REFRESH_INTERVAL_MS, keepPreviousData: true }
|
||||
);
|
||||
|
||||
export type MonitorsRequest = ReturnType<typeof useMonitorsRequest>;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user