From 944ee74a1d70fcc108d86c382a2defb8600b16c8 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 23 Apr 2026 22:49:50 -0400 Subject: [PATCH] feat(status-page): four selectable themes for the public status page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four fully-designed visual themes for the public status page (refined | modern | bold | editorial) with a light/dark mode selector, admin preview in a mock browser frame, and a shared theme provider that keeps the component tree unified while letting each theme own its visual identity. ## Selectable themes - Refined — clean, flat cards, soft shadows, colored status dot. - Modern — flat surfaces with a pulsing status dot and staggered fade-in cards. - Bold — conic-gradient logo, 34px hero title with a check disc, subtle dual-radial glow on dark mode, accent-green hover border. - Editorial — serif headings, narrow newspaper column, 'Service Report' eyebrow + title, flat rectangular badges and dateline. ## Architecture - Single `StatusPageThemeProvider` hosts CSS custom-property tokens per theme and resolves light/dark based on admin-forced mode, falling back to `prefers-color-scheme` with a matchMedia listener. - Each theme owns only its `*StatusPage.tsx` + scoped CSS file. The heatmap, histogram, and infrastructure renderers are shared across all four themes (parameterized by a `classPrefix`) to avoid the 12-file duplication the initial draft had. - Renderer lookup table in `Status/index.tsx` replaces a 4-way ternary chain; unknown themes fall through to the legacy renderer. - `resolveOverallStatus`, `statusBadgeKey`, `monoFirstChar`, and `monitorBadgeTone` are extracted to `themes/shared/overallStatus.tsx`. ## Admin UX - `ThemePicker` uses the project's `RadioWithDescription` and `Select` components — one radio card per theme plus an Auto/ Light-only/Dark-only mode dropdown. - `BrowserFrame` wraps the preview in the admin panel with a mock chrome (traffic lights + URL bar) that adopts the status page's theme tokens. Edge-to-edge fill with no gray bleed. - Public route renders the theme directly on the viewport with the document body painted to the theme background, reverted on unmount. ## Performance & polish - Histogram's derived stats (padded array, max, avg, peak) are memoized on `checks`; `max` is reused for `peak` instead of computing twice. - Editorial's dateline is wrapped in `useMemo` so the 10s poll doesn't rerender "April 23, 2026" as a changing string. - Provider's `setSystemMode` reconciliation uses the updater form to avoid an extra render when the value is unchanged. - `monoFirstChar` no longer uses a non-null assertion. ## Form plumbing - `useStatusPageForm` defaults include `theme` + `themeMode`. - `Validation/statusPage.ts` accepts both as optional zod enums. - `Pages/StatusPage/Create/index.tsx` appends both to FormData on create and update. ## CLAUDE.md - Dev port note: local client runs on 10001 (5173 is used by a different local project); server `CLIENT_HOST` is set to match. --- CLAUDE.md | 4 +- client/src/Hooks/useStatusPageForm.ts | 6 + .../Create/Components/ThemePicker.tsx | 66 +++ client/src/Pages/StatusPage/Create/index.tsx | 27 + .../Components/HeaderStatusPageControls.tsx | 1 + .../Status/Components/MonitorsList.tsx | 3 + .../Status/Components/StatusBar.tsx | 197 ++++--- client/src/Pages/StatusPage/Status/index.tsx | 139 +++-- .../StatusPage/Status/themes/BrowserFrame.tsx | 76 +++ .../Status/themes/StatusPageThemeProvider.tsx | 148 +++++ .../Status/themes/bold/BoldStatusPage.tsx | 180 ++++++ .../StatusPage/Status/themes/bold/bold.css | 497 +++++++++++++++++ .../themes/editorial/EditorialStatusPage.tsx | 189 +++++++ .../Status/themes/editorial/editorial.css | 353 ++++++++++++ .../Status/themes/modern/ModernStatusPage.tsx | 179 ++++++ .../Status/themes/modern/modern.css | 519 ++++++++++++++++++ .../themes/refined/RefinedStatusPage.tsx | 179 ++++++ .../Status/themes/refined/refined.css | 420 ++++++++++++++ .../Status/themes/shared/ThemedHeatmap.tsx | 83 +++ .../Status/themes/shared/ThemedHistogram.tsx | 99 ++++ .../themes/shared/ThemedInfrastructure.tsx | 102 ++++ .../Status/themes/shared/overallStatus.tsx | 160 ++++++ .../Status/themes/shared/timeFormat.ts | 12 + .../Status/themes/theme-overrides.css | 156 ++++++ .../Pages/StatusPage/Status/themes/tokens.ts | 218 ++++++++ client/src/Types/StatusPage.ts | 10 + client/src/Validation/statusPage.ts | 3 + client/src/locales/en.json | 55 ++ 28 files changed, 3967 insertions(+), 114 deletions(-) create mode 100644 client/src/Pages/StatusPage/Create/Components/ThemePicker.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/bold/bold.css create mode 100644 client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/editorial/editorial.css create mode 100644 client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/modern/modern.css create mode 100644 client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/refined/refined.css create mode 100644 client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/shared/overallStatus.tsx create mode 100644 client/src/Pages/StatusPage/Status/themes/shared/timeFormat.ts create mode 100644 client/src/Pages/StatusPage/Status/themes/theme-overrides.css create mode 100644 client/src/Pages/StatusPage/Status/themes/tokens.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9af1a6742..be7a347c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,13 +12,15 @@ Checkmate is an open-source uptime and infrastructure monitoring application. It ```bash cd client npm install -npm run dev # Start dev server at http://localhost:5173 +npm run dev -- --port 10001 --strictPort # Local dev port is 10001 (5173 is used by another project on this machine) npm run build # TypeScript check + production build npm run lint # ESLint (strict, max-warnings 0) npm run format # Prettier formatting npm run format-check # Check formatting ``` +Server `.env` on this machine is configured with `CLIENT_HOST="http://localhost:10001"` to match the client dev port. If you change the client port, keep `.env` in sync. + ### Server (Node.js/Express) ```bash cd server diff --git a/client/src/Hooks/useStatusPageForm.ts b/client/src/Hooks/useStatusPageForm.ts index 0011ea761..b4ae7fe33 100644 --- a/client/src/Hooks/useStatusPageForm.ts +++ b/client/src/Hooks/useStatusPageForm.ts @@ -1,6 +1,10 @@ import { useMemo } from "react"; import { statusPageSchema, type StatusPageFormData } from "@/Validation/statusPage"; import type { StatusPage } from "@/Types/StatusPage"; +import { + DEFAULT_STATUS_PAGE_THEME, + DEFAULT_STATUS_PAGE_THEME_MODE, +} from "@/Types/StatusPage"; import type { Monitor } from "@/Types/Monitor"; interface UseStatusPageFormOptions { @@ -36,6 +40,8 @@ export const useStatusPageForm = ({ showAdminLoginLink: data?.showAdminLoginLink ?? false, showInfrastructure: data?.showInfrastructure ?? false, customCSS: data?.customCSS || "", + theme: data?.theme ?? DEFAULT_STATUS_PAGE_THEME, + themeMode: data?.themeMode ?? DEFAULT_STATUS_PAGE_THEME_MODE, logo: transformLogo(data?.logo), }; diff --git a/client/src/Pages/StatusPage/Create/Components/ThemePicker.tsx b/client/src/Pages/StatusPage/Create/Components/ThemePicker.tsx new file mode 100644 index 000000000..d68e1d0c1 --- /dev/null +++ b/client/src/Pages/StatusPage/Create/Components/ThemePicker.tsx @@ -0,0 +1,66 @@ +import { useTranslation } from "react-i18next"; +import Stack from "@mui/material/Stack"; +import RadioGroup from "@mui/material/RadioGroup"; +import MenuItem from "@mui/material/MenuItem"; +import { useTheme } from "@mui/material/styles"; +import { RadioWithDescription, Select } from "@/Components/inputs"; +import { + STATUS_PAGE_THEMES, + STATUS_PAGE_THEME_MODES, + type StatusPageTheme, + type StatusPageThemeMode, +} from "@/Types/StatusPage"; + +interface Props { + theme: StatusPageTheme; + themeMode: StatusPageThemeMode; + onThemeChange: (value: StatusPageTheme) => void; + onThemeModeChange: (value: StatusPageThemeMode) => void; +} + +export const ThemePicker = ({ + theme, + themeMode, + onThemeChange, + onThemeModeChange, +}: Props) => { + const { t } = useTranslation(); + const muiTheme = useTheme(); + + return ( + + onThemeChange(value as StatusPageTheme)} + > + + {STATUS_PAGE_THEMES.map((option) => ( + + ))} + + + + + + ); +}; diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index f09d9e47d..d972a90c7 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -34,6 +34,7 @@ import timezones from "@/Utils/timezones.json"; import { useNavigate, useParams } from "react-router-dom"; import axios from "axios"; import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls"; +import { ThemePicker } from "./Components/ThemePicker"; const monitorsUrl = (() => { const params = new URLSearchParams(); @@ -139,6 +140,8 @@ const CreateStatusPage = () => { fd.append("showUptimePercentage", String(data.showUptimePercentage)); fd.append("showAdminLoginLink", String(data.showAdminLoginLink)); fd.append("showInfrastructure", String(data.showInfrastructure)); + if (data.theme) fd.append("theme", data.theme); + if (data.themeMode) fd.append("themeMode", data.themeMode); data.monitors.forEach((monitorId) => { fd.append("monitors[]", monitorId); @@ -438,6 +441,30 @@ const CreateStatusPage = () => { } /> + ( + ( + + )} + /> + )} + /> + } + /> {monitor.name} {getMonitorTypeLabel(monitor.type, t)} @@ -153,6 +155,7 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => { {monitors.map((monitor) => ( { - const monitorsStatus: StatusDisplay = { - icon: , - }; - - // Handle empty monitors array +const getMonitorStatus = (monitors: Monitor[], t: Function): StatusDisplay => { if (monitors.length === 0) { - monitorsStatus.msg = t("pages.statusPages.statusBar.noMonitors"); - monitorsStatus.color = theme.palette.warning.main; - monitorsStatus.icon = ; - return monitorsStatus; + return { + msg: t("pages.statusPages.statusBar.noMonitors"), + tone: "warn", + icon: , + }; } const allOf = (...statuses: MonitorStatus[]) => @@ -41,75 +38,112 @@ const getMonitorStatus = (monitors: Monitor[], theme: Theme, t: Function) => { const noneOf = (...statuses: MonitorStatus[]) => monitors.every((m) => !statuses.includes(m.status)); - // All monitors in a single state if (allOf("up")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.allUp"); - monitorsStatus.color = theme.palette.success.main; - monitorsStatus.icon = ; - } else if (allOf("breached")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.allBreached"); - monitorsStatus.color = theme.palette.error.main; - monitorsStatus.icon = ; - } else if (allOf("maintenance")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.allMaintenance"); - monitorsStatus.color = theme.palette.warning.main; - monitorsStatus.icon = ; - } else if (allOf("down")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.allDown"); - monitorsStatus.color = theme.palette.error.main; - monitorsStatus.icon = ; - } else if (allOf("paused")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.allPaused"); - monitorsStatus.color = theme.palette.warning.main; - monitorsStatus.icon = ; - } else if (allOf("initializing")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.allInitializing"); - monitorsStatus.color = theme.palette.warning.main; - monitorsStatus.icon = ; - - // Breached takes highest priority in mixed states - } else if (someOf("breached") && someOf("down")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.breachedAndDown"); - monitorsStatus.color = theme.palette.error.main; - monitorsStatus.icon = ; - } else if (someOf("breached")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.breached"); - monitorsStatus.color = theme.palette.error.main; - monitorsStatus.icon = ; - - // Maintenance combinations - } else if (someOf("maintenance") && someOf("down")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.maintenanceAndDown"); - monitorsStatus.color = theme.palette.error.main; - monitorsStatus.icon = ; - } else if (someOf("maintenance") && noneOf("down")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.maintenance"); - monitorsStatus.color = theme.palette.warning.main; - monitorsStatus.icon = ; - - // Degraded (some down, no maintenance/breached) - } else if (someOf("down")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.degraded"); - monitorsStatus.color = theme.palette.warning.main; - monitorsStatus.icon = ; - - // Some Paused - } else if (someOf("paused")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.partiallyPaused"); - monitorsStatus.color = theme.palette.warning.main; - monitorsStatus.icon = ; - - // Initializing - } else if (someOf("initializing")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.initializing"); - monitorsStatus.color = theme.palette.info.main; - monitorsStatus.icon = ; - } else { - monitorsStatus.msg = t("pages.statusPages.statusBar.unknown"); - monitorsStatus.color = theme.palette.warning.main; + return { + msg: t("pages.statusPages.statusBar.allUp"), + tone: "up", + icon: , + }; + } + if (allOf("breached")) { + return { + msg: t("pages.statusPages.statusBar.allBreached"), + tone: "down", + icon: , + }; + } + if (allOf("maintenance")) { + return { + msg: t("pages.statusPages.statusBar.allMaintenance"), + tone: "warn", + icon: , + }; + } + if (allOf("down")) { + return { + msg: t("pages.statusPages.statusBar.allDown"), + tone: "down", + icon: , + }; + } + if (allOf("paused")) { + return { + msg: t("pages.statusPages.statusBar.allPaused"), + tone: "warn", + icon: , + }; + } + if (allOf("initializing")) { + return { + msg: t("pages.statusPages.statusBar.allInitializing"), + tone: "warn", + icon: , + }; } - return monitorsStatus; + // Breached takes highest priority in mixed states + if (someOf("breached") && someOf("down")) { + return { + msg: t("pages.statusPages.statusBar.breachedAndDown"), + tone: "down", + icon: , + }; + } + if (someOf("breached")) { + return { + msg: t("pages.statusPages.statusBar.breached"), + tone: "down", + icon: , + }; + } + if (someOf("maintenance") && someOf("down")) { + return { + msg: t("pages.statusPages.statusBar.maintenanceAndDown"), + tone: "down", + icon: , + }; + } + if (someOf("maintenance") && noneOf("down")) { + return { + msg: t("pages.statusPages.statusBar.maintenance"), + tone: "warn", + icon: , + }; + } + if (someOf("down")) { + return { + msg: t("pages.statusPages.statusBar.degraded"), + tone: "warn", + icon: , + }; + } + if (someOf("paused")) { + return { + msg: t("pages.statusPages.statusBar.partiallyPaused"), + tone: "warn", + icon: , + }; + } + if (someOf("initializing")) { + return { + msg: t("pages.statusPages.statusBar.initializing"), + tone: "info", + icon: , + }; + } + + return { + msg: t("pages.statusPages.statusBar.unknown"), + tone: "warn", + icon: , + }; +}; + +const toneToCssVar: Record = { + up: "var(--sp-up)", + down: "var(--sp-down)", + warn: "var(--sp-warn)", + info: "var(--sp-up)", }; interface StatusBarProps { @@ -119,7 +153,7 @@ interface StatusBarProps { export const StatusBar = ({ monitors }: StatusBarProps) => { const theme = useTheme(); const { t } = useTranslation(); - const monitorsStatus = getMonitorStatus(monitors, theme, t); + const monitorsStatus = getMonitorStatus(monitors, t); return ( { justifyContent="center" gap={theme.spacing(2)} height={theme.spacing(30)} - bgcolor={monitorsStatus.color} + bgcolor={toneToCssVar[monitorsStatus.tone]} borderRadius={theme.shape.borderRadius} + className="sp-hero" > {monitorsStatus.icon} - {monitorsStatus.msg} + {monitorsStatus.msg} ); }; diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 78b7cba41..a4864d748 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -11,8 +11,29 @@ import { useTranslation } from "react-i18next"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useLocation, useParams } from "react-router-dom"; import { useGet } from "@/Hooks/UseApi"; -import type { StatusPageResponse } from "@/Types/StatusPage"; +import type { Monitor } from "@/Types/Monitor"; +import type { StatusPage, StatusPageResponse, StatusPageTheme } from "@/Types/StatusPage"; import { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls"; +import { StatusPageThemeProvider } from "./themes/StatusPageThemeProvider"; +import { RefinedStatusPage } from "./themes/refined/RefinedStatusPage"; +import { ModernStatusPage } from "./themes/modern/ModernStatusPage"; +import { BoldStatusPage } from "./themes/bold/BoldStatusPage"; +import { EditorialStatusPage } from "./themes/editorial/EditorialStatusPage"; +import { BrowserFrame } from "./themes/BrowserFrame"; + +type ThemedRendererProps = { + statusPage: StatusPage; + monitors: (Monitor & { checks?: Monitor["recentChecks"] })[]; +}; + +const THEMED_RENDERERS: Partial< + Record> +> = { + refined: RefinedStatusPage, + modern: ModernStatusPage, + bold: BoldStatusPage, + editorial: EditorialStatusPage, +}; const StatusPageView = () => { const theme = useTheme(); @@ -76,38 +97,92 @@ const StatusPageView = () => { ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` : null; - return ( - - - {logoSrc && ( - + ) : null; + + if (themedRenderer) { + // Public route: render directly on the viewport, themed background covers everything. + if (isPublic) { + return ( + + {themedRenderer} + + ); + } + + // Admin preview: wrap in a mock browser frame inside the admin shell. + const publicUrl = `${window.location.origin}/status/public/${statusPage.url}`; + return ( + + - )} - - - + + {themedRenderer} + + + ); + } + + return ( + + + + {logoSrc && ( + + )} + + + + ); }; diff --git a/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx new file mode 100644 index 000000000..55e62c440 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx @@ -0,0 +1,76 @@ +import type { ReactNode } from "react"; +import Box from "@mui/material/Box"; +import { useTheme } from "@mui/material/styles"; + +interface Props { + url: string; + children: ReactNode; +} + +export const BrowserFrame = ({ url, children }: Props) => { + const theme = useTheme(); + + return ( + + + + + + + + + {url} + + + {children} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx new file mode 100644 index 000000000..294198330 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx @@ -0,0 +1,148 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { + DEFAULT_STATUS_PAGE_THEME, + DEFAULT_STATUS_PAGE_THEME_MODE, + type StatusPageTheme, + type StatusPageThemeMode, +} from "@/Types/StatusPage"; +import { themeTokens, toCssVars, type StatusPageThemeTokens } from "./tokens"; +import "./theme-overrides.css"; + +type ResolvedMode = "light" | "dark"; + +interface StatusPageThemeContextValue { + theme: StatusPageTheme; + mode: ResolvedMode; + tokens: StatusPageThemeTokens; +} + +const StatusPageThemeContext = createContext(null); + +const resolveSystemMode = (): ResolvedMode => { + if (typeof window === "undefined" || !window.matchMedia) return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +}; + +interface Props { + theme?: StatusPageTheme; + themeMode?: StatusPageThemeMode; + /** + * When true, paint the document body with the theme background so it + * covers beyond the provider's wrapper (useful on the public route + * where no admin shell sits behind). Defaults to false for admin + * previews that already have an app background. + */ + paintBody?: boolean; + /** + * When true, the provider's wrapper div does NOT paint its own + * background or set a min-height. Use this when the wrapper is + * inside a clipped container (e.g. BrowserFrame) that already sets + * the visible background, so the wrapper's bg doesn't bleed past the + * container's rounded corners. + */ + transparent?: boolean; + children: ReactNode; +} + +export const StatusPageThemeProvider = ({ + theme, + themeMode, + paintBody = false, + transparent = false, + children, +}: Props) => { + const resolvedTheme = theme ?? DEFAULT_STATUS_PAGE_THEME; + const resolvedThemeMode = themeMode ?? DEFAULT_STATUS_PAGE_THEME_MODE; + + const [systemMode, setSystemMode] = useState(resolveSystemMode); + + useEffect(() => { + if (resolvedThemeMode !== "auto") return; + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + // Reconcile on first paint whenever we (re-)enter auto mode — only + // when it disagrees with the stored value, so we don't trigger an + // extra render on the common hot path. + const next: ResolvedMode = mq.matches ? "dark" : "light"; + setSystemMode((prev) => (prev === next ? prev : next)); + const handler = (e: MediaQueryListEvent) => + setSystemMode(e.matches ? "dark" : "light"); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [resolvedThemeMode]); + + useEffect(() => { + if (!paintBody || typeof document === "undefined") return; + const html = document.documentElement; + const body = document.body; + const prev = { + htmlBg: html.style.background, + bodyBg: body.style.background, + bodyMargin: body.style.margin, + htmlColorScheme: html.style.colorScheme, + }; + const resolvedMode = resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode; + const bg = themeTokens[theme ?? DEFAULT_STATUS_PAGE_THEME][resolvedMode].bg; + html.style.background = bg; + body.style.background = bg; + body.style.margin = "0"; + html.style.colorScheme = resolvedMode; + return () => { + html.style.background = prev.htmlBg; + body.style.background = prev.bodyBg; + body.style.margin = prev.bodyMargin; + html.style.colorScheme = prev.htmlColorScheme; + }; + }, [paintBody, theme, resolvedThemeMode, systemMode]); + + const resolvedMode: ResolvedMode = + resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode; + const tokens = themeTokens[resolvedTheme][resolvedMode]; + + const style = useMemo(() => toCssVars(tokens) as React.CSSProperties, [tokens]); + + const value = useMemo( + () => ({ theme: resolvedTheme, mode: resolvedMode, tokens }), + [resolvedTheme, resolvedMode, tokens] + ); + + return ( + +
+ {children} +
+
+ ); +}; + +export const useStatusPageTheme = (): StatusPageThemeContextValue => { + const ctx = useContext(StatusPageThemeContext); + if (!ctx) { + return { + theme: DEFAULT_STATUS_PAGE_THEME, + mode: resolveSystemMode(), + tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()], + }; + } + return ctx; +}; diff --git a/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx new file mode 100644 index 000000000..ad777dbff --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx @@ -0,0 +1,180 @@ +import { useState } from "react"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTranslation } from "react-i18next"; +import type { Monitor } from "@/Types/Monitor"; +import type { StatusPage } from "@/Types/StatusPage"; +import { getMonitorTypeLabel } from "@/Types/StatusPage"; +import { ThemedHeatmap } from "../shared/ThemedHeatmap"; +import { ThemedHistogram } from "../shared/ThemedHistogram"; +import { ThemedInfrastructure } from "../shared/ThemedInfrastructure"; +import { + monitorBadgeTone, + monoFirstChar, + resolveOverallStatus, + statusBadgeKey, +} from "../shared/overallStatus"; +import "./bold.css"; + +type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; + +interface Props { + statusPage: StatusPage; + monitors: StatusPageMonitor[]; +} + +const PREFIX = "bo"; + +export const BoldStatusPage = ({ statusPage, monitors }: Props) => { + const { t } = useTranslation(); + const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); + + // Bold uses a dedicated check-disc treatment next to the title; its + // icon is scaled differently but the semantic resolution is shared. + const overall = resolveOverallStatus(monitors, t, { iconSize: 18 }); + const logoSrc = statusPage.logo?.data + ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` + : null; + + return ( + + + + {logoSrc ? ( + + ) : ( + + {monoFirstChar(statusPage.companyName)} + + )} + {statusPage.companyName} + + + + +

+ {overall.icon} + {overall.message} +

+

+ {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitors.length, + })} +

+
+ + {statusPage.showCharts && ( + + + + + + + )} + + + {monitors.map((monitor) => { + const isHardware = monitor.type === "hardware"; + const showInfra = isHardware && statusPage.showInfrastructure !== false; + const showChart = !isHardware && statusPage.showCharts !== false; + const badgeTone = monitorBadgeTone(monitor.status); + + return ( + + + + {monitor.name} + + + {getMonitorTypeLabel(monitor.type, t)} + + {monitor.url && ( + + {monitor.url} + + )} + + + + {t(statusBadgeKey[monitor.status])} + + + + {showInfra && ( + + )} + {showChart && + (chartMode === "heatmap" ? ( + + ) : ( + + ))} + + ); + })} + + + + {t("pages.statusPages.footer.poweredBy")}{" "} + + Checkmate + + +
+ ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/bold/bold.css b/client/src/Pages/StatusPage/Status/themes/bold/bold.css new file mode 100644 index 000000000..a6e44db47 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/bold/bold.css @@ -0,0 +1,497 @@ +/* + * Bold theme — scoped to [data-status-theme="bold"]. + * Port of /Users/gorkemcetin/Desktop/checkmate-status-mockups/03-bold-dark.html. + * Signature traits: conic-gradient logo, dual-radial-glow body, large + * 34px hero title with a check-disc inline, glowing chart cells/bars, + * 18px-radius cards with staggered fade-in and accent-green hover. + */ + +[data-status-theme="bold"]:not([data-status-transparent]) { + display: flex; + flex-direction: column; + min-height: 100vh; + background: + radial-gradient( + 900px 500px at 15% -10%, + color-mix(in srgb, var(--sp-up) 4%, transparent), + transparent 60% + ), + radial-gradient( + 900px 500px at 95% 10%, + color-mix(in srgb, var(--sp-up-strong) 3%, transparent), + transparent 60% + ), + var(--sp-bg); +} +[data-status-theme="bold"][data-status-mode="dark"]:not([data-status-transparent]) { + background: + radial-gradient( + 900px 500px at 15% -10%, + color-mix(in srgb, var(--sp-up) 8%, transparent), + transparent 60% + ), + radial-gradient( + 900px 500px at 95% 10%, + color-mix(in srgb, var(--sp-up-strong) 9%, transparent), + transparent 60% + ), + var(--sp-bg); +} + +[data-status-theme="bold"] .bo-page { + flex: 1 0 auto; + max-width: 1040px; + width: 100%; + margin: 0 auto; + padding: 56px 24px 96px; + font: + 14px/1.55 ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + "Inter", + "Segoe UI", + Roboto, + sans-serif; + color: var(--sp-text); + -webkit-font-smoothing: antialiased; +} + +/* --- header / conic logo --- */ +[data-status-theme="bold"] .bo-top { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 36px; +} +[data-status-theme="bold"] .bo-brand { + display: flex; + align-items: center; + gap: 14px; + font-weight: 700; + font-size: 16px; + letter-spacing: -0.01em; + color: var(--sp-text); +} +[data-status-theme="bold"] .bo-logo-conic { + width: 36px; + height: 36px; + border-radius: 12px; + background: conic-gradient( + from 140deg, + var(--sp-up), + var(--sp-up-strong), + #0f766e, + var(--sp-up) + ); + display: grid; + place-items: center; + color: #05070a; + font-weight: 900; + font-size: 14px; + box-shadow: + 0 8px 24px color-mix(in srgb, var(--sp-up) 30%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.2); +} +[data-status-theme="bold"] .bo-logo-img { + max-height: 36px; + max-width: 140px; + object-fit: contain; +} + +/* --- hero: gradient overlay + dual radial glow + big title with check disc --- */ +[data-status-theme="bold"] .bo-hero { + position: relative; + border-radius: 22px; + padding: 36px 36px 32px; + margin-bottom: 28px; + background: + linear-gradient( + 135deg, + color-mix(in srgb, var(--sp-up) 8%, transparent), + transparent 60% + ), + var(--sp-surface); + border: 1px solid var(--sp-border); + overflow: hidden; +} +[data-status-theme="bold"][data-status-mode="dark"] .bo-hero { + background: + linear-gradient( + 135deg, + color-mix(in srgb, var(--sp-up) 16%, transparent), + transparent 60% + ), + var(--sp-surface); +} +[data-status-theme="bold"] .bo-hero::before { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient( + 400px 200px at 20% 30%, + color-mix(in srgb, var(--sp-up) 14%, transparent), + transparent 60% + ), + radial-gradient( + 300px 200px at 90% 80%, + color-mix(in srgb, var(--sp-up-strong) 10%, transparent), + transparent 60% + ); + pointer-events: none; + opacity: 0.8; + z-index: 0; +} +[data-status-theme="bold"][data-status-mode="dark"] .bo-hero::before { + background: + radial-gradient( + 400px 200px at 20% 30%, + color-mix(in srgb, var(--sp-up) 32%, transparent), + transparent 60% + ), + radial-gradient( + 300px 200px at 90% 80%, + color-mix(in srgb, var(--sp-up-strong) 22%, transparent), + transparent 60% + ); + mix-blend-mode: screen; + opacity: 0.8; +} +[data-status-theme="bold"] .bo-hero > * { + position: relative; + z-index: 1; +} +[data-status-theme="bold"] .bo-hero-title { + margin: 0; + font-size: 34px; + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.1; + color: var(--sp-text); + display: flex; + align-items: center; + gap: 10px; +} +[data-status-theme="bold"] .bo-hero-check { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--sp-up); + color: #05070a; + flex-shrink: 0; + box-shadow: 0 0 0 6px var(--sp-up-soft); +} +[data-status-theme="bold"] .bo-hero.bo-tone-warn .bo-hero-check { + background: var(--sp-warn); + box-shadow: 0 0 0 6px var(--sp-warn-soft); +} +[data-status-theme="bold"] .bo-hero.bo-tone-down .bo-hero-check { + background: var(--sp-down); + color: #fff; + box-shadow: 0 0 0 6px var(--sp-down-soft); +} +[data-status-theme="bold"] .bo-hero-sub { + margin: 10px 0 0; + color: var(--sp-text-muted); + font-size: 14px; +} + +/* --- chart toggle --- */ +[data-status-theme="bold"] .bo-chart-switch-wrap { + display: flex; + justify-content: flex-end; + margin-bottom: 16px; +} +[data-status-theme="bold"] .bo-chart-switch { + display: inline-flex; + border: 1px solid var(--sp-border); + border-radius: 999px; + background: var(--sp-surface); + padding: 3px; + gap: 2px; +} +[data-status-theme="bold"] .bo-chart-switch > button { + border: 0; + background: transparent; + font: inherit; + font-size: 11px; + padding: 8px 18px; + cursor: pointer; + color: var(--sp-text-muted); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + transition: + background 0.2s, + color 0.2s; + border-radius: 999px; +} +[data-status-theme="bold"] .bo-chart-switch > button:hover { + color: var(--sp-text); +} +[data-status-theme="bold"] .bo-chart-switch > button.bo-active { + color: var(--sp-up); + background: var(--sp-up-soft); +} + +/* --- cards: 18px radius, staggered fade-in, accent-green border on hover --- */ +[data-status-theme="bold"] .bo-monitor-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 14px; +} +[data-status-theme="bold"] .bo-card { + background: var(--sp-surface); + border: 1px solid var(--sp-border); + border-radius: 18px; + overflow: hidden; + transition: + transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), + border-color 0.2s; +} +[data-status-theme="bold"] .bo-card:hover { + transform: translateY(-3px); + border-color: color-mix(in srgb, var(--sp-up) 40%, var(--sp-border)); +} + +[data-status-theme="bold"] .bo-card-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 24px; + padding: 22px 28px; +} +[data-status-theme="bold"] .bo-card-left { + min-width: 0; +} +[data-status-theme="bold"] .bo-monitor-name { + font-weight: 700; + font-size: 17px; + letter-spacing: -0.01em; + color: var(--sp-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-status-theme="bold"] .bo-monitor-meta { + display: flex; + gap: 10px; + align-items: center; + margin-top: 6px; + flex-wrap: wrap; +} +[data-status-theme="bold"] .bo-pill { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--sp-text-muted); + background: color-mix(in srgb, var(--sp-surface) 80%, var(--sp-bg)); + border: 1px solid var(--sp-border); + padding: 3px 10px; + border-radius: 999px; + font-weight: 700; +} +[data-status-theme="bold"] .bo-pill-hardware { + color: var(--sp-up); + border-color: color-mix(in srgb, var(--sp-up) 40%, transparent); + background: var(--sp-up-soft); +} +[data-status-theme="bold"] .bo-monitor-url { + font-size: 12px; + color: var(--sp-text-muted); + font-family: ui-monospace, Menlo, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 300px; +} + +/* --- badge: glowing dot --- */ +[data-status-theme="bold"] .bo-badge { + font-size: 11px; + font-weight: 800; + padding: 6px 14px; + border-radius: 999px; + display: inline-flex; + align-items: center; + gap: 7px; + text-transform: uppercase; + letter-spacing: 0.08em; + white-space: nowrap; +} +[data-status-theme="bold"] .bo-badge::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 8px currentColor; +} +[data-status-theme="bold"] .bo-badge-up { + background: var(--sp-up-soft); + color: var(--sp-up); +} +[data-status-theme="bold"] .bo-badge-warn { + background: var(--sp-warn-soft); + color: var(--sp-warn); +} +[data-status-theme="bold"] .bo-badge-down { + background: var(--sp-down-soft); + color: var(--sp-down); +} + +/* --- heatmap: glowing cells --- */ +[data-status-theme="bold"] .bo-heatmap { + padding: 0 28px 22px; + display: grid; + grid-template-columns: repeat(25, 1fr); + gap: 3px; + height: 48px; +} +[data-status-theme="bold"] .bo-heatmap-cell { + border-radius: 3px; + background: var(--sp-up); + transition: transform 0.15s; + box-shadow: 0 0 6px color-mix(in srgb, var(--sp-up) 18%, transparent); +} +[data-status-theme="bold"] .bo-heatmap-cell:hover { + transform: scaleY(1.2); +} +[data-status-theme="bold"] .bo-heatmap-cell.bo-fast { + background: var(--sp-up); +} +[data-status-theme="bold"] .bo-heatmap-cell.bo-med { + background: color-mix(in srgb, var(--sp-up) 70%, #ffffff 30%); +} +[data-status-theme="bold"] .bo-heatmap-cell.bo-slow { + background: var(--sp-warn); + box-shadow: 0 0 6px color-mix(in srgb, var(--sp-warn) 20%, transparent); +} +[data-status-theme="bold"] .bo-heatmap-cell.bo-down { + background: var(--sp-down); + box-shadow: 0 0 6px color-mix(in srgb, var(--sp-down) 20%, transparent); +} +[data-status-theme="bold"] .bo-heatmap-cell.bo-empty { + background: var(--sp-border); + box-shadow: none; + opacity: 0.4; +} + +/* --- histogram: glowing gradient bars --- */ +[data-status-theme="bold"] .bo-histogram { + padding: 0 28px; + display: grid; + grid-template-columns: repeat(25, 1fr); + gap: 3px; + align-items: flex-end; + height: 48px; +} +[data-status-theme="bold"] .bo-bar { + background: var(--sp-up); + border-radius: 3px; + min-height: 4px; +} +[data-status-theme="bold"] .bo-bar.bo-down { + background: var(--sp-down); +} +[data-status-theme="bold"] .bo-bar.bo-empty { + background: var(--sp-border); + box-shadow: none; + opacity: 0.4; +} +[data-status-theme="bold"] .bo-chart-stats { + padding: 0 28px 22px; + font-size: 11px; + color: var(--sp-text-muted); + font-variant-numeric: tabular-nums; + font-weight: 700; + letter-spacing: 0.05em; +} + +/* --- gauges: larger, bolder, 16px radius --- */ +[data-status-theme="bold"] .bo-infra { + padding: 18px 28px 26px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} +@media (max-width: 720px) { + [data-status-theme="bold"] .bo-infra { + grid-template-columns: 1fr; + } +} +[data-status-theme="bold"] .bo-infra-empty { + padding: 18px 28px 26px; + color: var(--sp-text-muted); + font-size: 13px; +} +[data-status-theme="bold"] .bo-gauge { + border: 1px solid var(--sp-border); + border-radius: 16px; + padding: 16px 18px; + background: color-mix(in srgb, var(--sp-surface) 80%, var(--sp-bg)); +} +[data-status-theme="bold"] .bo-gauge-label { + font-size: 10px; + color: var(--sp-text-muted); + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 700; +} +[data-status-theme="bold"] .bo-gauge-value { + font-size: 30px; + font-weight: 800; + letter-spacing: -0.02em; + margin-top: 6px; + font-variant-numeric: tabular-nums; + color: var(--sp-text); +} +[data-status-theme="bold"] .bo-gauge-bar { + height: 6px; + background: var(--sp-border); + border-radius: 3px; + overflow: hidden; + margin-top: 12px; +} +[data-status-theme="bold"] .bo-gauge-fill { + display: block; + height: 100%; + background: linear-gradient( + 90deg, + var(--sp-up), + color-mix(in srgb, var(--sp-up) 70%, #ffffff 30%) + ); + border-radius: 3px; + transition: width 0.9s cubic-bezier(0.2, 0.8, 0.2, 1); +} +[data-status-theme="bold"] .bo-gauge-fill.bo-warm { + background: linear-gradient(90deg, var(--sp-warn), #fbbf24); +} +[data-status-theme="bold"] .bo-gauge-fill.bo-hot { + background: linear-gradient(90deg, var(--sp-down), #fb7185); +} +[data-status-theme="bold"] .bo-gauge-sub { + font-size: 11px; + color: var(--sp-text-muted); + margin-top: 10px; + font-variant-numeric: tabular-nums; +} + +/* --- footer --- */ +[data-status-theme="bold"] .bo-footer { + text-align: center; + color: var(--sp-text-muted); + font-size: 12px; + margin-top: 52px; +} +[data-status-theme="bold"] .bo-footer a { + color: var(--sp-up); + text-decoration: underline; + text-underline-offset: 3px; + font-weight: 800; +} diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx new file mode 100644 index 000000000..306ae2bc0 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx @@ -0,0 +1,189 @@ +import { useMemo, useState } from "react"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTranslation } from "react-i18next"; +import type { Monitor } from "@/Types/Monitor"; +import type { StatusPage } from "@/Types/StatusPage"; +import { getMonitorTypeLabel } from "@/Types/StatusPage"; +import { ThemedHeatmap } from "../shared/ThemedHeatmap"; +import { ThemedHistogram } from "../shared/ThemedHistogram"; +import { ThemedInfrastructure } from "../shared/ThemedInfrastructure"; +import { + monitorBadgeTone, + resolveOverallStatus, + statusBadgeKey, +} from "../shared/overallStatus"; +import "./editorial.css"; + +type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; + +interface Props { + statusPage: StatusPage; + monitors: StatusPageMonitor[]; +} + +const PREFIX = "ed"; + +export const EditorialStatusPage = ({ statusPage, monitors }: Props) => { + const { t } = useTranslation(); + const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); + + // Stable date across re-renders; the page polls every 10s and we don't + // want the dateline to jitter. + const todayLabel = useMemo( + () => + new Date().toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }), + [] + ); + + const overall = resolveOverallStatus(monitors, t, { + allUpKey: "pages.statusPages.editorial.allUp", + }); + const logoSrc = statusPage.logo?.data + ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` + : null; + + return ( + + + + {logoSrc && ( + + )} +

{statusPage.companyName}

+

+ {t("pages.statusPages.editorial.reportTitle")} +

+
+
+ +

+ + {overall.message} +

+

+ {todayLabel} ·{" "} + {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitors.length, + })} +

+ + {statusPage.showCharts && ( + + + + + + + )} + + + {monitors.map((monitor) => { + const isHardware = monitor.type === "hardware"; + const showInfra = isHardware && statusPage.showInfrastructure !== false; + const showChart = !isHardware && statusPage.showCharts !== false; + const badgeTone = monitorBadgeTone(monitor.status); + + return ( + + + + {monitor.name} + + + {getMonitorTypeLabel(monitor.type, t)} + + {monitor.url && ( + + {monitor.url} + + )} + + + + {t(statusBadgeKey[monitor.status])} + + + + {showInfra && ( + + )} + {showChart && + (chartMode === "heatmap" ? ( + + ) : ( + + ))} + + ); + })} + + + + {t("pages.statusPages.footer.poweredBy")}{" "} + + Checkmate + + +
+ ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/editorial.css b/client/src/Pages/StatusPage/Status/themes/editorial/editorial.css new file mode 100644 index 000000000..3cab68b6f --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/editorial/editorial.css @@ -0,0 +1,353 @@ +/* + * Editorial theme — scoped to [data-status-theme="editorial"]. + * Port of /Users/gorkemcetin/Desktop/checkmate-status-mockups/04-editorial.html. + * Signature traits: serif body, narrow 760px column, newspaper-style + * header with eyebrow + big "Service Report" title, flat rectangular + * badges (no border-radius), hairline dividers instead of cards, + * dateline below status, serif gauge values. + */ + +[data-status-theme="editorial"]:not([data-status-transparent]) { + display: flex; + flex-direction: column; + min-height: 100vh; + background: var(--sp-bg); +} + +[data-status-theme="editorial"] .ed-page { + flex: 1 0 auto; + max-width: 760px; + width: 100%; + margin: 0 auto; + padding: 64px 24px 96px; + font: + 15px/1.6 Georgia, + "Iowan Old Style", + "Palatino Linotype", + serif; + color: var(--sp-text); + -webkit-font-smoothing: antialiased; +} + +/* --- header: eyebrow caps + serif h1, 2px underline under whole block --- */ +[data-status-theme="editorial"] .ed-top { + display: flex; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 48px; + border-bottom: 2px solid var(--sp-text); + padding-bottom: 18px; +} +[data-status-theme="editorial"] .ed-brand-wrap { + display: flex; + flex-direction: column; +} +[data-status-theme="editorial"] .ed-logo-img { + max-height: 48px; + max-width: 160px; + object-fit: contain; + margin-bottom: 8px; +} +[data-status-theme="editorial"] .ed-brand-eyebrow { + margin: 0; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.3em; + font-weight: 700; + color: var(--sp-text-muted); + font-family: -apple-system, "Helvetica Neue", Arial, sans-serif; +} +[data-status-theme="editorial"] .ed-brand-title { + margin: 6px 0 0; + font-size: 40px; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1; + color: var(--sp-text); +} + +/* --- status line: single sentence, not a card --- */ +[data-status-theme="editorial"] .ed-status-line { + font-size: 22px; + font-weight: 400; + margin: 0 0 8px; + letter-spacing: -0.01em; + line-height: 1.35; + color: var(--sp-text); + font-family: Georgia, "Iowan Old Style", "Palatino Linotype", serif; +} +[data-status-theme="editorial"] .ed-status-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--sp-up); + margin-right: 10px; + vertical-align: middle; +} +[data-status-theme="editorial"] .ed-status-line.ed-tone-warn .ed-status-dot { + background: var(--sp-warn); +} +[data-status-theme="editorial"] .ed-status-line.ed-tone-down .ed-status-dot { + background: var(--sp-down); +} +[data-status-theme="editorial"] .ed-dateline { + font-family: -apple-system, "Helvetica Neue", Arial, sans-serif; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--sp-text-muted); + margin: 0 0 56px; +} + +/* --- chart switch: rectangular, dark fill on active (no radius) --- */ +[data-status-theme="editorial"] .ed-chart-switch-wrap { + display: flex; + justify-content: flex-end; + margin-bottom: 20px; + font-family: -apple-system, "Helvetica Neue", Arial, sans-serif; +} +[data-status-theme="editorial"] .ed-chart-switch { + display: inline-flex; + border: 1px solid var(--sp-text); +} +[data-status-theme="editorial"] .ed-chart-switch > button { + font: inherit; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.2em; + padding: 6px 14px; + border: 0; + background: transparent; + color: var(--sp-text); + cursor: pointer; + font-weight: 700; +} +[data-status-theme="editorial"] .ed-chart-switch > button + button { + border-left: 1px solid var(--sp-text); +} +[data-status-theme="editorial"] .ed-chart-switch > button.ed-active { + background: var(--sp-text); + color: var(--sp-bg); +} + +/* --- monitor cards: transparent rows with bottom hairline --- */ +[data-status-theme="editorial"] .ed-monitor-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +[data-status-theme="editorial"] .ed-card { + background: transparent; + border: 0; + border-bottom: 1px solid var(--sp-border); + margin-bottom: 0; + padding: 20px 0; +} +[data-status-theme="editorial"] .ed-card:last-of-type { + border-bottom: 0; +} + +[data-status-theme="editorial"] .ed-card-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: baseline; + gap: 20px; +} +[data-status-theme="editorial"] .ed-card-left { + min-width: 0; +} +[data-status-theme="editorial"] .ed-monitor-name { + font-size: 20px; + font-weight: 700; + letter-spacing: -0.015em; + color: var(--sp-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-status-theme="editorial"] .ed-monitor-meta { + font-family: -apple-system, "Helvetica Neue", Arial, sans-serif; + font-size: 12px; + color: var(--sp-text-muted); + margin-top: 6px; + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: center; +} +[data-status-theme="editorial"] .ed-pill { + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 700; + color: var(--sp-text-muted); +} +[data-status-theme="editorial"] .ed-pill-hardware { + color: var(--sp-up); +} +[data-status-theme="editorial"] .ed-monitor-url { + font-family: ui-monospace, Menlo, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 280px; + font-size: 12px; +} + +/* --- badge: rectangular flat label --- */ +[data-status-theme="editorial"] .ed-badge { + font-family: -apple-system, "Helvetica Neue", Arial, sans-serif; + font-size: 10px; + font-weight: 700; + padding: 4px 10px; + border-radius: 0; + text-transform: uppercase; + letter-spacing: 0.15em; + white-space: nowrap; +} +[data-status-theme="editorial"] .ed-badge-up { + background: var(--sp-up-soft); + color: var(--sp-up); +} +[data-status-theme="editorial"] .ed-badge-warn { + background: var(--sp-warn-soft); + color: var(--sp-warn); +} +[data-status-theme="editorial"] .ed-badge-down { + background: var(--sp-down-soft); + color: var(--sp-down); +} + +/* --- charts: flat rectangles, no glow --- */ +[data-status-theme="editorial"] .ed-heatmap { + margin-top: 16px; + display: grid; + grid-template-columns: repeat(25, 1fr); + gap: 2px; + height: 40px; +} +[data-status-theme="editorial"] .ed-heatmap-cell { + background: var(--sp-up); +} +[data-status-theme="editorial"] .ed-heatmap-cell.ed-fast { + background: var(--sp-up); +} +[data-status-theme="editorial"] .ed-heatmap-cell.ed-med { + background: color-mix(in srgb, var(--sp-up) 60%, var(--sp-bg)); +} +[data-status-theme="editorial"] .ed-heatmap-cell.ed-slow { + background: var(--sp-warn); +} +[data-status-theme="editorial"] .ed-heatmap-cell.ed-down { + background: var(--sp-down); +} +[data-status-theme="editorial"] .ed-heatmap-cell.ed-empty { + background: var(--sp-border); + opacity: 0.5; +} + +[data-status-theme="editorial"] .ed-histogram { + margin-top: 16px; + display: grid; + grid-template-columns: repeat(25, 1fr); + gap: 2px; + align-items: flex-end; + height: 40px; +} +[data-status-theme="editorial"] .ed-bar { + background: var(--sp-up); + min-height: 3px; +} +[data-status-theme="editorial"] .ed-bar.ed-down { + background: var(--sp-down); +} +[data-status-theme="editorial"] .ed-bar.ed-empty { + background: var(--sp-border); + opacity: 0.5; +} +[data-status-theme="editorial"] .ed-chart-stats { + font-family: -apple-system, "Helvetica Neue", Arial, sans-serif; + font-size: 11px; + color: var(--sp-text-muted); + font-variant-numeric: tabular-nums; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +/* --- gauges: 3-column with serif value + 2px underline bar --- */ +[data-status-theme="editorial"] .ed-infra { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + margin-top: 20px; + font-family: -apple-system, "Helvetica Neue", Arial, sans-serif; +} +@media (max-width: 720px) { + [data-status-theme="editorial"] .ed-infra { + grid-template-columns: 1fr; + } +} +[data-status-theme="editorial"] .ed-infra-empty { + margin-top: 20px; + color: var(--sp-text-muted); + font-size: 13px; +} +[data-status-theme="editorial"] .ed-gauge-label { + font-size: 10px; + color: var(--sp-text-muted); + text-transform: uppercase; + letter-spacing: 0.18em; + font-weight: 700; +} +[data-status-theme="editorial"] .ed-gauge-value { + font-size: 28px; + font-weight: 700; + letter-spacing: -0.02em; + margin-top: 4px; + font-variant-numeric: tabular-nums; + font-family: Georgia, serif; + color: var(--sp-text); +} +[data-status-theme="editorial"] .ed-gauge-bar { + height: 2px; + background: var(--sp-border); + margin-top: 10px; +} +[data-status-theme="editorial"] .ed-gauge-fill { + display: block; + height: 100%; + background: var(--sp-up); + transition: width 0.8s; +} +[data-status-theme="editorial"] .ed-gauge-fill.ed-warm { + background: var(--sp-warn); +} +[data-status-theme="editorial"] .ed-gauge-fill.ed-hot { + background: var(--sp-down); +} +[data-status-theme="editorial"] .ed-gauge-sub { + font-size: 11px; + color: var(--sp-text-muted); + margin-top: 8px; + font-variant-numeric: tabular-nums; +} + +/* --- footer: tiny uppercase tracked + underlined link --- */ +[data-status-theme="editorial"] .ed-footer { + text-align: center; + color: var(--sp-text-muted); + font-size: 11px; + margin-top: 56px; + text-transform: uppercase; + letter-spacing: 0.2em; + font-family: -apple-system, "Helvetica Neue", Arial, sans-serif; +} +[data-status-theme="editorial"] .ed-footer a { + color: var(--sp-text); + text-decoration: none; + border-bottom: 1px solid var(--sp-text); + padding-bottom: 2px; + font-weight: 700; +} diff --git a/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx new file mode 100644 index 000000000..f853d90d6 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx @@ -0,0 +1,179 @@ +import { useState } from "react"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTranslation } from "react-i18next"; +import type { Monitor } from "@/Types/Monitor"; +import type { StatusPage } from "@/Types/StatusPage"; +import { getMonitorTypeLabel } from "@/Types/StatusPage"; +import { ThemedHeatmap } from "../shared/ThemedHeatmap"; +import { ThemedHistogram } from "../shared/ThemedHistogram"; +import { ThemedInfrastructure } from "../shared/ThemedInfrastructure"; +import { + monitorBadgeTone, + monoFirstChar, + resolveOverallStatus, + statusBadgeKey, +} from "../shared/overallStatus"; +import "./modern.css"; + +type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; + +interface Props { + statusPage: StatusPage; + monitors: StatusPageMonitor[]; +} + +const PREFIX = "mod"; + +export const ModernStatusPage = ({ statusPage, monitors }: Props) => { + const { t } = useTranslation(); + const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); + + const overall = resolveOverallStatus(monitors, t, { iconSize: 20 }); + const logoSrc = statusPage.logo?.data + ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` + : null; + + return ( + + + + {logoSrc ? ( + + ) : ( + + {monoFirstChar(statusPage.companyName)} + + )} + {statusPage.companyName} + + + + + + +

{overall.message}

+

+ {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitors.length, + })} +

+
+ {overall.icon} +
+ + {statusPage.showCharts && ( + + + + + + + )} + + + {monitors.map((monitor) => { + const isHardware = monitor.type === "hardware"; + const showInfra = isHardware && statusPage.showInfrastructure !== false; + const showChart = !isHardware && statusPage.showCharts !== false; + const badgeTone = monitorBadgeTone(monitor.status); + + return ( + + + + {monitor.name} + + + {getMonitorTypeLabel(monitor.type, t)} + + {monitor.url && ( + + {monitor.url} + + )} + + + + {t(statusBadgeKey[monitor.status])} + + + + {showInfra && ( + + )} + {showChart && + (chartMode === "heatmap" ? ( + + ) : ( + + ))} + + ); + })} + + + + {t("pages.statusPages.footer.poweredBy")}{" "} + + Checkmate + + +
+ ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/modern/modern.css b/client/src/Pages/StatusPage/Status/themes/modern/modern.css new file mode 100644 index 000000000..d51064600 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/modern/modern.css @@ -0,0 +1,519 @@ +/* + * Modern theme — scoped to [data-status-theme="modern"]. + * Port of /Users/gorkemcetin/Desktop/checkmate-status-mockups/02-modern.html. + * Signature traits: gradient logo, pulsing status dot, gradient border + + * radial glow on the hero, 16px rounded cards with strong shadow and + * staggered fade-in on mount, gradient-filled chart bars, 4-column + * gauge row with gradient backgrounds. + */ + +[data-status-theme="modern"]:not([data-status-transparent]) { + display: flex; + flex-direction: column; + min-height: 100vh; + background: var(--sp-bg); +} + +[data-status-theme="modern"] .mod-page { + flex: 1 0 auto; + max-width: 980px; + width: 100%; + margin: 0 auto; + padding: 56px 20px 80px; + font: + 14px/1.5 -apple-system, + BlinkMacSystemFont, + "Inter", + "Segoe UI", + Roboto, + sans-serif; + color: var(--sp-text); + -webkit-font-smoothing: antialiased; +} + +/* --- header / brand --- */ +[data-status-theme="modern"] .mod-top { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32px; +} +[data-status-theme="modern"] .mod-brand { + display: flex; + align-items: center; + gap: 12px; + font-weight: 700; + font-size: 15px; + letter-spacing: -0.01em; + color: var(--sp-text); +} +[data-status-theme="modern"] .mod-logo-grad { + width: 32px; + height: 32px; + border-radius: 10px; + background: linear-gradient(135deg, var(--sp-up), var(--sp-up-strong)); + display: grid; + place-items: center; + color: #fff; + font-weight: 700; + font-size: 14px; + box-shadow: 0 4px 10px color-mix(in srgb, var(--sp-up) 40%, transparent); +} +[data-status-theme="modern"] .mod-logo-img { + max-height: 32px; + max-width: 120px; + object-fit: contain; +} + +/* --- hero: clean flat card with pulsing dot (the signature motion) --- */ +[data-status-theme="modern"] .mod-hero { + position: relative; + border-radius: 20px; + padding: 28px 32px; + margin-bottom: 24px; + display: flex; + align-items: center; + gap: 20px; + background: var(--sp-surface); + border: 1px solid var(--sp-border); + box-shadow: + 0 2px 4px rgba(16, 24, 40, 0.04), + 0 12px 32px rgba(16, 24, 40, 0.06); +} +[data-status-theme="modern"][data-status-mode="dark"] .mod-hero { + box-shadow: + 0 2px 6px rgba(0, 0, 0, 0.5), + 0 20px 40px rgba(0, 0, 0, 0.4); +} +[data-status-theme="modern"] .mod-pulse { + position: relative; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--sp-up); + flex-shrink: 0; +} +[data-status-theme="modern"] .mod-hero.mod-tone-warn .mod-pulse { + background: var(--sp-warn); +} +[data-status-theme="modern"] .mod-hero.mod-tone-down .mod-pulse { + background: var(--sp-down); +} +[data-status-theme="modern"] .mod-pulse::before, +[data-status-theme="modern"] .mod-pulse::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + background: currentColor; + opacity: 0.5; + animation: mod-pulse 2s ease-out infinite; +} +[data-status-theme="modern"] .mod-pulse::after { + animation-delay: 1s; +} +[data-status-theme="modern"] .mod-pulse { + color: var(--sp-up); +} +[data-status-theme="modern"] .mod-hero.mod-tone-warn .mod-pulse { + color: var(--sp-warn); +} +[data-status-theme="modern"] .mod-hero.mod-tone-down .mod-pulse { + color: var(--sp-down); +} +@keyframes mod-pulse { + 0% { + transform: scale(1); + opacity: 0.5; + } + 100% { + transform: scale(2.4); + opacity: 0; + } +} +[data-status-theme="modern"] .mod-status-copy { + flex: 1; + min-width: 0; +} +[data-status-theme="modern"] .mod-hero-title { + margin: 0 0 4px; + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--sp-text); +} +[data-status-theme="modern"] .mod-hero-sub { + margin: 0; + color: var(--sp-text-muted); + font-size: 13px; +} +[data-status-theme="modern"] .mod-hero-icon { + color: var(--sp-text-muted); + display: flex; + align-items: center; +} +[data-status-theme="modern"] .mod-hero.mod-tone-up .mod-hero-icon { + color: var(--sp-up); +} +[data-status-theme="modern"] .mod-hero.mod-tone-warn .mod-hero-icon { + color: var(--sp-warn); +} +[data-status-theme="modern"] .mod-hero.mod-tone-down .mod-hero-icon { + color: var(--sp-down); +} + +/* --- chart toggle (rounded segmented control) --- */ +[data-status-theme="modern"] .mod-chart-switch-wrap { + display: flex; + justify-content: flex-end; + margin-bottom: 14px; +} +[data-status-theme="modern"] .mod-chart-switch { + display: inline-flex; + border: 1px solid var(--sp-border); + border-radius: 999px; + background: var(--sp-surface); + padding: 3px; + gap: 2px; +} +[data-status-theme="modern"] .mod-chart-switch > button { + border: 0; + background: transparent; + font: inherit; + font-size: 11px; + padding: 6px 16px; + cursor: pointer; + color: var(--sp-text-muted); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + transition: + background 0.2s, + color 0.2s; + border-radius: 999px; +} +[data-status-theme="modern"] .mod-chart-switch > button:hover { + color: var(--sp-text); +} +[data-status-theme="modern"] .mod-chart-switch > button.mod-active { + color: var(--sp-up); + background: var(--sp-up-soft); +} + +/* --- cards: 16px radius, strong shadow, staggered fade-in, hover lift --- */ +[data-status-theme="modern"] .mod-monitor-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 14px; +} +[data-status-theme="modern"] .mod-card { + background: var(--sp-surface); + border: 1px solid var(--sp-border); + border-radius: 16px; + box-shadow: + 0 2px 4px rgba(16, 24, 40, 0.04), + 0 12px 32px rgba(16, 24, 40, 0.06); + overflow: hidden; + transition: + transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), + box-shadow 0.2s; + opacity: 0; + transform: translateY(8px); + animation: mod-fadein 0.5s forwards; +} +[data-status-theme="modern"][data-status-mode="dark"] .mod-card { + box-shadow: + 0 2px 6px rgba(0, 0, 0, 0.5), + 0 20px 40px rgba(0, 0, 0, 0.4); +} +[data-status-theme="modern"] .mod-card:nth-child(1) { + animation-delay: 0.05s; +} +[data-status-theme="modern"] .mod-card:nth-child(2) { + animation-delay: 0.1s; +} +[data-status-theme="modern"] .mod-card:nth-child(3) { + animation-delay: 0.15s; +} +[data-status-theme="modern"] .mod-card:nth-child(4) { + animation-delay: 0.2s; +} +[data-status-theme="modern"] .mod-card:nth-child(5) { + animation-delay: 0.25s; +} +[data-status-theme="modern"] .mod-card:nth-child(n + 6) { + animation-delay: 0.3s; +} +@keyframes mod-fadein { + to { + opacity: 1; + transform: translateY(0); + } +} +[data-status-theme="modern"] .mod-card:hover { + transform: translateY(-2px); + box-shadow: + 0 4px 8px rgba(16, 24, 40, 0.05), + 0 16px 40px rgba(16, 24, 40, 0.08); +} + +[data-status-theme="modern"] .mod-card-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 20px; + padding: 18px 24px; +} +[data-status-theme="modern"] .mod-card-left { + min-width: 0; +} +[data-status-theme="modern"] .mod-monitor-name { + font-weight: 600; + font-size: 15px; + letter-spacing: -0.005em; + color: var(--sp-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-status-theme="modern"] .mod-monitor-meta { + display: flex; + gap: 10px; + align-items: center; + margin-top: 6px; + flex-wrap: wrap; +} +[data-status-theme="modern"] .mod-pill { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--sp-text-muted); + background: var(--sp-bg); + padding: 3px 9px; + border-radius: 999px; + font-weight: 600; +} +[data-status-theme="modern"] .mod-pill-hardware { + color: #2563eb; + background: rgba(37, 99, 235, 0.1); +} +[data-status-theme="modern"][data-status-mode="dark"] .mod-pill-hardware { + color: #60a5fa; + background: rgba(96, 165, 250, 0.15); +} +[data-status-theme="modern"] .mod-monitor-url { + font-size: 12px; + color: var(--sp-text-muted); + font-family: ui-monospace, Menlo, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 280px; +} + +/* --- badge: with inner dot --- */ +[data-status-theme="modern"] .mod-badge { + font-size: 11px; + font-weight: 700; + padding: 5px 12px; + border-radius: 999px; + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} +[data-status-theme="modern"] .mod-badge::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +[data-status-theme="modern"] .mod-badge-up { + background: var(--sp-up-soft); + color: var(--sp-up); +} +[data-status-theme="modern"] .mod-badge-warn { + background: var(--sp-warn-soft); + color: var(--sp-warn); +} +[data-status-theme="modern"] .mod-badge-down { + background: var(--sp-down-soft); + color: var(--sp-down); +} + +/* --- heatmap with gradient cells --- */ +[data-status-theme="modern"] .mod-heatmap { + padding: 0 24px 20px; + display: grid; + grid-template-columns: repeat(25, 1fr); + gap: 3px; + height: 46px; +} +[data-status-theme="modern"] .mod-heatmap-cell { + border-radius: 3px; + background: var(--sp-up); + transition: transform 0.15s; +} +[data-status-theme="modern"] .mod-heatmap-cell:hover { + transform: scaleY(1.2); +} +[data-status-theme="modern"] .mod-heatmap-cell.mod-fast { + background: linear-gradient(180deg, var(--sp-up), var(--sp-up-strong)); +} +[data-status-theme="modern"] .mod-heatmap-cell.mod-med { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--sp-up) 70%, #ffffff 30%), + var(--sp-up) + ); +} +[data-status-theme="modern"] .mod-heatmap-cell.mod-slow { + background: linear-gradient(180deg, var(--sp-warn), var(--sp-degraded)); +} +[data-status-theme="modern"] .mod-heatmap-cell.mod-down { + background: linear-gradient( + 180deg, + var(--sp-down), + color-mix(in srgb, var(--sp-down) 70%, #000000 30%) + ); +} +[data-status-theme="modern"] .mod-heatmap-cell.mod-empty { + background: var(--sp-border); + opacity: 0.4; +} + +/* --- histogram with gradient bars --- */ +[data-status-theme="modern"] .mod-histogram { + padding: 0 24px; + display: grid; + grid-template-columns: repeat(25, 1fr); + gap: 3px; + align-items: flex-end; + height: 46px; +} +[data-status-theme="modern"] .mod-bar { + background: var(--sp-up); + border-radius: 3px; + min-height: 4px; + transition: transform 0.15s; +} +[data-status-theme="modern"] .mod-bar:hover { + transform: scaleY(1.08); +} +[data-status-theme="modern"] .mod-bar.mod-down { + background: var(--sp-down); +} +[data-status-theme="modern"] .mod-bar.mod-empty { + background: var(--sp-border); + opacity: 0.4; +} +[data-status-theme="modern"] .mod-chart-stats { + padding: 0 24px 20px; + font-size: 11px; + color: var(--sp-text-muted); + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +/* --- infra gauges: 3-col (4 if temp present), gradient surfaces --- */ +[data-status-theme="modern"] .mod-infra { + padding: 16px 24px 22px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; +} +@media (max-width: 720px) { + [data-status-theme="modern"] .mod-infra { + grid-template-columns: 1fr; + } +} +[data-status-theme="modern"] .mod-infra-empty { + padding: 16px 24px 22px; + color: var(--sp-text-muted); + font-size: 13px; +} +[data-status-theme="modern"] .mod-gauge { + border: 1px solid var(--sp-border); + border-radius: 14px; + padding: 14px 16px; + background: linear-gradient(135deg, var(--sp-bg), var(--sp-surface)); +} +[data-status-theme="modern"] .mod-gauge-label { + font-size: 10px; + color: var(--sp-text-muted); + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 700; +} +[data-status-theme="modern"] .mod-gauge-value { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.02em; + margin-top: 4px; + font-variant-numeric: tabular-nums; + color: var(--sp-text); +} +[data-status-theme="modern"] .mod-gauge-bar { + height: 6px; + background: var(--sp-border); + border-radius: 3px; + overflow: hidden; + margin-top: 10px; +} +[data-status-theme="modern"] .mod-gauge-fill { + display: block; + height: 100%; + background: linear-gradient(90deg, var(--sp-up), var(--sp-up-strong)); + border-radius: 3px; + transition: width 0.8s cubic-bezier(0.2, 0.8, 0.2, 1); +} +[data-status-theme="modern"] .mod-gauge-fill.mod-warm { + background: linear-gradient(90deg, var(--sp-warn), var(--sp-degraded)); +} +[data-status-theme="modern"] .mod-gauge-fill.mod-hot { + background: linear-gradient( + 90deg, + var(--sp-down), + color-mix(in srgb, var(--sp-down) 70%, #000000 30%) + ); +} +[data-status-theme="modern"] .mod-gauge-sub { + font-size: 11px; + color: var(--sp-text-muted); + margin-top: 8px; + font-variant-numeric: tabular-nums; +} + +/* --- footer --- */ +[data-status-theme="modern"] .mod-footer { + text-align: center; + color: var(--sp-text-muted); + font-size: 12px; + margin-top: 48px; +} +[data-status-theme="modern"] .mod-footer a { + color: var(--sp-up); + text-decoration: underline; + text-underline-offset: 3px; + font-weight: 700; +} +[data-status-theme="modern"] .mod-footer a:hover { + color: var(--sp-up-strong); +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + [data-status-theme="modern"] .mod-card { + animation: none; + opacity: 1; + transform: none; + } + [data-status-theme="modern"] .mod-pulse::before, + [data-status-theme="modern"] .mod-pulse::after { + animation: none; + } +} diff --git a/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx new file mode 100644 index 000000000..5be6eab21 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx @@ -0,0 +1,179 @@ +import { useState } from "react"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTranslation } from "react-i18next"; +import type { Monitor } from "@/Types/Monitor"; +import type { StatusPage } from "@/Types/StatusPage"; +import { getMonitorTypeLabel } from "@/Types/StatusPage"; +import { ThemedHeatmap } from "../shared/ThemedHeatmap"; +import { ThemedHistogram } from "../shared/ThemedHistogram"; +import { ThemedInfrastructure } from "../shared/ThemedInfrastructure"; +import { + monitorBadgeTone, + monoFirstChar, + resolveOverallStatus, + statusBadgeKey, +} from "../shared/overallStatus"; +import "./refined.css"; + +type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; + +interface Props { + statusPage: StatusPage; + monitors: StatusPageMonitor[]; +} + +const PREFIX = "rf"; + +export const RefinedStatusPage = ({ statusPage, monitors }: Props) => { + const { t } = useTranslation(); + const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); + + const overall = resolveOverallStatus(monitors, t); + const logoSrc = statusPage.logo?.data + ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` + : null; + + return ( + + + + {logoSrc ? ( + + ) : ( + + {monoFirstChar(statusPage.companyName)} + + )} + {statusPage.companyName} + + + + + + +

{overall.message}

+

+ {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitors.length, + })} +

+
+ {overall.icon} +
+ + {statusPage.showCharts && ( + + + + + + + )} + + + {monitors.map((monitor) => { + const isHardware = monitor.type === "hardware"; + const showInfra = isHardware && statusPage.showInfrastructure !== false; + const showChart = !isHardware && statusPage.showCharts !== false; + const badgeTone = monitorBadgeTone(monitor.status); + + return ( + + + + {monitor.name} + + + {getMonitorTypeLabel(monitor.type, t)} + + {monitor.url && ( + + {monitor.url} + + )} + + + + {t(statusBadgeKey[monitor.status])} + + + + {showInfra && ( + + )} + {showChart && + (chartMode === "heatmap" ? ( + + ) : ( + + ))} + + ); + })} + + + + {t("pages.statusPages.footer.poweredBy")}{" "} + + Checkmate + + +
+ ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/refined/refined.css b/client/src/Pages/StatusPage/Status/themes/refined/refined.css new file mode 100644 index 000000000..ac1dee862 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/refined/refined.css @@ -0,0 +1,420 @@ +/* + * Refined theme — scoped to [data-status-theme="refined"]. + * Port of /Users/gorkemcetin/Desktop/checkmate-status-mockups/01-refined.html. + * Colour tokens come from the provider's inline CSS vars (see tokens.ts). + */ + +[data-status-theme="refined"]:not([data-status-transparent]) { + /* On the public route the wrapper paints its own bg and fills the + viewport. Inside a BrowserFrame (admin preview) it stays transparent + so the frame's rounded corners don't bleed the wrapper's square + corners past the clip. */ + display: flex; + flex-direction: column; + min-height: 100vh; +} + +[data-status-theme="refined"] .rf-page { + flex: 1 0 auto; + max-width: 960px; + width: 100%; + margin: 0 auto; + padding: 48px 20px 80px; + font: + 14px/1.5 -apple-system, + BlinkMacSystemFont, + "Inter", + "Segoe UI", + Roboto, + sans-serif; + color: var(--sp-text); + -webkit-font-smoothing: antialiased; +} + +/* --- header --- */ +[data-status-theme="refined"] .rf-top { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 28px; +} +[data-status-theme="refined"] .rf-brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--sp-text); +} +[data-status-theme="refined"] .rf-logo-mono { + width: 28px; + height: 28px; + border-radius: 8px; + background: var(--sp-up); + display: grid; + place-items: center; + color: #fff; + font-weight: 700; + font-size: 13px; +} +[data-status-theme="refined"] .rf-logo-img { + max-height: 32px; + max-width: 120px; + object-fit: contain; +} +[data-status-theme="refined"] .rf-company { + font-size: 14px; +} + +/* --- hero status bar --- */ +[data-status-theme="refined"] .rf-hero { + background: var(--sp-surface); + border: 1px solid var(--sp-border); + border-radius: var(--sp-radius); + padding: 22px 24px; + display: flex; + align-items: center; + gap: 16px; + box-shadow: var( + --sp-shadow, + 0 1px 2px rgba(10, 16, 32, 0.06), + 0 6px 18px rgba(10, 16, 32, 0.08) + ); + margin-bottom: 20px; +} +[data-status-theme="refined"] .rf-status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--sp-up); + box-shadow: 0 0 0 4px var(--sp-up-soft); + flex-shrink: 0; +} +[data-status-theme="refined"] .rf-hero.rf-tone-warn .rf-status-dot { + background: var(--sp-warn); + box-shadow: 0 0 0 4px var(--sp-warn-soft); +} +[data-status-theme="refined"] .rf-hero.rf-tone-down .rf-status-dot { + background: var(--sp-down); + box-shadow: 0 0 0 4px var(--sp-down-soft); +} +[data-status-theme="refined"] .rf-status-copy { + flex: 1; + min-width: 0; +} +[data-status-theme="refined"] .rf-hero-title { + margin: 0 0 2px; + font-size: 17px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--sp-text); +} +[data-status-theme="refined"] .rf-hero-sub { + margin: 0; + color: var(--sp-text-muted); + font-size: 13px; +} +[data-status-theme="refined"] .rf-hero-icon { + color: var(--sp-text-muted); + display: flex; + align-items: center; +} +[data-status-theme="refined"] .rf-hero.rf-tone-up .rf-hero-icon { + color: var(--sp-up); +} +[data-status-theme="refined"] .rf-hero.rf-tone-warn .rf-hero-icon { + color: var(--sp-warn); +} +[data-status-theme="refined"] .rf-hero.rf-tone-down .rf-hero-icon { + color: var(--sp-down); +} + +/* --- chart switch (segmented control) --- */ +[data-status-theme="refined"] .rf-chart-switch-wrap { + display: flex; + justify-content: flex-end; + margin-bottom: 12px; +} +[data-status-theme="refined"] .rf-chart-switch { + display: inline-flex; + border: 1px solid var(--sp-border); + border-radius: 8px; + background: var(--sp-surface); + padding: 3px; + gap: 2px; +} +[data-status-theme="refined"] .rf-chart-switch > button { + border: 0; + background: transparent; + font: + inherit, + -apple-system, + sans-serif; + font-size: 11px; + padding: 5px 14px; + cursor: pointer; + color: var(--sp-text-muted); + border-radius: 5px; + transition: + background 0.15s ease, + color 0.15s ease; + font-weight: 500; +} +[data-status-theme="refined"] .rf-chart-switch > button:hover { + color: var(--sp-text); +} +[data-status-theme="refined"] .rf-chart-switch > button.rf-active { + color: var(--sp-up); + background: var(--sp-up-soft); + font-weight: 600; +} + +/* --- monitor card list --- */ +[data-status-theme="refined"] .rf-monitor-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 12px; +} +[data-status-theme="refined"] .rf-card { + background: var(--sp-surface); + border: 1px solid var(--sp-border); + border-radius: var(--sp-radius); + box-shadow: var( + --sp-shadow, + 0 1px 2px rgba(10, 16, 32, 0.06), + 0 6px 18px rgba(10, 16, 32, 0.08) + ); + overflow: hidden; + position: relative; + transition: + transform 0.15s, + box-shadow 0.15s; +} +[data-status-theme="refined"] .rf-card:hover { + transform: translateY(-1px); + box-shadow: + 0 2px 4px rgba(16, 24, 40, 0.06), + 0 10px 24px rgba(16, 24, 40, 0.06); +} + +[data-status-theme="refined"] .rf-card-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 16px; + padding: 16px 20px; +} +[data-status-theme="refined"] .rf-card-left { + min-width: 0; +} +[data-status-theme="refined"] .rf-monitor-name { + font-weight: 600; + font-size: 14px; + letter-spacing: -0.005em; + color: var(--sp-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-status-theme="refined"] .rf-monitor-meta { + display: flex; + gap: 10px; + align-items: center; + margin-top: 4px; + flex-wrap: wrap; +} +[data-status-theme="refined"] .rf-pill { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--sp-text-muted); + border: 1px solid var(--sp-border); + padding: 2px 8px; + border-radius: 999px; + font-weight: 600; +} +[data-status-theme="refined"] .rf-pill-hardware { + color: #1d4ed8; + border-color: rgba(29, 78, 216, 0.3); + background: rgba(29, 78, 216, 0.06); +} +[data-status-theme="refined"][data-status-mode="dark"] .rf-pill-hardware { + color: #60a5fa; + border-color: rgba(96, 165, 250, 0.3); + background: rgba(96, 165, 250, 0.1); +} +[data-status-theme="refined"] .rf-monitor-url { + font-size: 12px; + color: var(--sp-text-muted); + font-family: ui-monospace, Menlo, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 280px; +} + +[data-status-theme="refined"] .rf-badge { + font-size: 11px; + font-weight: 600; + padding: 4px 10px; + border-radius: 999px; + white-space: nowrap; +} +[data-status-theme="refined"] .rf-badge-up { + background: var(--sp-up-soft); + color: var(--sp-up); +} +[data-status-theme="refined"] .rf-badge-warn { + background: var(--sp-warn-soft); + color: var(--sp-warn); +} +[data-status-theme="refined"] .rf-badge-down { + background: var(--sp-down-soft); + color: var(--sp-down); +} + +/* --- heatmap --- */ +[data-status-theme="refined"] .rf-heatmap { + padding: 0 20px 16px; + display: grid; + grid-template-columns: repeat(25, 1fr); + gap: 3px; + height: 42px; +} +[data-status-theme="refined"] .rf-heatmap-cell { + border-radius: 2px; + background: var(--sp-up); + transition: transform 0.15s; +} +[data-status-theme="refined"] .rf-heatmap-cell:hover { + transform: scaleY(1.15); +} +[data-status-theme="refined"] .rf-heatmap-cell.rf-fast { + background: var(--sp-up); +} +[data-status-theme="refined"] .rf-heatmap-cell.rf-med { + background: color-mix(in srgb, var(--sp-up) 60%, #ffffff 40%); +} +[data-status-theme="refined"] .rf-heatmap-cell.rf-slow { + background: var(--sp-warn); +} +[data-status-theme="refined"] .rf-heatmap-cell.rf-down { + background: var(--sp-down); +} +[data-status-theme="refined"] .rf-heatmap-cell.rf-empty { + background: var(--sp-border); + opacity: 0.4; +} + +/* --- histogram --- */ +[data-status-theme="refined"] .rf-histogram { + padding: 0 20px; + display: grid; + grid-template-columns: repeat(25, 1fr); + gap: 3px; + align-items: flex-end; + height: 42px; +} +[data-status-theme="refined"] .rf-bar { + background: var(--sp-up); + border-radius: 2px; + min-height: 3px; +} +[data-status-theme="refined"] .rf-bar.rf-down { + background: var(--sp-down); +} +[data-status-theme="refined"] .rf-bar.rf-empty { + background: var(--sp-border); + opacity: 0.4; +} +[data-status-theme="refined"] .rf-chart-stats { + padding: 0 20px 16px; + font-size: 11px; + color: var(--sp-text-muted); + font-variant-numeric: tabular-nums; +} + +/* --- infrastructure gauges --- */ +[data-status-theme="refined"] .rf-infra { + padding: 14px 20px 18px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} +@media (max-width: 720px) { + [data-status-theme="refined"] .rf-infra { + grid-template-columns: 1fr; + } +} +[data-status-theme="refined"] .rf-infra-empty { + padding: 14px 20px 18px; + color: var(--sp-text-muted); + font-size: 13px; +} +[data-status-theme="refined"] .rf-gauge { + border: 1px solid var(--sp-border); + border-radius: 10px; + padding: 12px 14px; + background: var(--sp-bg); +} +[data-status-theme="refined"] .rf-gauge-label { + font-size: 11px; + color: var(--sp-text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; +} +[data-status-theme="refined"] .rf-gauge-value { + font-size: 20px; + font-weight: 600; + letter-spacing: -0.01em; + margin-top: 2px; + font-variant-numeric: tabular-nums; + color: var(--sp-text); +} +[data-status-theme="refined"] .rf-gauge-bar { + height: 4px; + background: var(--sp-border); + border-radius: 2px; + overflow: hidden; + margin-top: 8px; +} +[data-status-theme="refined"] .rf-gauge-fill { + display: block; + height: 100%; + background: var(--sp-up); + border-radius: 2px; + transition: width 0.6s; +} +[data-status-theme="refined"] .rf-gauge-fill.rf-warm { + background: var(--sp-warn); +} +[data-status-theme="refined"] .rf-gauge-fill.rf-hot { + background: var(--sp-down); +} +[data-status-theme="refined"] .rf-gauge-sub { + font-size: 11px; + color: var(--sp-text-muted); + margin-top: 6px; + font-variant-numeric: tabular-nums; +} + +/* --- footer --- */ +[data-status-theme="refined"] .rf-footer { + text-align: center; + color: var(--sp-text-muted); + font-size: 12px; + margin-top: 40px; +} +[data-status-theme="refined"] .rf-footer a { + color: var(--sp-up); + text-decoration: underline; + text-underline-offset: 3px; + font-weight: 600; +} +[data-status-theme="refined"] .rf-footer a:hover { + color: var(--sp-up-strong, var(--sp-up)); +} diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx new file mode 100644 index 000000000..735f02fa8 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx @@ -0,0 +1,83 @@ +import Tooltip from "@mui/material/Tooltip"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useTranslation } from "react-i18next"; +import type { CheckSnapshot } from "@/Types/Check"; +import { formatCheckTimestamp } from "./timeFormat"; + +const CELLS = 25; + +interface Props { + checks: CheckSnapshot[]; + classPrefix: string; +} + +const classify = (check: CheckSnapshot): "fast" | "med" | "slow" | "down" => { + if (!check.status) return "down"; + const rt = check.responseTime ?? 0; + if (rt > 500) return "slow"; + if (rt > 250) return "med"; + return "fast"; +}; + +export const ThemedHeatmap = ({ checks, classPrefix }: Props) => { + const { t } = useTranslation(); + + const source = checks.slice(0, CELLS).reverse(); + const padded: (CheckSnapshot | null)[] = [ + ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null), + ...source, + ]; + + return ( + + {padded.map((check, i) => { + if (!check) { + return ( + + ); + } + const cls = classify(check); + const tooltipContent = ( + + + {check.status + ? `${check.responseTime} ms` + : t("pages.statusPages.monitorsList.chart.downTooltip")} + + + {formatCheckTimestamp(check.createdAt as unknown as string)} + + + ); + return ( + + + + ); + })} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx new file mode 100644 index 000000000..83a63d01c --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -0,0 +1,99 @@ +import { useMemo } from "react"; +import Tooltip from "@mui/material/Tooltip"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useTranslation } from "react-i18next"; +import type { CheckSnapshot } from "@/Types/Check"; +import { formatCheckTimestamp } from "./timeFormat"; + +const CELLS = 25; +const MIN_HEIGHT_PCT = 6; + +interface Props { + checks: CheckSnapshot[]; + classPrefix: string; + statsGap?: number; +} + +const tone = (check: CheckSnapshot): "up" | "down" => (check.status ? "up" : "down"); + +export const ThemedHistogram = ({ checks, classPrefix, statsGap = 1 }: Props) => { + const { t } = useTranslation(); + + const { padded, max, avg, peak } = useMemo(() => { + const source = checks.slice(0, CELLS).reverse(); + const out: (CheckSnapshot | null)[] = [ + ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null), + ...source, + ]; + const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0); + const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1; + const avgRt = valid.length + ? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length) + : 0; + return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 }; + }, [checks]); + + return ( + + + {padded.map((check, i) => { + if (!check) { + return ( + + ); + } + const height = Math.max( + MIN_HEIGHT_PCT, + Math.round((check.responseTime / max) * 100) + ); + const tooltipContent = ( + + + {check.status + ? `${check.responseTime} ms` + : t("pages.statusPages.monitorsList.chart.downTooltip")} + + + {formatCheckTimestamp(check.createdAt as unknown as string)} + + + ); + return ( + + + + ); + })} + + + {t("pages.statusPages.monitorsList.chart.avg", { value: avg })} + {t("pages.statusPages.monitorsList.chart.max", { value: peak })} + + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx new file mode 100644 index 000000000..f465ece91 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx @@ -0,0 +1,102 @@ +import Box from "@mui/material/Box"; +import prettyBytes from "pretty-bytes"; +import { useTranslation } from "react-i18next"; +import type { Monitor } from "@/Types/Monitor"; + +interface Props { + monitor: Monitor & { checks?: Monitor["recentChecks"] }; + classPrefix: string; +} + +interface Gauge { + key: string; + label: string; + value: number; + sub: string; +} + +const PCT = 100; + +export const ThemedInfrastructure = ({ monitor, classPrefix }: Props) => { + const { t } = useTranslation(); + const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0]; + if (!latest) { + return ( + + {t("pages.statusPages.monitorsList.noData")} + + ); + } + + const gauges: Gauge[] = []; + + if (typeof latest.cpu?.usage_percent === "number") { + const pct = latest.cpu.usage_percent * PCT; + gauges.push({ + key: "cpu", + label: t("pages.statusPages.monitorsList.infrastructure.cpu"), + value: pct, + sub: `${pct.toFixed(0)}%`, + }); + } + + if ( + typeof latest.memory?.usage_percent === "number" && + typeof latest.memory?.used_bytes === "number" && + typeof latest.memory?.total_bytes === "number" + ) { + gauges.push({ + key: "memory", + label: t("pages.statusPages.monitorsList.infrastructure.memory"), + value: latest.memory.usage_percent * PCT, + sub: `${prettyBytes(latest.memory.used_bytes)} / ${prettyBytes(latest.memory.total_bytes)}`, + }); + } + + if (latest.disk && latest.disk.length > 0) { + const disks = latest.disk; + const avg = + (disks.reduce((acc, d) => acc + (d?.usage_percent ?? 0), 0) / disks.length) * PCT; + const used = disks.reduce((acc, d) => acc + (d?.used_bytes ?? 0), 0); + const total = disks.reduce((acc, d) => acc + (d?.total_bytes ?? 0), 0); + gauges.push({ + key: "disk", + label: t("pages.statusPages.monitorsList.infrastructure.disk"), + value: avg, + sub: `${prettyBytes(used)} / ${prettyBytes(total)}`, + }); + } + + if (gauges.length === 0) { + return ( + + {t("pages.statusPages.monitorsList.noData")} + + ); + } + + return ( + + {gauges.map((g) => { + const heatClass = + g.value > 85 ? `${classPrefix}-hot` : g.value > 70 ? `${classPrefix}-warm` : ""; + return ( + + {g.label} + {g.value.toFixed(0)}% + + + + {g.sub} + + ); + })} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/overallStatus.tsx b/client/src/Pages/StatusPage/Status/themes/shared/overallStatus.tsx new file mode 100644 index 000000000..3318cb3f3 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/overallStatus.tsx @@ -0,0 +1,160 @@ +import type { ReactNode } from "react"; +import { + AlertTriangle, + CircleCheck, + CircleX, + Loader, + PauseCircle, + ShieldAlert, + Wrench, +} from "lucide-react"; +import type { Monitor, MonitorStatus } from "@/Types/Monitor"; + +export type OverallTone = "up" | "warn" | "down"; + +export interface OverallStatus { + tone: OverallTone; + message: string; + icon: ReactNode; +} + +type StatusPageMonitor = Pick; + +interface Options { + iconSize?: number; + // Optional override for the `allUp` message key (editorial uses a more + // formal sentence). + allUpKey?: string; +} + +export const resolveOverallStatus = ( + monitors: StatusPageMonitor[], + t: (key: string) => string, + options: Options = {} +): OverallStatus => { + const size = options.iconSize ?? 18; + const allUpMessage = t(options.allUpKey ?? "pages.statusPages.statusBar.allUp"); + + if (monitors.length === 0) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.noMonitors"), + icon: , + }; + } + + const allOf = (...statuses: MonitorStatus[]) => + monitors.every((m) => statuses.includes(m.status)); + const someOf = (...statuses: MonitorStatus[]) => + monitors.some((m) => statuses.includes(m.status)); + const noneOf = (...statuses: MonitorStatus[]) => + monitors.every((m) => !statuses.includes(m.status)); + + if (allOf("up")) { + return { tone: "up", message: allUpMessage, icon: }; + } + if (allOf("breached")) { + return { + tone: "down", + message: t("pages.statusPages.statusBar.allBreached"), + icon: , + }; + } + if (allOf("maintenance")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.allMaintenance"), + icon: , + }; + } + if (allOf("down")) { + return { + tone: "down", + message: t("pages.statusPages.statusBar.allDown"), + icon: , + }; + } + if (allOf("paused")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.allPaused"), + icon: , + }; + } + if (allOf("initializing")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.allInitializing"), + icon: , + }; + } + if (someOf("breached") && someOf("down")) { + return { + tone: "down", + message: t("pages.statusPages.statusBar.breachedAndDown"), + icon: , + }; + } + if (someOf("breached")) { + return { + tone: "down", + message: t("pages.statusPages.statusBar.breached"), + icon: , + }; + } + if (someOf("maintenance") && someOf("down")) { + return { + tone: "down", + message: t("pages.statusPages.statusBar.maintenanceAndDown"), + icon: , + }; + } + if (someOf("maintenance") && noneOf("down")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.maintenance"), + icon: , + }; + } + if (someOf("down")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.degraded"), + icon: , + }; + } + if (someOf("paused")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.partiallyPaused"), + icon: , + }; + } + if (someOf("initializing")) { + return { + tone: "up", + message: t("pages.statusPages.statusBar.initializing"), + icon: , + }; + } + return { + tone: "warn", + message: t("pages.statusPages.statusBar.unknown"), + icon: , + }; +}; + +export const statusBadgeKey: Record = { + up: "pages.statusPages.monitorsList.status.up", + down: "pages.statusPages.monitorsList.status.down", + breached: "pages.statusPages.monitorsList.status.breached", + maintenance: "pages.statusPages.monitorsList.status.maintenance", + paused: "pages.statusPages.monitorsList.status.paused", + initializing: "pages.statusPages.monitorsList.status.initializing", +}; + +export const monoFirstChar = (s?: string): string => + (s?.trim().charAt(0) || "?").toUpperCase(); + +export const monitorBadgeTone = (status: MonitorStatus): OverallTone => + status === "up" ? "up" : status === "down" || status === "breached" ? "down" : "warn"; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/timeFormat.ts b/client/src/Pages/StatusPage/Status/themes/shared/timeFormat.ts new file mode 100644 index 000000000..7e313471c --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/timeFormat.ts @@ -0,0 +1,12 @@ +export const formatCheckTimestamp = (iso?: string): string => { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + month: "short", + day: "numeric", + }); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/theme-overrides.css b/client/src/Pages/StatusPage/Status/themes/theme-overrides.css new file mode 100644 index 000000000..b66257d18 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/theme-overrides.css @@ -0,0 +1,156 @@ +/* + * Scoped styling for the public status page themes. + * Every selector is prefixed with [data-status-theme="..."] so these styles + * only apply inside the StatusPageThemeProvider subtree. + * + * Colour tokens come from CSS custom properties set by the provider + * (see tokens.ts → toCssVars). This file adds structural and surface rules + * that a plain token swap can't express. + */ + +/* --- shared: push tokens onto MUI surfaces inside the themed tree --- */ +[data-status-theme] { + color: var(--sp-text); +} + +[data-status-theme] .sp-card, +[data-status-theme] .MuiBox-root.sp-card { + background-color: var(--sp-surface) !important; + border-color: var(--sp-border) !important; + border-radius: var(--sp-radius) !important; + color: var(--sp-text); +} + +[data-status-theme] .sp-heading { + color: var(--sp-text); +} + +[data-status-theme] .sp-badge { + background-color: var(--sp-up-soft) !important; + color: var(--sp-up) !important; + border-radius: 999px; + padding: 2px 10px; + font-weight: 600; + letter-spacing: 0.04em; +} + +/* Status bar / hero */ +[data-status-theme] .sp-hero { + border-radius: var(--sp-radius); +} + +/* --- refined: soft shadow, small accent on card --- */ +[data-status-theme="refined"] .sp-card { + box-shadow: + 0 1px 2px rgba(10, 16, 32, 0.06), + 0 6px 18px rgba(10, 16, 32, 0.08) !important; +} +[data-status-theme="refined"] .sp-hero-title { + font-weight: 600; + letter-spacing: -0.01em; +} + +/* --- modern: stronger shadow, larger radius, pulse dot + staggered fade --- */ +[data-status-theme="modern"] .sp-card { + box-shadow: + 0 2px 4px rgba(16, 24, 40, 0.04), + 0 12px 32px rgba(16, 24, 40, 0.06) !important; + border-radius: 16px !important; + opacity: 0; + transform: translateY(8px); + animation: sp-card-fadein 0.5s forwards; +} +[data-status-theme="modern"] .sp-card:nth-of-type(1) { + animation-delay: 0.05s; +} +[data-status-theme="modern"] .sp-card:nth-of-type(2) { + animation-delay: 0.1s; +} +[data-status-theme="modern"] .sp-card:nth-of-type(3) { + animation-delay: 0.15s; +} +[data-status-theme="modern"] .sp-card:nth-of-type(4) { + animation-delay: 0.2s; +} +[data-status-theme="modern"] .sp-card:nth-of-type(5) { + animation-delay: 0.25s; +} +[data-status-theme="modern"] .sp-card:nth-of-type(n + 6) { + animation-delay: 0.3s; +} +@keyframes sp-card-fadein { + to { + opacity: 1; + transform: translateY(0); + } +} +[data-status-theme="modern"] .sp-hero-title { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; +} + +/* --- bold: large type, conic-gradient hero accent when no logo --- */ +[data-status-theme="bold"] .sp-hero { + background: + linear-gradient(135deg, var(--sp-up-soft), transparent 60%), var(--sp-surface) !important; + border: 1px solid var(--sp-border) !important; +} +[data-status-theme="bold"] .sp-hero-title { + font-size: 32px; + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.1; + color: var(--sp-text) !important; +} +[data-status-theme="bold"] .sp-card { + border-radius: 18px !important; + border: 1px solid var(--sp-border) !important; +} + +/* --- editorial: serif headings, hairline dividers instead of cards --- */ +[data-status-theme="editorial"] .sp-heading { + font-family: Georgia, "Iowan Old Style", "Palatino Linotype", serif; + letter-spacing: -0.01em; +} +[data-status-theme="editorial"] .sp-card { + background-color: transparent !important; + border: 0 !important; + border-bottom: 1px solid var(--sp-border) !important; + border-radius: 0 !important; + box-shadow: none !important; + padding-left: 0 !important; + padding-right: 0 !important; +} +[data-status-theme="editorial"] .sp-card:last-of-type { + border-bottom: 0 !important; +} +[data-status-theme="editorial"] .sp-hero { + background: transparent !important; + color: var(--sp-text) !important; + border-bottom: 2px solid var(--sp-text) !important; + border-radius: 0 !important; + box-shadow: none !important; +} +[data-status-theme="editorial"] .sp-hero-title { + font-family: Georgia, "Iowan Old Style", "Palatino Linotype", serif; + font-weight: 700; +} +[data-status-theme="editorial"] .sp-badge { + background-color: transparent !important; + color: var(--sp-text-muted) !important; + border: 1px solid var(--sp-border) !important; + border-radius: 0 !important; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.12em; +} + +/* Reduce motion */ +@media (prefers-reduced-motion: reduce) { + [data-status-theme] .sp-card { + animation: none; + opacity: 1; + transform: none; + } +} diff --git a/client/src/Pages/StatusPage/Status/themes/tokens.ts b/client/src/Pages/StatusPage/Status/themes/tokens.ts new file mode 100644 index 000000000..8b0517836 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/tokens.ts @@ -0,0 +1,218 @@ +import type { StatusPageTheme } from "@/Types/StatusPage"; + +export interface StatusPageThemeTokens { + bg: string; + surface: string; + border: string; + text: string; + textMuted: string; + up: string; + upStrong: string; + upSoft: string; + degraded: string; + degradedSoft: string; + down: string; + downSoft: string; + warn: string; + warnSoft: string; + radius: string; + fontFamily?: string; + headingFontFamily?: string; + headingWeight?: number; + headingLetterSpacing?: string; + chartBarRadius?: string; + pulseStatusDot?: boolean; + staggeredCardFadeIn?: boolean; + conicLogo?: boolean; + heroSize?: "default" | "large"; + cardStyle?: "card" | "hairline"; +} + +type ThemeVariants = { light: StatusPageThemeTokens; dark: StatusPageThemeTokens }; + +const refined: ThemeVariants = { + light: { + bg: "#f4f6fa", + surface: "#ffffff", + border: "#dce1ea", + text: "#0a1020", + textMuted: "#4b5768", + up: "#0f8a6d", + upStrong: "#0c7359", + upSoft: "#d6f1e6", + degraded: "#c2630a", + degradedSoft: "#fde7bf", + down: "#d11f2f", + downSoft: "#fdd9dc", + warn: "#e08a0b", + warnSoft: "#fdebc5", + radius: "12px", + }, + dark: { + bg: "#070b11", + surface: "#131a24", + border: "#253142", + text: "#f1f5fb", + textMuted: "#9aa7bd", + up: "#2fd7a2", + upStrong: "#1fb487", + upSoft: "rgba(47,215,162,0.15)", + degraded: "#e69138", + degradedSoft: "rgba(230,145,56,0.18)", + down: "#ff5b6b", + downSoft: "rgba(255,91,107,0.18)", + warn: "#f0a837", + warnSoft: "rgba(240,168,55,0.18)", + radius: "12px", + }, +}; + +const modern: ThemeVariants = { + light: { + bg: "#fafbfc", + surface: "#ffffff", + border: "#eceef2", + text: "#0b1220", + textMuted: "#6b7280", + up: "#10a37f", + upStrong: "#0b8a6a", + upSoft: "#d7f3e9", + degraded: "#d97706", + degradedSoft: "#fef3c7", + down: "#dc2626", + downSoft: "#fee2e2", + warn: "#f59e0b", + warnSoft: "#fef3c7", + radius: "16px", + pulseStatusDot: true, + staggeredCardFadeIn: true, + }, + dark: { + bg: "#0a0d12", + surface: "#10151c", + border: "#1b2430", + text: "#e8edf5", + textMuted: "#8a95a8", + up: "#10a37f", + upStrong: "#0b8a6a", + upSoft: "rgba(16,163,127,0.18)", + degraded: "#d97706", + degradedSoft: "rgba(217,119,6,0.22)", + down: "#dc2626", + downSoft: "rgba(220,38,38,0.22)", + warn: "#f59e0b", + warnSoft: "rgba(245,158,11,0.2)", + radius: "16px", + pulseStatusDot: true, + staggeredCardFadeIn: true, + }, +}; + +const bold: ThemeVariants = { + light: { + bg: "#f5f6f9", + surface: "#ffffff", + border: "#e6e8ee", + text: "#0b1220", + textMuted: "#6b7280", + up: "#10a37f", + upStrong: "#0b8a6a", + upSoft: "rgba(16,163,127,0.12)", + degraded: "#f59e0b", + degradedSoft: "rgba(245,158,11,0.14)", + down: "#f43f5e", + downSoft: "rgba(244,63,94,0.12)", + warn: "#f59e0b", + warnSoft: "rgba(245,158,11,0.14)", + radius: "18px", + heroSize: "large", + conicLogo: true, + }, + dark: { + bg: "#05070a", + surface: "#0c1117", + border: "#1a2330", + text: "#eef2f7", + textMuted: "#7a8699", + up: "#22d3a6", + upStrong: "#10a37f", + upSoft: "rgba(34,211,166,0.14)", + degraded: "#f59e0b", + degradedSoft: "rgba(245,158,11,0.14)", + down: "#f43f5e", + downSoft: "rgba(244,63,94,0.16)", + warn: "#f59e0b", + warnSoft: "rgba(245,158,11,0.14)", + radius: "18px", + heroSize: "large", + conicLogo: true, + }, +}; + +const editorial: ThemeVariants = { + light: { + bg: "#fbfaf7", + surface: "#ffffff", + // Darkened from mockup's #e9e4d8 (~1.3:1 on #fbfaf7) to pass WCAG AA 3:1 for UI borders. + border: "#d4cdb8", + text: "#1a1a1a", + textMuted: "#6b675e", + up: "#2c6a4f", + upStrong: "#205239", + upSoft: "#dfeee4", + degraded: "#a46200", + degradedSoft: "#f7ecd4", + down: "#9a2a2a", + downSoft: "#f5dede", + warn: "#a46200", + warnSoft: "#f7ecd4", + radius: "6px", + headingFontFamily: 'Georgia, "Iowan Old Style", "Palatino Linotype", serif', + cardStyle: "hairline", + }, + dark: { + bg: "#141310", + surface: "#1c1a15", + border: "#3a352b", + text: "#f0ece3", + textMuted: "#9e988a", + up: "#6fbf96", + upStrong: "#4fa37a", + upSoft: "rgba(44,106,79,0.22)", + degraded: "#d79a3b", + degradedSoft: "rgba(164,98,0,0.22)", + down: "#d87a7a", + downSoft: "rgba(154,42,42,0.22)", + warn: "#d79a3b", + warnSoft: "rgba(164,98,0,0.22)", + radius: "6px", + headingFontFamily: 'Georgia, "Iowan Old Style", "Palatino Linotype", serif', + cardStyle: "hairline", + }, +}; + +export const themeTokens: Record = { + refined, + modern, + bold, + editorial, +}; + +export const toCssVars = (tokens: StatusPageThemeTokens): Record => ({ + "--sp-bg": tokens.bg, + "--sp-surface": tokens.surface, + "--sp-border": tokens.border, + "--sp-text": tokens.text, + "--sp-text-muted": tokens.textMuted, + "--sp-up": tokens.up, + "--sp-up-strong": tokens.upStrong, + "--sp-up-soft": tokens.upSoft, + "--sp-degraded": tokens.degraded, + "--sp-degraded-soft": tokens.degradedSoft, + "--sp-down": tokens.down, + "--sp-down-soft": tokens.downSoft, + "--sp-warn": tokens.warn, + "--sp-warn-soft": tokens.warnSoft, + "--sp-radius": tokens.radius, + ...(tokens.chartBarRadius ? { "--sp-chart-bar-radius": tokens.chartBarRadius } : {}), +}); diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts index 6fe641210..6388ccfd9 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -21,6 +21,14 @@ export const getMonitorTypeLabel = ( return key ? t(key) : type; }; +export const STATUS_PAGE_THEMES = ["refined", "modern", "bold", "editorial"] as const; +export type StatusPageTheme = (typeof STATUS_PAGE_THEMES)[number]; +export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined"; + +export const STATUS_PAGE_THEME_MODES = ["auto", "light", "dark"] as const; +export type StatusPageThemeMode = (typeof STATUS_PAGE_THEME_MODES)[number]; +export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto"; + export interface StatusPage { id: string; userId: string; @@ -43,6 +51,8 @@ export interface StatusPage { showAdminLoginLink: boolean; showInfrastructure: boolean; customCSS: string; + theme?: StatusPageTheme; + themeMode?: StatusPageThemeMode; createdAt: string; updatedAt: string; } diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index 43d7198f0..8b41b7780 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage"; export const statusPageSchema = z.object({ companyName: z @@ -25,6 +26,8 @@ export const statusPageSchema = z.object({ showAdminLoginLink: z.boolean(), showInfrastructure: z.boolean(), customCSS: z.string().optional(), + theme: z.enum(STATUS_PAGE_THEMES).optional(), + themeMode: z.enum(STATUS_PAGE_THEME_MODES).optional(), logo: z .object({ data: z.string(), diff --git a/client/src/locales/en.json b/client/src/locales/en.json index eb9a34339..96aaa4045 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1214,6 +1214,20 @@ "chartTypeHeatmap": "Heatmap", "chartTypeHistogram": "Histogram", "noData": "No data available", + "status": { + "up": "Operational", + "down": "Down", + "breached": "Threshold exceeded", + "maintenance": "Maintenance", + "paused": "Paused", + "initializing": "Initializing" + }, + "chart": { + "avg": "avg {{value}}ms", + "max": "max {{value}}ms", + "downTooltip": "Down", + "heatmapAria": "Response time heatmap" + }, "infrastructure": { "title": "Infrastructure", "cpu": "CPU", @@ -1230,6 +1244,8 @@ } }, "statusBar": { + "monitoringSummary": "Monitoring {{count}} service · Updates every 10 seconds", + "monitoringSummary_other": "Monitoring {{count}} services · Updates every 10 seconds", "allUp": "All systems operational", "allBreached": "All systems have exceeded thresholds", "allMaintenance": "All systems are under maintenance", @@ -1261,6 +1277,13 @@ "addMonitor": "Add a monitor to get started" } }, + "footer": { + "poweredBy": "Powered by" + }, + "editorial": { + "reportTitle": "Service Report", + "allUp": "All systems are operating normally." + }, "form": { "access": { "title": "Access", @@ -1318,6 +1341,38 @@ } } }, + "theme": { + "title": "Page theme", + "description": "Choose the visual style for your public status page.", + "label": "Theme", + "previewLight": "Light", + "previewDark": "Dark", + "options": { + "refined": { + "name": "Refined", + "description": "Clean, professional, close to the classic Checkmate look." + }, + "modern": { + "name": "Modern", + "description": "SaaS-grade with a pulsing status dot and softly fading cards." + }, + "bold": { + "name": "Bold", + "description": "Large type and confident spacing. Great for brand-forward pages." + }, + "editorial": { + "name": "Editorial", + "description": "Serif headings and hairline dividers. Distinctive and calm." + } + } + }, + "themeMode": { + "label": "Theme mode", + "description": "Auto follows the visitor's device preference.", + "auto": "Auto (follow visitor's device)", + "light": "Light only", + "dark": "Dark only" + }, "features": { "title": "Features", "description": "Configure what information is displayed on your status page.",