feat(status-page): four selectable themes for the public status page
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.
This commit is contained in:
1 parent
ad964446de
commit
944ee74a1d
28 files changed
+3967
-114
No files matched your search
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Stack spacing={muiTheme.spacing(6)}>
|
||||
<RadioGroup
|
||||
value={theme}
|
||||
onChange={(_, value) => onThemeChange(value as StatusPageTheme)}
|
||||
>
|
||||
<Stack spacing={muiTheme.spacing(4)}>
|
||||
{STATUS_PAGE_THEMES.map((option) => (
|
||||
<RadioWithDescription
|
||||
key={option}
|
||||
value={option}
|
||||
label={t(`pages.statusPages.form.theme.options.${option}.name`)}
|
||||
description={t(
|
||||
`pages.statusPages.form.theme.options.${option}.description`
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
|
||||
<Select
|
||||
fieldLabel={t("pages.statusPages.form.themeMode.label")}
|
||||
value={themeMode}
|
||||
onChange={(e) => onThemeModeChange(e.target.value as StatusPageThemeMode)}
|
||||
>
|
||||
{STATUS_PAGE_THEME_MODES.map((mode) => (
|
||||
<MenuItem
|
||||
key={mode}
|
||||
value={mode}
|
||||
>
|
||||
{t(`pages.statusPages.form.themeMode.${mode}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -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 = () => {
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.theme.title")}
|
||||
subtitle={t("pages.statusPages.form.theme.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="theme"
|
||||
control={control}
|
||||
render={({ field: themeField }) => (
|
||||
<Controller
|
||||
name="themeMode"
|
||||
control={control}
|
||||
render={({ field: modeField }) => (
|
||||
<ThemePicker
|
||||
theme={themeField.value ?? "refined"}
|
||||
themeMode={modeField.value ?? "auto"}
|
||||
onThemeChange={themeField.onChange}
|
||||
onThemeModeChange={modeField.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.features.title")}
|
||||
subtitle={t("pages.statusPages.form.features.description")}
|
||||
|
||||
@@ -37,6 +37,7 @@ export const HeaderStatusPageControls = ({
|
||||
>
|
||||
<Typography
|
||||
variant="h1"
|
||||
className="sp-heading"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
sx={{
|
||||
|
||||
@@ -63,12 +63,14 @@ const MonitorHeader = ({
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
className="sp-heading"
|
||||
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
className="sp-badge"
|
||||
sx={getMonitorBadgeStyles(monitor.type ?? "", theme)}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
@@ -153,6 +155,7 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
|
||||
{monitors.map((monitor) => (
|
||||
<BaseBox
|
||||
key={monitor.id}
|
||||
className="sp-card"
|
||||
padding={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
<MonitorHeader
|
||||
|
||||
@@ -12,26 +12,23 @@ import Typography from "@mui/material/Typography";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material";
|
||||
import type { Theme } from "@mui/material";
|
||||
import type { Monitor, MonitorStatus } from "@/Types/Monitor";
|
||||
|
||||
type SemanticTone = "up" | "down" | "warn" | "info";
|
||||
|
||||
interface StatusDisplay {
|
||||
icon: JSX.Element;
|
||||
msg?: string;
|
||||
color?: string;
|
||||
tone: SemanticTone;
|
||||
}
|
||||
|
||||
const getMonitorStatus = (monitors: Monitor[], theme: Theme, t: Function) => {
|
||||
const monitorsStatus: StatusDisplay = {
|
||||
icon: <AlertTriangle size={24} />,
|
||||
};
|
||||
|
||||
// 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 = <CircleX size={24} />;
|
||||
return monitorsStatus;
|
||||
return {
|
||||
msg: t("pages.statusPages.statusBar.noMonitors"),
|
||||
tone: "warn",
|
||||
icon: <CircleX size={24} />,
|
||||
};
|
||||
}
|
||||
|
||||
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 = <CircleCheck size={24} />;
|
||||
} else if (allOf("breached")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.allBreached");
|
||||
monitorsStatus.color = theme.palette.error.main;
|
||||
monitorsStatus.icon = <ShieldAlert size={24} />;
|
||||
} else if (allOf("maintenance")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.allMaintenance");
|
||||
monitorsStatus.color = theme.palette.warning.main;
|
||||
monitorsStatus.icon = <Wrench size={24} />;
|
||||
} else if (allOf("down")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.allDown");
|
||||
monitorsStatus.color = theme.palette.error.main;
|
||||
monitorsStatus.icon = <CircleX size={24} />;
|
||||
} else if (allOf("paused")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.allPaused");
|
||||
monitorsStatus.color = theme.palette.warning.main;
|
||||
monitorsStatus.icon = <PauseCircle size={24} />;
|
||||
} else if (allOf("initializing")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.allInitializing");
|
||||
monitorsStatus.color = theme.palette.warning.main;
|
||||
monitorsStatus.icon = <Loader size={24} />;
|
||||
|
||||
// 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 = <ShieldAlert size={24} />;
|
||||
} else if (someOf("breached")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.breached");
|
||||
monitorsStatus.color = theme.palette.error.main;
|
||||
monitorsStatus.icon = <ShieldAlert size={24} />;
|
||||
|
||||
// Maintenance combinations
|
||||
} else if (someOf("maintenance") && someOf("down")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.maintenanceAndDown");
|
||||
monitorsStatus.color = theme.palette.error.main;
|
||||
monitorsStatus.icon = <Wrench size={24} />;
|
||||
} else if (someOf("maintenance") && noneOf("down")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.maintenance");
|
||||
monitorsStatus.color = theme.palette.warning.main;
|
||||
monitorsStatus.icon = <Wrench size={24} />;
|
||||
|
||||
// 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 = <AlertTriangle size={24} />;
|
||||
|
||||
// Some Paused
|
||||
} else if (someOf("paused")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.partiallyPaused");
|
||||
monitorsStatus.color = theme.palette.warning.main;
|
||||
monitorsStatus.icon = <PauseCircle size={24} />;
|
||||
|
||||
// Initializing
|
||||
} else if (someOf("initializing")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.initializing");
|
||||
monitorsStatus.color = theme.palette.info.main;
|
||||
monitorsStatus.icon = <Loader size={24} />;
|
||||
} else {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.unknown");
|
||||
monitorsStatus.color = theme.palette.warning.main;
|
||||
return {
|
||||
msg: t("pages.statusPages.statusBar.allUp"),
|
||||
tone: "up",
|
||||
icon: <CircleCheck size={24} />,
|
||||
};
|
||||
}
|
||||
if (allOf("breached")) {
|
||||
return {
|
||||
msg: t("pages.statusPages.statusBar.allBreached"),
|
||||
tone: "down",
|
||||
icon: <ShieldAlert size={24} />,
|
||||
};
|
||||
}
|
||||
if (allOf("maintenance")) {
|
||||
return {
|
||||
msg: t("pages.statusPages.statusBar.allMaintenance"),
|
||||
tone: "warn",
|
||||
icon: <Wrench size={24} />,
|
||||
};
|
||||
}
|
||||
if (allOf("down")) {
|
||||
return {
|
||||
msg: t("pages.statusPages.statusBar.allDown"),
|
||||
tone: "down",
|
||||
icon: <CircleX size={24} />,
|
||||
};
|
||||
}
|
||||
if (allOf("paused")) {
|
||||
return {
|
||||
msg: t("pages.statusPages.statusBar.allPaused"),
|
||||
tone: "warn",
|
||||
icon: <PauseCircle size={24} />,
|
||||
};
|
||||
}
|
||||
if (allOf("initializing")) {
|
||||
return {
|
||||
msg: t("pages.statusPages.statusBar.allInitializing"),
|
||||
tone: "warn",
|
||||
icon: <Loader size={24} />,
|
||||
};
|
||||
}
|
||||
|
||||
return monitorsStatus;
|
||||
// Breached takes highest priority in mixed states
|
||||
if (someOf("breached") && someOf("down")) {
|
||||
return {
|
||||
msg: t("pages.statusPages.statusBar.breachedAndDown"),
|
||||
tone: "down",
|
||||
icon: <ShieldAlert size={24} />,
|
||||
};
|
||||
}
|
||||
if (someOf("breached")) {
|
||||
return {
|
||||
msg: t("pages.statusPages.statusBar.breached"),
|
||||
tone: "down",
|
||||
icon: <ShieldAlert size={24} />,
|
||||
};
|
||||
}
|
||||
if (someOf("maintenance") && someOf("down")) {
|
||||
return {
|
||||
msg: t("pages.statusPages.statusBar.maintenanceAndDown"),
|
||||
tone: "down",
|
||||
icon: <Wrench size={24} />,
|
||||
};
|
||||
}
|
||||
if (someOf("maintenance") && noneOf("down")) {
|
||||
return {
|
||||
msg: t("pages.statusPages.statusBar.maintenance"),
|
||||
tone: "warn",
|
||||
icon: <Wrench size={24} />,
|
||||
};
|
||||
}
|
||||
if (someOf("down")) {
|
||||
return {
|
||||
msg: t("pages.statusPages.statusBar.degraded"),
|
||||
tone: "warn",
|
||||
icon: <AlertTriangle size={24} />,
|
||||
};
|
||||
}
|
||||
if (someOf("paused")) {
|
||||
return {
|
||||
msg: t("pages.statusPages.statusBar.partiallyPaused"),
|
||||
tone: "warn",
|
||||
icon: <PauseCircle size={24} />,
|
||||
};
|
||||
}
|
||||
if (someOf("initializing")) {
|
||||
return {
|
||||
msg: t("pages.statusPages.statusBar.initializing"),
|
||||
tone: "info",
|
||||
icon: <Loader size={24} />,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
msg: t("pages.statusPages.statusBar.unknown"),
|
||||
tone: "warn",
|
||||
icon: <AlertTriangle size={24} />,
|
||||
};
|
||||
};
|
||||
|
||||
const toneToCssVar: Record<SemanticTone, string> = {
|
||||
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 (
|
||||
<Stack
|
||||
@@ -128,11 +162,12 @@ export const StatusBar = ({ monitors }: StatusBarProps) => {
|
||||
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}
|
||||
<Typography>{monitorsStatus.msg}</Typography>
|
||||
<Typography className="sp-hero-title">{monitorsStatus.msg}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -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<StatusPageTheme, React.ComponentType<ThemedRendererProps>>
|
||||
> = {
|
||||
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 (
|
||||
<BasePage
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
sx={sx}
|
||||
breadcrumbOverride={isPublic ? [] : undefined}
|
||||
>
|
||||
<HeaderStatusPageControls
|
||||
isAdmin={isAdmin}
|
||||
statusPage={statusPage}
|
||||
isPublic={isPublic}
|
||||
/>
|
||||
{logoSrc && (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alignSelf={"flex-start"}
|
||||
alt={statusPage.companyName}
|
||||
sx={{
|
||||
maxHeight: 120,
|
||||
maxWidth: "100%",
|
||||
objectFit: "contain",
|
||||
mb: 2,
|
||||
}}
|
||||
const ThemedRenderer = statusPage.theme
|
||||
? THEMED_RENDERERS[statusPage.theme]
|
||||
: undefined;
|
||||
const themedRenderer = ThemedRenderer ? (
|
||||
<ThemedRenderer
|
||||
statusPage={statusPage}
|
||||
monitors={monitors}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (themedRenderer) {
|
||||
// Public route: render directly on the viewport, themed background covers everything.
|
||||
if (isPublic) {
|
||||
return (
|
||||
<StatusPageThemeProvider
|
||||
theme={statusPage.theme}
|
||||
themeMode={statusPage.themeMode}
|
||||
paintBody
|
||||
>
|
||||
{themedRenderer}
|
||||
</StatusPageThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Admin preview: wrap in a mock browser frame inside the admin shell.
|
||||
const publicUrl = `${window.location.origin}/status/public/${statusPage.url}`;
|
||||
return (
|
||||
<BasePage
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
breadcrumbOverride={undefined}
|
||||
sx={{ flex: 1, minHeight: 0 }}
|
||||
>
|
||||
<HeaderStatusPageControls
|
||||
isAdmin={isAdmin}
|
||||
statusPage={statusPage}
|
||||
isPublic={false}
|
||||
/>
|
||||
)}
|
||||
<StatusBar monitors={monitors} />
|
||||
<MonitorsList
|
||||
statusPage={statusPage}
|
||||
monitors={monitors}
|
||||
/>
|
||||
</BasePage>
|
||||
<StatusPageThemeProvider
|
||||
theme={statusPage.theme}
|
||||
themeMode={statusPage.themeMode}
|
||||
transparent
|
||||
>
|
||||
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
|
||||
</StatusPageThemeProvider>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusPageThemeProvider
|
||||
theme={statusPage.theme}
|
||||
themeMode={statusPage.themeMode}
|
||||
>
|
||||
<BasePage
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
sx={sx}
|
||||
breadcrumbOverride={isPublic ? [] : undefined}
|
||||
>
|
||||
<HeaderStatusPageControls
|
||||
isAdmin={isAdmin}
|
||||
statusPage={statusPage}
|
||||
isPublic={isPublic}
|
||||
/>
|
||||
{logoSrc && (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alignSelf={"flex-start"}
|
||||
alt={statusPage.companyName}
|
||||
sx={{
|
||||
maxHeight: 120,
|
||||
maxWidth: "100%",
|
||||
objectFit: "contain",
|
||||
mb: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<StatusBar monitors={monitors} />
|
||||
<MonitorsList
|
||||
statusPage={statusPage}
|
||||
monitors={monitors}
|
||||
/>
|
||||
</BasePage>
|
||||
</StatusPageThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
minHeight: "calc(100vh - 180px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: "12px",
|
||||
overflow: "hidden",
|
||||
border: "1px solid var(--sp-border, " + theme.palette.divider + ")",
|
||||
background: "var(--sp-surface, " + theme.palette.background.paper + ")",
|
||||
color: "var(--sp-text, " + theme.palette.text.primary + ")",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.25,
|
||||
padding: "10px 14px",
|
||||
background:
|
||||
"var(--sp-bg, " +
|
||||
(theme.palette.mode === "dark" ? "#1a1a1a" : "#f1f2f4") +
|
||||
")",
|
||||
borderBottom: "1px solid var(--sp-border, " + theme.palette.divider + ")",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", gap: 0.75 }}>
|
||||
<Box
|
||||
sx={{ width: 12, height: 12, borderRadius: "50%", background: "#ff5f57" }}
|
||||
/>
|
||||
<Box
|
||||
sx={{ width: 12, height: 12, borderRadius: "50%", background: "#febc2e" }}
|
||||
/>
|
||||
<Box
|
||||
sx={{ width: 12, height: 12, borderRadius: "50%", background: "#28c840" }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
padding: "4px 12px",
|
||||
marginLeft: 2,
|
||||
borderRadius: "6px",
|
||||
background:
|
||||
"var(--sp-surface, " +
|
||||
(theme.palette.mode === "dark" ? "#2a2a2a" : "#ffffff") +
|
||||
")",
|
||||
border: "1px solid var(--sp-border, " + theme.palette.divider + ")",
|
||||
fontFamily: "ui-monospace, Menlo, monospace",
|
||||
fontSize: 12,
|
||||
color: "var(--sp-text-muted, " + theme.palette.text.secondary + ")",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{url}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minHeight: 0 }}>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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<StatusPageThemeContextValue | null>(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<ResolvedMode>(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 (
|
||||
<StatusPageThemeContext.Provider value={value}>
|
||||
<div
|
||||
data-status-theme={resolvedTheme}
|
||||
data-status-mode={resolvedMode}
|
||||
data-status-transparent={transparent ? "" : undefined}
|
||||
style={
|
||||
transparent
|
||||
? (style as React.CSSProperties)
|
||||
: {
|
||||
...style,
|
||||
background: tokens.bg,
|
||||
minHeight: "100vh",
|
||||
}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StatusPageThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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 (
|
||||
<Box className={`${PREFIX}-page`}>
|
||||
<Box
|
||||
component="header"
|
||||
className={`${PREFIX}-top`}
|
||||
>
|
||||
<Box className={`${PREFIX}-brand`}>
|
||||
{logoSrc ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
className={`${PREFIX}-logo-img`}
|
||||
/>
|
||||
) : (
|
||||
<Box className={`${PREFIX}-logo-conic`}>
|
||||
{monoFirstChar(statusPage.companyName)}
|
||||
</Box>
|
||||
)}
|
||||
<span className={`${PREFIX}-company`}>{statusPage.companyName}</span>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className={`${PREFIX}-hero ${PREFIX}-tone-${overall.tone}`}>
|
||||
<h1 className={`${PREFIX}-hero-title`}>
|
||||
<span className={`${PREFIX}-hero-check`}>{overall.icon}</span>
|
||||
{overall.message}
|
||||
</h1>
|
||||
<p className={`${PREFIX}-hero-sub`}>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitors.length,
|
||||
})}
|
||||
</p>
|
||||
</Box>
|
||||
|
||||
{statusPage.showCharts && (
|
||||
<Box className={`${PREFIX}-chart-switch-wrap`}>
|
||||
<Box
|
||||
className={`${PREFIX}-chart-switch`}
|
||||
role="radiogroup"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "heatmap"}
|
||||
className={chartMode === "heatmap" ? `${PREFIX}-active` : ""}
|
||||
onClick={() => setChartMode("heatmap")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "histogram"}
|
||||
className={chartMode === "histogram" ? `${PREFIX}-active` : ""}
|
||||
onClick={() => setChartMode("histogram")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||
</button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
component="ul"
|
||||
className={`${PREFIX}-monitor-list`}
|
||||
>
|
||||
{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 (
|
||||
<Box
|
||||
component="li"
|
||||
key={monitor.id}
|
||||
className={`${PREFIX}-card ${PREFIX}-card-${monitor.status}`}
|
||||
>
|
||||
<Box className={`${PREFIX}-card-row`}>
|
||||
<Box className={`${PREFIX}-card-left`}>
|
||||
<Box className={`${PREFIX}-monitor-name`}>{monitor.name}</Box>
|
||||
<Box className={`${PREFIX}-monitor-meta`}>
|
||||
<span
|
||||
className={`${PREFIX}-pill ${PREFIX}-pill-${isHardware ? "hardware" : "default"}`}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
</span>
|
||||
{monitor.url && (
|
||||
<span
|
||||
className={`${PREFIX}-monitor-url`}
|
||||
title={monitor.url}
|
||||
>
|
||||
{monitor.url}
|
||||
</span>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<span className={`${PREFIX}-badge ${PREFIX}-badge-${badgeTone}`}>
|
||||
{t(statusBadgeKey[monitor.status])}
|
||||
</span>
|
||||
</Box>
|
||||
|
||||
{showInfra && (
|
||||
<ThemedInfrastructure
|
||||
monitor={monitor}
|
||||
classPrefix={PREFIX}
|
||||
/>
|
||||
)}
|
||||
{showChart &&
|
||||
(chartMode === "heatmap" ? (
|
||||
<ThemedHeatmap
|
||||
checks={monitor.recentChecks ?? []}
|
||||
classPrefix={PREFIX}
|
||||
/>
|
||||
) : (
|
||||
<ThemedHistogram
|
||||
checks={monitor.recentChecks ?? []}
|
||||
classPrefix={PREFIX}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
className={`${PREFIX}-footer`}
|
||||
>
|
||||
{t("pages.statusPages.footer.poweredBy")}{" "}
|
||||
<a
|
||||
href="https://checkmate.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Checkmate
|
||||
</a>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<Box className={`${PREFIX}-page`}>
|
||||
<Box
|
||||
component="header"
|
||||
className={`${PREFIX}-top`}
|
||||
>
|
||||
<Box className={`${PREFIX}-brand-wrap`}>
|
||||
{logoSrc && (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
className={`${PREFIX}-logo-img`}
|
||||
/>
|
||||
)}
|
||||
<h2 className={`${PREFIX}-brand-eyebrow`}>{statusPage.companyName}</h2>
|
||||
<h1 className={`${PREFIX}-brand-title`}>
|
||||
{t("pages.statusPages.editorial.reportTitle")}
|
||||
</h1>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<p className={`${PREFIX}-status-line ${PREFIX}-tone-${overall.tone}`}>
|
||||
<span className={`${PREFIX}-status-dot`} />
|
||||
{overall.message}
|
||||
</p>
|
||||
<p className={`${PREFIX}-dateline`}>
|
||||
{todayLabel} ·{" "}
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitors.length,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{statusPage.showCharts && (
|
||||
<Box className={`${PREFIX}-chart-switch-wrap`}>
|
||||
<Box
|
||||
className={`${PREFIX}-chart-switch`}
|
||||
role="radiogroup"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "heatmap"}
|
||||
className={chartMode === "heatmap" ? `${PREFIX}-active` : ""}
|
||||
onClick={() => setChartMode("heatmap")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "histogram"}
|
||||
className={chartMode === "histogram" ? `${PREFIX}-active` : ""}
|
||||
onClick={() => setChartMode("histogram")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||
</button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
component="ul"
|
||||
className={`${PREFIX}-monitor-list`}
|
||||
>
|
||||
{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 (
|
||||
<Box
|
||||
component="li"
|
||||
key={monitor.id}
|
||||
className={`${PREFIX}-card ${PREFIX}-card-${monitor.status}`}
|
||||
>
|
||||
<Box className={`${PREFIX}-card-row`}>
|
||||
<Box className={`${PREFIX}-card-left`}>
|
||||
<Box className={`${PREFIX}-monitor-name`}>{monitor.name}</Box>
|
||||
<Box className={`${PREFIX}-monitor-meta`}>
|
||||
<span
|
||||
className={`${PREFIX}-pill ${PREFIX}-pill-${isHardware ? "hardware" : "default"}`}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
</span>
|
||||
{monitor.url && (
|
||||
<span
|
||||
className={`${PREFIX}-monitor-url`}
|
||||
title={monitor.url}
|
||||
>
|
||||
{monitor.url}
|
||||
</span>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<span className={`${PREFIX}-badge ${PREFIX}-badge-${badgeTone}`}>
|
||||
{t(statusBadgeKey[monitor.status])}
|
||||
</span>
|
||||
</Box>
|
||||
|
||||
{showInfra && (
|
||||
<ThemedInfrastructure
|
||||
monitor={monitor}
|
||||
classPrefix={PREFIX}
|
||||
/>
|
||||
)}
|
||||
{showChart &&
|
||||
(chartMode === "heatmap" ? (
|
||||
<ThemedHeatmap
|
||||
checks={monitor.recentChecks ?? []}
|
||||
classPrefix={PREFIX}
|
||||
/>
|
||||
) : (
|
||||
<ThemedHistogram
|
||||
checks={monitor.recentChecks ?? []}
|
||||
classPrefix={PREFIX}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
className={`${PREFIX}-footer`}
|
||||
>
|
||||
{t("pages.statusPages.footer.poweredBy")}{" "}
|
||||
<a
|
||||
href="https://checkmate.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Checkmate
|
||||
</a>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<Box className={`${PREFIX}-page`}>
|
||||
<Box
|
||||
component="header"
|
||||
className={`${PREFIX}-top`}
|
||||
>
|
||||
<Box className={`${PREFIX}-brand`}>
|
||||
{logoSrc ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
className={`${PREFIX}-logo-img`}
|
||||
/>
|
||||
) : (
|
||||
<Box className={`${PREFIX}-logo-grad`}>
|
||||
{monoFirstChar(statusPage.companyName)}
|
||||
</Box>
|
||||
)}
|
||||
<span className={`${PREFIX}-company`}>{statusPage.companyName}</span>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className={`${PREFIX}-hero ${PREFIX}-tone-${overall.tone}`}>
|
||||
<Box className={`${PREFIX}-pulse`} />
|
||||
<Box className={`${PREFIX}-status-copy`}>
|
||||
<h1 className={`${PREFIX}-hero-title`}>{overall.message}</h1>
|
||||
<p className={`${PREFIX}-hero-sub`}>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitors.length,
|
||||
})}
|
||||
</p>
|
||||
</Box>
|
||||
<Box className={`${PREFIX}-hero-icon`}>{overall.icon}</Box>
|
||||
</Box>
|
||||
|
||||
{statusPage.showCharts && (
|
||||
<Box className={`${PREFIX}-chart-switch-wrap`}>
|
||||
<Box
|
||||
className={`${PREFIX}-chart-switch`}
|
||||
role="radiogroup"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "heatmap"}
|
||||
className={chartMode === "heatmap" ? `${PREFIX}-active` : ""}
|
||||
onClick={() => setChartMode("heatmap")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "histogram"}
|
||||
className={chartMode === "histogram" ? `${PREFIX}-active` : ""}
|
||||
onClick={() => setChartMode("histogram")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||
</button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
component="ul"
|
||||
className={`${PREFIX}-monitor-list`}
|
||||
>
|
||||
{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 (
|
||||
<Box
|
||||
component="li"
|
||||
key={monitor.id}
|
||||
className={`${PREFIX}-card ${PREFIX}-card-${monitor.status}`}
|
||||
>
|
||||
<Box className={`${PREFIX}-card-row`}>
|
||||
<Box className={`${PREFIX}-card-left`}>
|
||||
<Box className={`${PREFIX}-monitor-name`}>{monitor.name}</Box>
|
||||
<Box className={`${PREFIX}-monitor-meta`}>
|
||||
<span
|
||||
className={`${PREFIX}-pill ${PREFIX}-pill-${isHardware ? "hardware" : "default"}`}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
</span>
|
||||
{monitor.url && (
|
||||
<span
|
||||
className={`${PREFIX}-monitor-url`}
|
||||
title={monitor.url}
|
||||
>
|
||||
{monitor.url}
|
||||
</span>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<span className={`${PREFIX}-badge ${PREFIX}-badge-${badgeTone}`}>
|
||||
{t(statusBadgeKey[monitor.status])}
|
||||
</span>
|
||||
</Box>
|
||||
|
||||
{showInfra && (
|
||||
<ThemedInfrastructure
|
||||
monitor={monitor}
|
||||
classPrefix={PREFIX}
|
||||
/>
|
||||
)}
|
||||
{showChart &&
|
||||
(chartMode === "heatmap" ? (
|
||||
<ThemedHeatmap
|
||||
checks={monitor.recentChecks ?? []}
|
||||
classPrefix={PREFIX}
|
||||
/>
|
||||
) : (
|
||||
<ThemedHistogram
|
||||
checks={monitor.recentChecks ?? []}
|
||||
classPrefix={PREFIX}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
className={`${PREFIX}-footer`}
|
||||
>
|
||||
{t("pages.statusPages.footer.poweredBy")}{" "}
|
||||
<a
|
||||
href="https://checkmate.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Checkmate
|
||||
</a>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<Box className={`${PREFIX}-page`}>
|
||||
<Box
|
||||
component="header"
|
||||
className={`${PREFIX}-top`}
|
||||
>
|
||||
<Box className={`${PREFIX}-brand`}>
|
||||
{logoSrc ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
className={`${PREFIX}-logo-img`}
|
||||
/>
|
||||
) : (
|
||||
<Box className={`${PREFIX}-logo-mono`}>
|
||||
{monoFirstChar(statusPage.companyName)}
|
||||
</Box>
|
||||
)}
|
||||
<span className={`${PREFIX}-company`}>{statusPage.companyName}</span>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className={`${PREFIX}-hero ${PREFIX}-tone-${overall.tone}`}>
|
||||
<Box className={`${PREFIX}-status-dot`} />
|
||||
<Box className={`${PREFIX}-status-copy`}>
|
||||
<h1 className={`${PREFIX}-hero-title`}>{overall.message}</h1>
|
||||
<p className={`${PREFIX}-hero-sub`}>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitors.length,
|
||||
})}
|
||||
</p>
|
||||
</Box>
|
||||
<Box className={`${PREFIX}-hero-icon`}>{overall.icon}</Box>
|
||||
</Box>
|
||||
|
||||
{statusPage.showCharts && (
|
||||
<Box className={`${PREFIX}-chart-switch-wrap`}>
|
||||
<Box
|
||||
className={`${PREFIX}-chart-switch`}
|
||||
role="radiogroup"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "heatmap"}
|
||||
className={chartMode === "heatmap" ? `${PREFIX}-active` : ""}
|
||||
onClick={() => setChartMode("heatmap")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "histogram"}
|
||||
className={chartMode === "histogram" ? `${PREFIX}-active` : ""}
|
||||
onClick={() => setChartMode("histogram")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||
</button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
component="ul"
|
||||
className={`${PREFIX}-monitor-list`}
|
||||
>
|
||||
{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 (
|
||||
<Box
|
||||
component="li"
|
||||
key={monitor.id}
|
||||
className={`${PREFIX}-card ${PREFIX}-card-${monitor.status}`}
|
||||
>
|
||||
<Box className={`${PREFIX}-card-row`}>
|
||||
<Box className={`${PREFIX}-card-left`}>
|
||||
<Box className={`${PREFIX}-monitor-name`}>{monitor.name}</Box>
|
||||
<Box className={`${PREFIX}-monitor-meta`}>
|
||||
<span
|
||||
className={`${PREFIX}-pill ${PREFIX}-pill-${isHardware ? "hardware" : "default"}`}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
</span>
|
||||
{monitor.url && (
|
||||
<span
|
||||
className={`${PREFIX}-monitor-url`}
|
||||
title={monitor.url}
|
||||
>
|
||||
{monitor.url}
|
||||
</span>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<span className={`${PREFIX}-badge ${PREFIX}-badge-${badgeTone}`}>
|
||||
{t(statusBadgeKey[monitor.status])}
|
||||
</span>
|
||||
</Box>
|
||||
|
||||
{showInfra && (
|
||||
<ThemedInfrastructure
|
||||
monitor={monitor}
|
||||
classPrefix={PREFIX}
|
||||
/>
|
||||
)}
|
||||
{showChart &&
|
||||
(chartMode === "heatmap" ? (
|
||||
<ThemedHeatmap
|
||||
checks={monitor.recentChecks ?? []}
|
||||
classPrefix={PREFIX}
|
||||
/>
|
||||
) : (
|
||||
<ThemedHistogram
|
||||
checks={monitor.recentChecks ?? []}
|
||||
classPrefix={PREFIX}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
className={`${PREFIX}-footer`}
|
||||
>
|
||||
{t("pages.statusPages.footer.poweredBy")}{" "}
|
||||
<a
|
||||
href="https://checkmate.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Checkmate
|
||||
</a>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 (
|
||||
<Box
|
||||
className={`${classPrefix}-heatmap`}
|
||||
role="img"
|
||||
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
|
||||
>
|
||||
{padded.map((check, i) => {
|
||||
if (!check) {
|
||||
return (
|
||||
<Box
|
||||
key={`empty-${i}`}
|
||||
className={`${classPrefix}-heatmap-cell ${classPrefix}-empty`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const cls = classify(check);
|
||||
const tooltipContent = (
|
||||
<Stack gap={0.25}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
>
|
||||
{check.status
|
||||
? `${check.responseTime} ms`
|
||||
: t("pages.statusPages.monitorsList.chart.downTooltip")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{formatCheckTimestamp(check.createdAt as unknown as string)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
key={check.id ?? i}
|
||||
title={tooltipContent}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
className={`${classPrefix}-heatmap-cell ${classPrefix}-${cls}`}
|
||||
aria-label={`${check.responseTime} ms, ${check.status ? "up" : "down"}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Stack gap={statsGap}>
|
||||
<Box className={`${classPrefix}-histogram`}>
|
||||
{padded.map((check, i) => {
|
||||
if (!check) {
|
||||
return (
|
||||
<Box
|
||||
key={`empty-${i}`}
|
||||
className={`${classPrefix}-bar ${classPrefix}-empty`}
|
||||
style={{ height: `${MIN_HEIGHT_PCT}%` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const height = Math.max(
|
||||
MIN_HEIGHT_PCT,
|
||||
Math.round((check.responseTime / max) * 100)
|
||||
);
|
||||
const tooltipContent = (
|
||||
<Stack gap={0.25}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
>
|
||||
{check.status
|
||||
? `${check.responseTime} ms`
|
||||
: t("pages.statusPages.monitorsList.chart.downTooltip")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{formatCheckTimestamp(check.createdAt as unknown as string)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
key={check.id ?? i}
|
||||
title={tooltipContent}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
className={`${classPrefix}-bar ${classPrefix}-${tone(check)}`}
|
||||
style={{ height: `${height}%` }}
|
||||
aria-label={`${check.responseTime} ms`}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
className={`${classPrefix}-chart-stats`}
|
||||
>
|
||||
<span>{t("pages.statusPages.monitorsList.chart.avg", { value: avg })}</span>
|
||||
<span>{t("pages.statusPages.monitorsList.chart.max", { value: peak })}</span>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Box className={`${classPrefix}-infra-empty`}>
|
||||
{t("pages.statusPages.monitorsList.noData")}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box className={`${classPrefix}-infra-empty`}>
|
||||
{t("pages.statusPages.monitorsList.noData")}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={`${classPrefix}-infra`}>
|
||||
{gauges.map((g) => {
|
||||
const heatClass =
|
||||
g.value > 85 ? `${classPrefix}-hot` : g.value > 70 ? `${classPrefix}-warm` : "";
|
||||
return (
|
||||
<Box
|
||||
key={g.key}
|
||||
className={`${classPrefix}-gauge`}
|
||||
>
|
||||
<Box className={`${classPrefix}-gauge-label`}>{g.label}</Box>
|
||||
<Box className={`${classPrefix}-gauge-value`}>{g.value.toFixed(0)}%</Box>
|
||||
<Box className={`${classPrefix}-gauge-bar`}>
|
||||
<Box
|
||||
className={`${classPrefix}-gauge-fill ${heatClass}`}
|
||||
style={{ width: `${Math.min(100, g.value)}%` }}
|
||||
/>
|
||||
</Box>
|
||||
<Box className={`${classPrefix}-gauge-sub`}>{g.sub}</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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<Monitor, "status">;
|
||||
|
||||
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: <CircleX size={size} />,
|
||||
};
|
||||
}
|
||||
|
||||
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: <CircleCheck size={size} /> };
|
||||
}
|
||||
if (allOf("breached")) {
|
||||
return {
|
||||
tone: "down",
|
||||
message: t("pages.statusPages.statusBar.allBreached"),
|
||||
icon: <ShieldAlert size={size} />,
|
||||
};
|
||||
}
|
||||
if (allOf("maintenance")) {
|
||||
return {
|
||||
tone: "warn",
|
||||
message: t("pages.statusPages.statusBar.allMaintenance"),
|
||||
icon: <Wrench size={size} />,
|
||||
};
|
||||
}
|
||||
if (allOf("down")) {
|
||||
return {
|
||||
tone: "down",
|
||||
message: t("pages.statusPages.statusBar.allDown"),
|
||||
icon: <CircleX size={size} />,
|
||||
};
|
||||
}
|
||||
if (allOf("paused")) {
|
||||
return {
|
||||
tone: "warn",
|
||||
message: t("pages.statusPages.statusBar.allPaused"),
|
||||
icon: <PauseCircle size={size} />,
|
||||
};
|
||||
}
|
||||
if (allOf("initializing")) {
|
||||
return {
|
||||
tone: "warn",
|
||||
message: t("pages.statusPages.statusBar.allInitializing"),
|
||||
icon: <Loader size={size} />,
|
||||
};
|
||||
}
|
||||
if (someOf("breached") && someOf("down")) {
|
||||
return {
|
||||
tone: "down",
|
||||
message: t("pages.statusPages.statusBar.breachedAndDown"),
|
||||
icon: <ShieldAlert size={size} />,
|
||||
};
|
||||
}
|
||||
if (someOf("breached")) {
|
||||
return {
|
||||
tone: "down",
|
||||
message: t("pages.statusPages.statusBar.breached"),
|
||||
icon: <ShieldAlert size={size} />,
|
||||
};
|
||||
}
|
||||
if (someOf("maintenance") && someOf("down")) {
|
||||
return {
|
||||
tone: "down",
|
||||
message: t("pages.statusPages.statusBar.maintenanceAndDown"),
|
||||
icon: <Wrench size={size} />,
|
||||
};
|
||||
}
|
||||
if (someOf("maintenance") && noneOf("down")) {
|
||||
return {
|
||||
tone: "warn",
|
||||
message: t("pages.statusPages.statusBar.maintenance"),
|
||||
icon: <Wrench size={size} />,
|
||||
};
|
||||
}
|
||||
if (someOf("down")) {
|
||||
return {
|
||||
tone: "warn",
|
||||
message: t("pages.statusPages.statusBar.degraded"),
|
||||
icon: <AlertTriangle size={size} />,
|
||||
};
|
||||
}
|
||||
if (someOf("paused")) {
|
||||
return {
|
||||
tone: "warn",
|
||||
message: t("pages.statusPages.statusBar.partiallyPaused"),
|
||||
icon: <PauseCircle size={size} />,
|
||||
};
|
||||
}
|
||||
if (someOf("initializing")) {
|
||||
return {
|
||||
tone: "up",
|
||||
message: t("pages.statusPages.statusBar.initializing"),
|
||||
icon: <Loader size={size} />,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tone: "warn",
|
||||
message: t("pages.statusPages.statusBar.unknown"),
|
||||
icon: <AlertTriangle size={size} />,
|
||||
};
|
||||
};
|
||||
|
||||
export const statusBadgeKey: Record<MonitorStatus, string> = {
|
||||
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";
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<StatusPageTheme, ThemeVariants> = {
|
||||
refined,
|
||||
modern,
|
||||
bold,
|
||||
editorial,
|
||||
};
|
||||
|
||||
export const toCssVars = (tokens: StatusPageThemeTokens): Record<string, string> => ({
|
||||
"--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 } : {}),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in new issue
Block a user