diff --git a/CLAUDE.md b/CLAUDE.md index 244dcfe8f..c5f16d3a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,13 +12,15 @@ Checkmate is an open-source uptime and infrastructure monitoring application. It ```bash cd client npm install -npm run dev # Start dev server at http://localhost:5173 +npm run dev -- --port 10001 --strictPort # Local dev port is 10001 (5173 is used by another project on this machine) npm run build # TypeScript check + production build npm run lint # ESLint (strict, max-warnings 0) npm run format # Prettier formatting npm run format-check # Check formatting ``` +Server `.env` on this machine is configured with `CLIENT_HOST="http://localhost:10001"` to match the client dev port. If you change the client port, keep `.env` in sync. + ### Server (Node.js/Express) ```bash cd server @@ -157,6 +159,14 @@ When working on anything related to check scheduling, incident lifecycle, or not ## Code Conventions +### Frontend conventions (mandatory for `client/src`) +Read `docs/frontend-conventions.md` before touching any `.tsx` file. Five rules, all enforced in code review: +1. Prefer MUI native props over `sx` (e.g. `color={…}`, `bgcolor={…}`, `mt={…}` — not `sx={{ color, bgcolor, mt }}`). +2. Use the full theme path for colors: `color={theme.palette.text.secondary}`, never `color="text.secondary"` (greppability). +3. No hardcoded literals — use `LAYOUT.*`, `typographyLevels.*`, `theme.shape.borderRadius`, `theme.palette.*`. +4. Use `useTheme()` inside components; don't `import { theme } from "@/Utils/Theme/Theme"`. +5. Pair runtime tuples with derived types: `const X = [...] as const; type X = (typeof X)[number]`. + ### Internationalization All user-facing strings must use the translation function: ```javascript diff --git a/client/.eslintrc.cjs b/client/.eslintrc.cjs index 86d523a6f..f8bd3d31f 100644 --- a/client/.eslintrc.cjs +++ b/client/.eslintrc.cjs @@ -1,3 +1,104 @@ +// MUI sx keys that have a native (system) prop equivalent. +// Flagged when found inside an sx={{ ... }} object literal — see frontend-conventions.md rule #1. +const SX_NATIVE_EQUIVALENT_KEYS = [ + "color", + "bgcolor", + "backgroundColor", + "borderRadius", + "border", + "borderTop", + "borderBottom", + "borderLeft", + "borderRight", + "width", + "height", + "minHeight", + "maxWidth", + "minWidth", + "maxHeight", + "p", + "pt", + "pb", + "px", + "py", + "pl", + "pr", + "m", + "mt", + "mb", + "mx", + "my", + "ml", + "mr", + "display", + "textAlign", + "fontWeight", + "fontSize", + "lineHeight", + "gap", +]; + +// MUI components that accept system props as top-level attributes. +// sx={{ color, p, mt, ... }} on these can be hoisted into native props. +// Dialog*, Drawer*, Menu*, Tab*, Toolbar etc. only accept sx — don't flag those. +const SX_HOISTABLE_COMPONENTS = [ + "Box", + "Stack", + "Typography", + "Grid", + "Container", + "Paper", + "Card", + "CardContent", + "CardActions", + "CardHeader", + "Divider", + "Link", + "Chip", +]; + +const sxKeySelector = SX_HOISTABLE_COMPONENTS.flatMap((component) => + SX_NATIVE_EQUIVALENT_KEYS.map( + (k) => + `JSXOpeningElement[name.name='${component}'] > JSXAttribute[name.name='sx'] > JSXExpressionContainer > ObjectExpression > Property[key.name='${k}']` + ) +).join(", "); + +const FRONTEND_CONVENTION_RULES = { + // Rule #2 — color/bgcolor/borderColor as a string shorthand like "text.secondary". + // Catches: — should be color={theme.palette.text.secondary}. + "no-restricted-syntax": [ + "warn", + { + selector: + "JSXAttribute[name.name=/^(color|bgcolor|borderColor)$/] > Literal[value=/\\./]", + message: + 'Use the full theme path instead of a string shorthand. e.g. color={theme.palette.text.secondary} not color="text.secondary". See docs/frontend-conventions.md rule #2.', + }, + { + // Rule #1 — sx={{ key: ... }} where key has a native prop equivalent. + selector: sxKeySelector, + message: + "Hoist this key out of `sx` onto the component as a native MUI prop (e.g. color={...}, mt={...}, width={...}). See docs/frontend-conventions.md rule #1.", + }, + { + // Rule #4 — importing the singleton theme from the Theme module. + // Catches: import { theme } from "@/Utils/Theme/Theme" — should be useTheme() inside the component. + selector: + "ImportDeclaration[source.value='@/Utils/Theme/Theme'] > ImportSpecifier[imported.name='theme']", + message: + "Don't import { theme } from @/Utils/Theme/Theme — use the useTheme() hook inside the component. See docs/frontend-conventions.md rule #4.", + }, + { + // Rule #3 (subset) — Typography fontSize={13} is the body default; drop it. + selector: + "JSXOpeningElement[name.name='Typography'] JSXAttribute[name.name='fontSize'] > JSXExpressionContainer > Literal[value=13]", + message: + "fontSize={13} is the Typography body default — drop the prop. For other sizes use typographyLevels tokens. See docs/frontend-conventions.md rule #3.", + }, + ], +}; + module.exports = { root: true, env: { browser: true, es2020: true }, @@ -15,9 +116,43 @@ module.exports = { "react/jsx-no-target-blank": "off", "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react/no-unescaped-entities": "off", + ...FRONTEND_CONVENTION_RULES, }, globals: { __APP_VERSION__: "readonly", process: "readonly", }, + overrides: [ + { + files: ["**/*.ts", "**/*.tsx"], + parser: "@typescript-eslint/parser", + parserOptions: { ecmaVersion: "latest", sourceType: "module" }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + ], + plugins: ["@typescript-eslint", "react-refresh"], + rules: { + "react/jsx-no-target-blank": "off", + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "react/no-unescaped-entities": "off", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + // TS handles these; eslint duplicates create noise on existing files. + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/no-non-null-assertion": "off", + ...FRONTEND_CONVENTION_RULES, + }, + }, + ], }; diff --git a/client/package-lock.json b/client/package-lock.json index 850a2b149..30368ec25 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -49,6 +49,8 @@ "@types/node": "24.5.2", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "^4.2.1", "eslint": "^8.57.0", "eslint-plugin-react": "^7.34.1", @@ -2350,6 +2352,238 @@ "version": "0.0.6", "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "dev": true, @@ -2557,6 +2791,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "dev": true, @@ -3272,6 +3516,19 @@ "node": ">=6" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "3.0.0", "dev": true, @@ -3814,6 +4071,36 @@ "node": ">=6.0.0" } }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "dev": true, @@ -4170,6 +4457,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "license": "MIT", @@ -5137,6 +5445,16 @@ "node": ">= 0.4" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -6393,6 +6711,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", @@ -6793,6 +7121,19 @@ "node": ">=8.0" } }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "license": "0BSD" diff --git a/client/package.json b/client/package.json index a09e89c4c..0400b5c00 100644 --- a/client/package.json +++ b/client/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "build-dev": "tsc -b && vite build --mode development", - "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "lint": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives", + "lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", "format": "prettier --write .", "format-check": "prettier --check ." @@ -57,6 +58,8 @@ "@types/node": "24.5.2", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "^4.2.1", "eslint": "^8.57.0", "eslint-plugin-react": "^7.34.1", diff --git a/client/src/Hooks/useStatusPageForm.ts b/client/src/Hooks/useStatusPageForm.ts index 2c1adf301..8bec86f9b 100644 --- a/client/src/Hooks/useStatusPageForm.ts +++ b/client/src/Hooks/useStatusPageForm.ts @@ -1,6 +1,10 @@ import { useMemo } from "react"; import { statusPageSchema, type StatusPageFormData } from "@/Validation/statusPage"; import type { StatusPage } from "@/Types/StatusPage"; +import { + DEFAULT_STATUS_PAGE_THEME, + DEFAULT_STATUS_PAGE_THEME_MODE, +} from "@/Types/StatusPage"; import type { Monitor } from "@/Types/Monitor"; interface UseStatusPageFormOptions { @@ -36,6 +40,8 @@ export const useStatusPageForm = ({ showAdminLoginLink: data?.showAdminLoginLink ?? false, showInfrastructure: data?.showInfrastructure ?? false, customCSS: data?.customCSS || "", + theme: data?.theme ?? DEFAULT_STATUS_PAGE_THEME, + themeMode: data?.themeMode ?? DEFAULT_STATUS_PAGE_THEME_MODE, logo: transformLogo(data?.logo), }; diff --git a/client/src/Pages/Incidents/Components/CardDetails.tsx b/client/src/Pages/Incidents/Components/CardDetails.tsx index 82ececaa8..b6c1288dd 100644 --- a/client/src/Pages/Incidents/Components/CardDetails.tsx +++ b/client/src/Pages/Incidents/Components/CardDetails.tsx @@ -13,7 +13,6 @@ import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { getIncidentsDuration } from "@/Pages/Incidents/utils"; -import { theme } from "@/Utils/Theme/Theme"; interface CardDetailsProps { incident: Incident | null; diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 1a9724916..fc3fceceb 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -974,7 +974,13 @@ export const SettingsPage = () => { setIsDemoMonitorsDialogOpen(false); }} loading={isDeletingAllMonitors} - /> + confirmColor="error" + confirmText={t("common.buttons.removeMonitors")} + > + + {t("pages.settings.form.removeMonitors.dialog.paragraph")} + + void; + onThemeModeChange: (value: StatusPageThemeMode) => void; +} + +export const ThemePicker = ({ + theme, + themeMode, + onThemeChange, + onThemeModeChange, +}: Props) => { + const { t } = useTranslation(); + const muiTheme = useTheme(); + + return ( + + onThemeChange(value as StatusPageTheme)} + > + + {STATUS_PAGE_THEMES.map((option) => ( + + ))} + + + + + + ); +}; diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index f09d9e47d..d972a90c7 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -34,6 +34,7 @@ import timezones from "@/Utils/timezones.json"; import { useNavigate, useParams } from "react-router-dom"; import axios from "axios"; import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls"; +import { ThemePicker } from "./Components/ThemePicker"; const monitorsUrl = (() => { const params = new URLSearchParams(); @@ -139,6 +140,8 @@ const CreateStatusPage = () => { fd.append("showUptimePercentage", String(data.showUptimePercentage)); fd.append("showAdminLoginLink", String(data.showAdminLoginLink)); fd.append("showInfrastructure", String(data.showInfrastructure)); + if (data.theme) fd.append("theme", data.theme); + if (data.themeMode) fd.append("themeMode", data.themeMode); data.monitors.forEach((monitorId) => { fd.append("monitors[]", monitorId); @@ -438,6 +441,30 @@ const CreateStatusPage = () => { } /> + ( + ( + + )} + /> + )} + /> + } + /> { window.open( - `/status/public/${statusPage.url}`, + `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`, "_blank", "noopener,noreferrer" ); diff --git a/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx b/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx deleted file mode 100644 index 32a15d64b..000000000 --- a/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import { Gauge } from "@/Components/design-elements"; -import { useTheme } from "@mui/material/styles"; -import { useTranslation } from "react-i18next"; -import prettyBytes from "pretty-bytes"; -import type { Monitor } from "@/Types/Monitor"; -import Grid from "@mui/material/Grid"; -import useMediaQuery from "@mui/material/useMediaQuery"; -import Box from "@mui/material/Box"; -import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; - -const GAUGE_RADIUS = 60; -const GAUGE_STROKE_WIDTH = 12; -const PERCENTAGE_MULTIPLIER = 100; - -interface StatusPageMonitor extends Monitor { - checks?: Monitor["recentChecks"]; -} - -interface MetricDetail { - label: string; - value: string; -} - -interface MetricConfig { - key: string; - label: string; - hasData: boolean; - progress: number; - details: MetricDetail[]; -} - -const MetricDetailRow = ({ label, value }: MetricDetail) => { - const theme = useTheme(); - return ( - - - {label} - - {value} - - ); -}; - -interface MetricItemProps { - label: string; - progress: number; - details?: MetricDetail[]; -} - -const MetricItem = ({ label, progress, details }: MetricItemProps) => { - const theme = useTheme(); - const isSmall = useMediaQuery(theme.breakpoints.down("md")); - return ( - - - - {label} - - {details && details.length > 0 && ( - - {details.map((detail) => ( - - ))} - - )} - - ); -}; - -type LatestCheck = NonNullable[number]; - -const buildCpuMetric = ( - check: LatestCheck, - t: (key: string) => string -): MetricConfig | null => { - if (!check.cpu || typeof check.cpu.usage_percent !== "number") { - return null; - } - const usagePercent = (check.cpu.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER; - return { - key: "cpu", - label: t("pages.statusPages.monitorsList.infrastructure.cpu"), - hasData: true, - progress: usagePercent, - details: [ - { - label: t("pages.statusPages.monitorsList.infrastructure.usage"), - value: `${usagePercent.toFixed(2)}%`, - }, - ], - }; -}; - -const buildMemoryMetric = ( - check: LatestCheck, - t: (key: string) => string -): MetricConfig | null => { - if ( - !check.memory || - typeof check.memory.usage_percent !== "number" || - typeof check.memory.used_bytes !== "number" || - typeof check.memory.total_bytes !== "number" - ) { - return null; - } - return { - key: "memory", - label: t("pages.statusPages.monitorsList.infrastructure.memory"), - hasData: true, - progress: (check.memory.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER, - details: [ - { - label: t("pages.statusPages.monitorsList.infrastructure.used"), - value: prettyBytes(check.memory.used_bytes ?? 0), - }, - { - label: t("pages.statusPages.monitorsList.infrastructure.total"), - value: prettyBytes(check.memory.total_bytes ?? 0), - }, - ], - }; -}; - -const buildDiskMetric = ( - check: LatestCheck, - t: (key: string) => string -): MetricConfig | null => { - if (!check.disk || check.disk.length === 0) { - return null; - } - const disks = check.disk; - const avgUsagePercent = - (disks.reduce((acc, disk) => acc + (disk?.usage_percent ?? 0), 0) / disks.length) * - PERCENTAGE_MULTIPLIER; - const totalUsedBytes = disks.reduce((acc, disk) => acc + (disk?.used_bytes ?? 0), 0); - const totalTotalBytes = disks.reduce((acc, disk) => acc + (disk?.total_bytes ?? 0), 0); - - return { - key: "disk", - label: t("pages.statusPages.monitorsList.infrastructure.disk"), - hasData: true, - progress: avgUsagePercent, - details: [ - { - label: t("pages.statusPages.monitorsList.infrastructure.used"), - value: prettyBytes(totalUsedBytes), - }, - { - label: t("pages.statusPages.monitorsList.infrastructure.total"), - value: prettyBytes(totalTotalBytes), - }, - ], - }; -}; - -export const InfrastructureMetrics = ({ monitor }: { monitor: StatusPageMonitor }) => { - const theme = useTheme(); - const { t } = useTranslation(); - - const latestCheck = monitor.recentChecks?.[0] ?? monitor.checks?.[0]; - - if (!latestCheck) { - return ( - - {t("pages.statusPages.monitorsList.noData")} - - ); - } - - const metrics: MetricConfig[] = [ - buildCpuMetric(latestCheck, t), - buildMemoryMetric(latestCheck, t), - buildDiskMetric(latestCheck, t), - ].filter((m): m is MetricConfig => m !== null); - - if (metrics.length === 0) { - return ( - - {t("pages.statusPages.monitorsList.noData")} - - ); - } - - return ( - - {metrics.map(({ key, label, progress, details }) => ( - - ))} - - ); -}; diff --git a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx deleted file mode 100644 index 248a69c9b..000000000 --- a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import Stack from "@mui/material/Stack"; -import { useTranslation } from "react-i18next"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common"; -import { StatusLabel, BaseBox } from "@/Components/design-elements"; -import { SwitchComponent } from "@/Components/inputs"; -import { InfrastructureMetrics } from "@/Pages/StatusPage/Status/Components/InfrastructureMetrics"; - -import { useTheme, type Theme } from "@mui/material/styles"; -import { useSelector } from "react-redux"; -import { useState } from "react"; -import type { Monitor } from "@/Types/Monitor"; -import type { StatusPage } from "@/Types/StatusPage"; -import { getMonitorTypeLabel } from "@/Types/StatusPage"; -import type { RootState } from "@/Types/state"; -import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; - -interface StatusPageMonitor extends Monitor { - checks?: Monitor["recentChecks"]; -} - -interface MonitorsListProps { - statusPage: StatusPage; - monitors: StatusPageMonitor[]; -} - -const getMonitorBadgeStyles = (monitorType: string, theme: Theme) => { - const bg = - monitorType === "hardware" ? theme.palette.info.light : theme.palette.success.light; - return { - backgroundColor: bg, - color: theme.palette.background.paper, - padding: `${theme.spacing(SPACING.SM)} ${theme.spacing(SPACING.LG)}`, - borderRadius: theme.shape.borderRadius, - }; -}; - -const MonitorHeader = ({ - monitor, - showURL, -}: { - monitor: StatusPageMonitor; - showURL: boolean; -}) => { - const theme = useTheme(); - const { t } = useTranslation(); - - return ( - - - - - {monitor.name} - - - {getMonitorTypeLabel(monitor.type, t)} - - - {showURL && monitor.url && ( - - {monitor.url} - - )} - - - - ); -}; - -const MonitorContent = ({ - monitor, - statusPage, - chartType, -}: { - monitor: StatusPageMonitor; - statusPage: StatusPage; - chartType: string; -}) => { - const theme = useTheme(); - - if (monitor.type === "hardware") { - if (statusPage.showInfrastructure === false) return null; - return ; - } - - if (statusPage.showCharts === false) return null; - - const checks = monitor.checks?.slice().reverse() ?? []; - return ( - - {chartType === "histogram" ? ( - - ) : ( - - )} - - ); -}; - -export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => { - const theme = useTheme(); - const { t } = useTranslation(); - const showURL = useSelector((state: RootState) => state.ui?.showURL); - const [chartType, setChartType] = useState<"histogram" | "heatmap">("histogram"); - - return ( - - {statusPage.showCharts && ( - - {t("pages.statusPages.monitorsList.chartTypeHeatmap")} - setChartType(e.target.checked ? "histogram" : "heatmap")} - /> - - {t("pages.statusPages.monitorsList.chartTypeHistogram")} - - - )} - - {monitors.map((monitor) => ( - - - - - ))} - - ); -}; diff --git a/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx b/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx deleted file mode 100644 index d2043a7f3..000000000 --- a/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { - AlertTriangle, - CircleCheck, - CircleX, - Loader, - PauseCircle, - ShieldAlert, - Wrench, -} from "lucide-react"; -import Stack from "@mui/material/Stack"; -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"; - -interface StatusDisplay { - icon: JSX.Element; - msg?: string; - color?: string; -} - -const getMonitorStatus = (monitors: Monitor[], theme: Theme, t: Function) => { - const monitorsStatus: StatusDisplay = { - icon: , - }; - - // Handle empty monitors array - if (monitors.length === 0) { - monitorsStatus.msg = t("pages.statusPages.statusBar.noMonitors"); - monitorsStatus.color = theme.palette.warning.main; - monitorsStatus.icon = ; - return monitorsStatus; - } - - 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)); - - // All monitors in a single state - if (allOf("up")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.allUp"); - monitorsStatus.color = theme.palette.success.main; - monitorsStatus.icon = ; - } else if (allOf("breached")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.allBreached"); - monitorsStatus.color = theme.palette.error.main; - monitorsStatus.icon = ; - } else if (allOf("maintenance")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.allMaintenance"); - monitorsStatus.color = theme.palette.warning.main; - monitorsStatus.icon = ; - } else if (allOf("down")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.allDown"); - monitorsStatus.color = theme.palette.error.main; - monitorsStatus.icon = ; - } else if (allOf("paused")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.allPaused"); - monitorsStatus.color = theme.palette.warning.main; - monitorsStatus.icon = ; - } else if (allOf("initializing")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.allInitializing"); - monitorsStatus.color = theme.palette.warning.main; - monitorsStatus.icon = ; - - // Breached takes highest priority in mixed states - } else if (someOf("breached") && someOf("down")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.breachedAndDown"); - monitorsStatus.color = theme.palette.error.main; - monitorsStatus.icon = ; - } else if (someOf("breached")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.breached"); - monitorsStatus.color = theme.palette.error.main; - monitorsStatus.icon = ; - - // Maintenance combinations - } else if (someOf("maintenance") && someOf("down")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.maintenanceAndDown"); - monitorsStatus.color = theme.palette.error.main; - monitorsStatus.icon = ; - } else if (someOf("maintenance") && noneOf("down")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.maintenance"); - monitorsStatus.color = theme.palette.warning.main; - monitorsStatus.icon = ; - - // Degraded (some down, no maintenance/breached) - } else if (someOf("down")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.degraded"); - monitorsStatus.color = theme.palette.warning.main; - monitorsStatus.icon = ; - - // Some Paused - } else if (someOf("paused")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.partiallyPaused"); - monitorsStatus.color = theme.palette.warning.main; - monitorsStatus.icon = ; - - // Initializing - } else if (someOf("initializing")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.initializing"); - monitorsStatus.color = theme.palette.info.main; - monitorsStatus.icon = ; - } else { - monitorsStatus.msg = t("pages.statusPages.statusBar.unknown"); - monitorsStatus.color = theme.palette.warning.main; - } - - return monitorsStatus; -}; - -interface StatusBarProps { - monitors: Monitor[]; -} - -export const StatusBar = ({ monitors }: StatusBarProps) => { - const theme = useTheme(); - const { t } = useTranslation(); - const monitorsStatus = getMonitorStatus(monitors, theme, t); - - return ( - - {monitorsStatus.icon} - {monitorsStatus.msg} - - ); -}; diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 78b7cba41..a3a535ace 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -1,18 +1,43 @@ import { BasePage, BaseFallback } from "@/Components/design-elements"; -import { StatusBar } from "@/Pages/StatusPage/Status/Components/StatusBar"; -import { MonitorsList } from "@/Pages/StatusPage/Status/Components/MonitorsList"; import Typography from "@mui/material/Typography"; import { Link } from "react-router-dom"; import Stack from "@mui/material/Stack"; -import Box from "@mui/material/Box"; -import { useMediaQuery, useTheme } from "@mui/material"; +import { useTheme } from "@mui/material"; 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 { resolveStatusPageTheme } from "@/Types/StatusPage"; +import { + PUBLIC_STATUS_PAGE_PREFIX, + type StatusPage, + type StatusPageResponse, + type 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: Record< + StatusPageTheme, + React.ComponentType +> = { + refined: RefinedStatusPage, + modern: ModernStatusPage, + bold: BoldStatusPage, + editorial: EditorialStatusPage, +}; const StatusPageView = () => { const theme = useTheme(); @@ -20,8 +45,8 @@ const StatusPageView = () => { const { url } = useParams(); const isAdmin = useIsAdmin(); const location = useLocation(); - const isSmall = useMediaQuery(theme.breakpoints.down("md")); - const isPublic = location.pathname.startsWith("/status/public"); + + const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX); const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null; @@ -65,48 +90,47 @@ const StatusPageView = () => { ); } - let sx: React.CSSProperties = {}; + const ThemedRenderer = THEMED_RENDERERS[resolveStatusPageTheme(statusPage.theme)]; + const themedRenderer = ( + + ); + + // Public route: render directly on the viewport, themed background covers everything. if (isPublic) { - sx.paddingTop = theme.spacing(20); - sx.paddingLeft = isSmall ? "5vw" : "20vw"; - sx.paddingRight = isSmall ? "5vw" : "20vw"; + return ( + + {themedRenderer} + + ); } - const logoSrc = statusPage.logo?.data - ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` - : null; - + const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; return ( - {logoSrc && ( - - )} - - + + {themedRenderer} + ); }; diff --git a/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx new file mode 100644 index 000000000..b893aa85c --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from "react"; +import Box from "@mui/material/Box"; +import { useStatusPageTheme } from "./StatusPageThemeProvider"; + +interface Props { + url: string; + children: ReactNode; +} + +export const BrowserFrame = ({ url, children }: Props) => { + const { tokens } = useStatusPageTheme(); + + return ( + + + + + + + + + {url} + + + {children} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx new file mode 100644 index 000000000..2b10b0d9a --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx @@ -0,0 +1,142 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { + DEFAULT_STATUS_PAGE_THEME, + resolveStatusPageTheme, + resolveStatusPageThemeMode, + type StatusPageTheme, + type StatusPageThemeMode, +} from "@/Types/StatusPage"; +import { themeTokens, type StatusPageThemeTokens } from "./tokens"; + +type ResolvedMode = "light" | "dark"; + +interface StatusPageThemeContextValue { + theme: StatusPageTheme; + mode: ResolvedMode; + tokens: StatusPageThemeTokens; +} + +const StatusPageThemeContext = createContext(null); + +const resolveSystemMode = (): ResolvedMode => { + if (typeof window === "undefined" || !window.matchMedia) return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +}; + +interface Props { + theme?: StatusPageTheme; + themeMode?: StatusPageThemeMode; + /** + * When true, paint the document body with the theme background so it + * covers beyond the provider's wrapper (useful on the public route + * where no admin shell sits behind). Defaults to false for admin + * previews that already have an app background. + */ + paintBody?: boolean; + /** + * When true, the provider's wrapper div does NOT paint its own + * background or set a min-height. Use this when the wrapper is + * inside a clipped container (e.g. BrowserFrame) that already sets + * the visible background, so the wrapper's bg doesn't bleed past the + * container's rounded corners. + */ + transparent?: boolean; + children: ReactNode; +} + +export const StatusPageThemeProvider = ({ + theme, + themeMode, + paintBody = false, + transparent = false, + children, +}: Props) => { + const resolvedTheme = resolveStatusPageTheme(theme); + const resolvedThemeMode = resolveStatusPageThemeMode(themeMode); + + const [systemMode, setSystemMode] = useState(resolveSystemMode); + + useEffect(() => { + if (resolvedThemeMode !== "auto") return; + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + // Reconcile on first paint whenever we (re-)enter auto mode — only + // when it disagrees with the stored value, so we don't trigger an + // extra render on the common hot path. + const next: ResolvedMode = mq.matches ? "dark" : "light"; + setSystemMode((prev) => (prev === next ? prev : next)); + const handler = (e: MediaQueryListEvent) => + setSystemMode(e.matches ? "dark" : "light"); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [resolvedThemeMode]); + + useEffect(() => { + if (!paintBody || typeof document === "undefined") return; + const html = document.documentElement; + const body = document.body; + const prev = { + htmlBg: html.style.background, + bodyBg: body.style.background, + bodyMargin: body.style.margin, + htmlColorScheme: html.style.colorScheme, + }; + const resolvedMode = resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode; + const bg = themeTokens[resolvedTheme][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, resolvedTheme, resolvedThemeMode, systemMode]); + + const resolvedMode: ResolvedMode = + resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode; + const tokens = themeTokens[resolvedTheme][resolvedMode]; + + const value = useMemo( + () => ({ theme: resolvedTheme, mode: resolvedMode, tokens }), + [resolvedTheme, resolvedMode, tokens] + ); + + return ( + +
+ {children} +
+
+ ); +}; + +export const useStatusPageTheme = (): StatusPageThemeContextValue => { + const ctx = useContext(StatusPageThemeContext); + if (!ctx) { + return { + theme: DEFAULT_STATUS_PAGE_THEME, + mode: resolveSystemMode(), + tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()], + }; + } + return ctx; +}; diff --git a/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx new file mode 100644 index 000000000..32959a2ed --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx @@ -0,0 +1,207 @@ +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, + monoFirstChar, + resolveOverallStatus, + statusBadgeKey, +} from "../shared/overallStatus"; +import { useStatusPageTheme } from "../StatusPageThemeProvider"; +import { boldStyles } from "./styles"; + +type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; + +interface Props { + statusPage: StatusPage; + monitors: StatusPageMonitor[]; +} + +export const BoldStatusPage = ({ statusPage, monitors }: Props) => { + const { t } = useTranslation(); + const { tokens, mode } = useStatusPageTheme(); + const styles = useMemo(() => boldStyles(tokens, mode === "dark"), [tokens, mode]); + const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); + + const overall = resolveOverallStatus(monitors, t, { iconSize: 18 }); + const logoSrc = statusPage.logo?.data + ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` + : null; + + return ( + + + + {logoSrc ? ( + + ) : ( + {monoFirstChar(statusPage.companyName)} + )} + {statusPage.companyName} + + + + + + + {overall.icon} + + {overall.message} + + + {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitors.length, + })} + + + + {statusPage.showCharts && ( + + + setChartMode("heatmap")} + sx={styles.chartSwitchButton(chartMode === "heatmap")} + > + {t("pages.statusPages.monitorsList.chartTypeHeatmap")} + + setChartMode("histogram")} + sx={styles.chartSwitchButton(chartMode === "histogram")} + > + {t("pages.statusPages.monitorsList.chartTypeHistogram")} + + + + )} + + + {monitors.map((monitor) => { + const isHardware = monitor.type === "hardware"; + const showInfra = isHardware && statusPage.showInfrastructure !== false; + const showChart = !isHardware && statusPage.showCharts !== false; + const badgeTone = monitorBadgeTone(monitor.status); + + return ( + + + + {monitor.name} + + + {getMonitorTypeLabel(monitor.type, t)} + + {monitor.url && ( + + {monitor.url} + + )} + + + + {t(statusBadgeKey[monitor.status])} + + + + {showInfra && ( + + )} + {showChart && + (chartMode === "heatmap" ? ( + + ) : ( + + ))} + + ); + })} + + + + {t("pages.statusPages.footer.poweredBy")}{" "} + + Checkmate + + + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts new file mode 100644 index 000000000..ac2eac9c0 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts @@ -0,0 +1,404 @@ +import type { SxProps, Theme } from "@mui/material/styles"; +import type { StatusPageThemeTokens } from "../tokens"; +import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; +import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks"; + +export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty"; +export type BoldBarKind = "up" | "down" | "empty"; +export type BoldGaugeFill = "ok" | "warm" | "hot"; + +export interface BoldStyles { + page: SxProps; + top: SxProps; + brand: SxProps; + logoConic: SxProps; + logoImg: SxProps; + hero: SxProps; + heroTitle: SxProps; + heroCheck: (tone: OverallTone) => SxProps; + heroSub: SxProps; + chartSwitchWrap: SxProps; + chartSwitch: SxProps; + chartSwitchButton: (active: boolean) => SxProps; + monitorList: SxProps; + card: SxProps; + cardRow: SxProps; + cardLeft: SxProps; + monitorName: SxProps; + monitorMeta: SxProps; + pill: SxProps; + pillHardware: SxProps; + monitorUrl: SxProps; + badge: (tone: OverallTone) => SxProps; + heatmap: SxProps; + heatmapCell: (kind: BoldHeatCell) => SxProps; + histogram: SxProps; + bar: (kind: BoldBarKind, heightPct: number) => SxProps; + chartStats: SxProps; + infra: SxProps; + infraEmpty: SxProps; + gauge: SxProps; + gaugeLabel: SxProps; + gaugeValue: SxProps; + gaugeBar: SxProps; + gaugeFill: (level: BoldGaugeFill, widthPct: number) => SxProps; + gaugeSub: SxProps; + footer: SxProps; +} + +export const boldStyles = ( + tokens: StatusPageThemeTokens, + isDark: boolean +): BoldStyles => { + const heatCellBg: Record = { + fast: tokens.up, + med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`, + slow: tokens.warn, + down: tokens.down, + empty: tokens.border, + }; + const heatCellShadow: Record = { + fast: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`, + med: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`, + slow: `0 0 6px color-mix(in srgb, ${tokens.warn} 20%, transparent)`, + down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`, + empty: "none", + }; + + const barBg: Record = { + up: tokens.up, + down: tokens.down, + empty: tokens.border, + }; + + const gaugeFillBg: Record = { + ok: `linear-gradient(90deg, ${tokens.up}, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%))`, + warm: `linear-gradient(90deg, ${tokens.warn}, #fbbf24)`, + hot: `linear-gradient(90deg, ${tokens.down}, #fb7185)`, + }; + + const heroBgLight = `linear-gradient(135deg, color-mix(in srgb, ${tokens.up} 8%, transparent), transparent 60%), ${tokens.surface}`; + const heroBgDark = `linear-gradient(135deg, color-mix(in srgb, ${tokens.up} 16%, transparent), transparent 60%), ${tokens.surface}`; + + const heroGlowLight = `radial-gradient(400px 200px at 20% 30%, color-mix(in srgb, ${tokens.up} 14%, transparent), transparent 60%), radial-gradient(300px 200px at 90% 80%, color-mix(in srgb, ${tokens.upStrong} 10%, transparent), transparent 60%)`; + const heroGlowDark = `radial-gradient(400px 200px at 20% 30%, color-mix(in srgb, ${tokens.up} 32%, transparent), transparent 60%), radial-gradient(300px 200px at 90% 80%, color-mix(in srgb, ${tokens.upStrong} 22%, transparent), transparent 60%)`; + + return { + page: { + flex: "1 0 auto", + maxWidth: 1040, + width: "100%", + mx: "auto", + p: "56px 24px 96px", + fontFamily: BOLD_SANS_STACK, + fontSize: 14, + lineHeight: 1.55, + color: tokens.text, + WebkitFontSmoothing: "antialiased", + }, + + top: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + mb: "36px", + }, + brand: { + display: "flex", + alignItems: "center", + gap: "14px", + fontWeight: 700, + fontSize: 16, + letterSpacing: "-0.01em", + color: tokens.text, + }, + logoConic: { + width: 36, + height: 36, + borderRadius: "12px", + background: `conic-gradient(from 140deg, ${tokens.up}, ${tokens.upStrong}, #0f766e, ${tokens.up})`, + display: "grid", + placeItems: "center", + color: "#05070a", + fontWeight: 900, + fontSize: 14, + boxShadow: `0 8px 24px color-mix(in srgb, ${tokens.up} 30%, transparent), inset 0 1px 0 rgba(255, 255, 255, 0.2)`, + }, + logoImg: { maxHeight: 36, maxWidth: 140, objectFit: "contain" }, + + hero: { + position: "relative", + borderRadius: "22px", + padding: "36px 36px 32px", + mb: "28px", + background: isDark ? heroBgDark : heroBgLight, + border: `1px solid ${tokens.border}`, + overflow: "hidden", + "&::before": { + content: '""', + position: "absolute", + inset: 0, + background: isDark ? heroGlowDark : heroGlowLight, + pointerEvents: "none", + opacity: 0.8, + zIndex: 0, + ...(isDark ? { mixBlendMode: "screen" } : {}), + }, + "& > *": { position: "relative", zIndex: 1 }, + }, + heroTitle: { + m: 0, + fontSize: 34, + fontWeight: 800, + letterSpacing: "-0.02em", + lineHeight: 1.1, + color: tokens.text, + display: "flex", + alignItems: "center", + gap: "10px", + }, + heroCheck: (tone) => ({ + display: "inline-grid", + placeItems: "center", + width: 32, + height: 32, + borderRadius: "50%", + background: toneColor(tone, tokens), + color: tone === "down" ? "#fff" : "#05070a", + flexShrink: 0, + boxShadow: `0 0 0 6px ${toneSoft(tone, tokens)}`, + }), + heroSub: { mt: "10px", mb: 0, color: tokens.textMuted, fontSize: 14 }, + + chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "16px" }, + chartSwitch: { + display: "inline-flex", + border: `1px solid ${tokens.border}`, + borderRadius: "999px", + background: tokens.surface, + p: "3px", + gap: "2px", + }, + chartSwitchButton: (active) => ({ + border: 0, + background: active ? tokens.upSoft : "transparent", + fontFamily: "inherit", + fontSize: 11, + padding: "8px 18px", + cursor: "pointer", + color: active ? tokens.up : tokens.textMuted, + fontWeight: 700, + textTransform: "uppercase", + letterSpacing: "0.12em", + transition: "background 0.2s, color 0.2s", + borderRadius: "999px", + "&:hover": { color: active ? tokens.up : tokens.text }, + }), + + monitorList: { + listStyle: "none", + m: 0, + p: 0, + display: "flex", + flexDirection: "column", + gap: "14px", + }, + card: { + background: tokens.surface, + border: `1px solid ${tokens.border}`, + borderRadius: "18px", + overflow: "hidden", + transition: "transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), border-color 0.2s", + "&:hover": { + transform: "translateY(-3px)", + borderColor: `color-mix(in srgb, ${tokens.up} 40%, ${tokens.border})`, + }, + }, + cardRow: { + display: "grid", + gridTemplateColumns: "1fr auto", + alignItems: "center", + gap: "24px", + p: "22px 28px", + }, + cardLeft: { minWidth: 0 }, + monitorName: { + fontWeight: 700, + fontSize: 17, + letterSpacing: "-0.01em", + color: tokens.text, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + monitorMeta: { + display: "flex", + gap: "10px", + alignItems: "center", + mt: "6px", + flexWrap: "wrap", + }, + pill: { + fontSize: 10, + textTransform: "uppercase", + letterSpacing: "0.1em", + color: tokens.textMuted, + background: `color-mix(in srgb, ${tokens.surface} 80%, ${tokens.bg})`, + border: `1px solid ${tokens.border}`, + padding: "3px 10px", + borderRadius: "999px", + fontWeight: 700, + }, + pillHardware: { + fontSize: 10, + textTransform: "uppercase", + letterSpacing: "0.1em", + padding: "3px 10px", + borderRadius: "999px", + fontWeight: 700, + color: tokens.up, + borderColor: `color-mix(in srgb, ${tokens.up} 40%, transparent)`, + border: `1px solid color-mix(in srgb, ${tokens.up} 40%, transparent)`, + background: tokens.upSoft, + }, + monitorUrl: { + fontSize: 12, + color: tokens.textMuted, + fontFamily: MONO_STACK, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + maxWidth: 300, + }, + + badge: (tone) => ({ + fontSize: 11, + fontWeight: 800, + padding: "6px 14px", + borderRadius: "999px", + display: "inline-flex", + alignItems: "center", + gap: "7px", + textTransform: "uppercase", + letterSpacing: "0.08em", + whiteSpace: "nowrap", + background: toneSoft(tone, tokens), + color: toneColor(tone, tokens), + "&::before": { + content: '""', + width: 6, + height: 6, + borderRadius: "50%", + background: "currentColor", + boxShadow: "0 0 8px currentColor", + }, + }), + + heatmap: { + padding: "0 28px 22px", + display: "grid", + gridTemplateColumns: "repeat(25, 1fr)", + gap: "3px", + height: 48, + }, + heatmapCell: (kind) => ({ + borderRadius: "3px", + background: heatCellBg[kind], + boxShadow: heatCellShadow[kind], + opacity: kind === "empty" ? 0.4 : 1, + transition: "transform 0.15s", + "&:hover": { transform: "scaleY(1.2)" }, + }), + + histogram: { + padding: "0 28px", + display: "grid", + gridTemplateColumns: "repeat(25, 1fr)", + gap: "3px", + alignItems: "flex-end", + height: 48, + }, + bar: (kind, heightPct) => ({ + background: barBg[kind], + borderRadius: "3px", + minHeight: 4, + opacity: kind === "empty" ? 0.4 : 1, + height: `${heightPct}%`, + }), + chartStats: { + padding: "0 28px 22px", + fontSize: 11, + color: tokens.textMuted, + fontVariantNumeric: "tabular-nums", + fontWeight: 700, + letterSpacing: "0.05em", + }, + + infra: { + padding: "18px 28px 26px", + display: "grid", + gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, + gap: "16px", + }, + infraEmpty: { + padding: "18px 28px 26px", + color: tokens.textMuted, + fontSize: 13, + }, + gauge: { + border: `1px solid ${tokens.border}`, + borderRadius: "16px", + p: "16px 18px", + background: `color-mix(in srgb, ${tokens.surface} 80%, ${tokens.bg})`, + }, + gaugeLabel: { + fontSize: 10, + color: tokens.textMuted, + textTransform: "uppercase", + letterSpacing: "0.12em", + fontWeight: 700, + }, + gaugeValue: { + fontSize: 30, + fontWeight: 800, + letterSpacing: "-0.02em", + mt: "6px", + fontVariantNumeric: "tabular-nums", + color: tokens.text, + }, + gaugeBar: { + height: 6, + background: tokens.border, + borderRadius: "3px", + overflow: "hidden", + mt: "12px", + }, + gaugeFill: (level, widthPct) => ({ + display: "block", + height: "100%", + background: gaugeFillBg[level], + borderRadius: "3px", + transition: "width 0.9s cubic-bezier(0.2, 0.8, 0.2, 1)", + width: `${Math.max(0, Math.min(100, widthPct))}%`, + }), + gaugeSub: { + fontSize: 11, + color: tokens.textMuted, + mt: "10px", + fontVariantNumeric: "tabular-nums", + }, + + footer: { + textAlign: "center", + color: tokens.textMuted, + fontSize: 12, + mt: "52px", + "& a": { + color: tokens.up, + textDecoration: "underline", + textUnderlineOffset: "3px", + fontWeight: 800, + }, + }, + }; +}; diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx new file mode 100644 index 000000000..ee8fa6f99 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx @@ -0,0 +1,224 @@ +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 { useStatusPageTheme } from "../StatusPageThemeProvider"; +import { editorialStyles } from "./styles"; + +type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; + +interface Props { + statusPage: StatusPage; + monitors: StatusPageMonitor[]; +} + +export const EditorialStatusPage = ({ statusPage, monitors }: Props) => { + const { t } = useTranslation(); + const { tokens } = useStatusPageTheme(); + const styles = useMemo(() => editorialStyles(tokens), [tokens]); + const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); + + const todayLabel = useMemo( + () => + new Date().toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }), + [] + ); + + const overall = resolveOverallStatus(monitors, t, { + allUpKey: "pages.statusPages.editorial.allUp", + }); + const logoSrc = statusPage.logo?.data + ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` + : null; + + return ( + + + + {logoSrc && ( + + )} + + {statusPage.companyName} + + + {t("pages.statusPages.editorial.reportTitle")} + + + + + + + {overall.message} + + + {todayLabel} ·{" "} + {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitors.length, + })} + + + {statusPage.showCharts && ( + + + setChartMode("heatmap")} + sx={styles.chartSwitchButton(chartMode === "heatmap")} + > + {t("pages.statusPages.monitorsList.chartTypeHeatmap")} + + setChartMode("histogram")} + sx={styles.chartSwitchButton(chartMode === "histogram")} + > + {t("pages.statusPages.monitorsList.chartTypeHistogram")} + + + + )} + + + {monitors.map((monitor) => { + const isHardware = monitor.type === "hardware"; + const showInfra = isHardware && statusPage.showInfrastructure !== false; + const showChart = !isHardware && statusPage.showCharts !== false; + const badgeTone = monitorBadgeTone(monitor.status); + + return ( + + + + {monitor.name} + + + {getMonitorTypeLabel(monitor.type, t)} + + {monitor.url && ( + + {monitor.url} + + )} + + + + {t(statusBadgeKey[monitor.status])} + + + + {showInfra && ( + + )} + {showChart && + (chartMode === "heatmap" ? ( + + ) : ( + + ))} + + ); + })} + + + + {t("pages.statusPages.footer.poweredBy")}{" "} + + Checkmate + + + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts new file mode 100644 index 000000000..bed3e72aa --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts @@ -0,0 +1,337 @@ +import type { SxProps, Theme } from "@mui/material/styles"; +import type { StatusPageThemeTokens } from "../tokens"; +import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; +import { + EDITORIAL_SECONDARY_SANS_STACK, + MONO_STACK, + SERIF_STACK, +} from "../shared/fontStacks"; + +export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty"; +export type EditorialBarKind = "up" | "down" | "empty"; +export type EditorialGaugeFill = "ok" | "warm" | "hot"; + +export interface EditorialStyles { + page: SxProps; + top: SxProps; + brandWrap: SxProps; + logoImg: SxProps; + brandEyebrow: SxProps; + brandTitle: SxProps; + statusLine: SxProps; + statusDot: (tone: OverallTone) => SxProps; + dateline: SxProps; + chartSwitchWrap: SxProps; + chartSwitch: SxProps; + chartSwitchButton: (active: boolean) => SxProps; + monitorList: SxProps; + card: SxProps; + cardRow: SxProps; + cardLeft: SxProps; + monitorName: SxProps; + monitorMeta: SxProps; + pill: SxProps; + pillHardware: SxProps; + monitorUrl: SxProps; + badge: (tone: OverallTone) => SxProps; + heatmap: SxProps; + heatmapCell: (kind: EditorialHeatCell) => SxProps; + histogram: SxProps; + bar: (kind: EditorialBarKind, heightPct: number) => SxProps; + chartStats: SxProps; + infra: SxProps; + infraEmpty: SxProps; + gauge: SxProps; + gaugeLabel: SxProps; + gaugeValue: SxProps; + gaugeBar: SxProps; + gaugeFill: (level: EditorialGaugeFill, widthPct: number) => SxProps; + gaugeSub: SxProps; + footer: SxProps; +} + +export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles => { + const heatCellBg: Record = { + fast: tokens.up, + med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`, + slow: tokens.warn, + down: tokens.down, + empty: tokens.border, + }; + + const barBg: Record = { + up: tokens.up, + down: tokens.down, + empty: tokens.border, + }; + + const gaugeFillBg: Record = { + ok: tokens.up, + warm: tokens.warn, + hot: tokens.down, + }; + + return { + page: { + flex: "1 0 auto", + maxWidth: 760, + width: "100%", + mx: "auto", + p: "64px 24px 96px", + fontFamily: SERIF_STACK, + fontSize: 15, + lineHeight: 1.6, + color: tokens.text, + WebkitFontSmoothing: "antialiased", + }, + + top: { + display: "flex", + alignItems: "flex-end", + justifyContent: "space-between", + mb: "48px", + borderBottom: `2px solid ${tokens.text}`, + pb: "18px", + }, + brandWrap: { display: "flex", flexDirection: "column" }, + logoImg: { maxHeight: 48, maxWidth: 160, objectFit: "contain", mb: "8px" }, + brandEyebrow: { + m: 0, + fontSize: 11, + textTransform: "uppercase", + letterSpacing: "0.3em", + fontWeight: 700, + color: tokens.textMuted, + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + }, + brandTitle: { + mt: "6px", + mb: 0, + fontSize: 40, + fontWeight: 700, + letterSpacing: "-0.02em", + lineHeight: 1, + color: tokens.text, + }, + + statusLine: { + fontSize: 22, + fontWeight: 400, + m: 0, + mb: "8px", + letterSpacing: "-0.01em", + lineHeight: 1.35, + color: tokens.text, + fontFamily: SERIF_STACK, + }, + statusDot: (tone) => ({ + display: "inline-block", + width: 10, + height: 10, + borderRadius: "50%", + background: toneColor(tone, tokens), + mr: "10px", + verticalAlign: "middle", + }), + dateline: { + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + fontSize: 11, + textTransform: "uppercase", + letterSpacing: "0.12em", + color: tokens.textMuted, + m: 0, + mb: "56px", + }, + + chartSwitchWrap: { + display: "flex", + justifyContent: "flex-end", + mb: "20px", + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + }, + chartSwitch: { + display: "inline-flex", + border: `1px solid ${tokens.text}`, + "& > button + button": { borderLeft: `1px solid ${tokens.text}` }, + }, + chartSwitchButton: (active) => ({ + fontFamily: "inherit", + fontSize: 10, + textTransform: "uppercase", + letterSpacing: "0.2em", + padding: "6px 14px", + border: 0, + background: active ? tokens.text : "transparent", + color: active ? tokens.bg : tokens.text, + cursor: "pointer", + fontWeight: 700, + }), + + monitorList: { + listStyle: "none", + m: 0, + p: 0, + display: "flex", + flexDirection: "column", + }, + card: { + background: "transparent", + border: 0, + borderBottom: `1px solid ${tokens.border}`, + mb: 0, + py: "20px", + "&:last-of-type": { borderBottom: 0 }, + }, + cardRow: { + display: "grid", + gridTemplateColumns: "1fr auto", + alignItems: "baseline", + gap: "20px", + }, + cardLeft: { minWidth: 0 }, + monitorName: { + fontSize: 20, + fontWeight: 700, + letterSpacing: "-0.015em", + color: tokens.text, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + monitorMeta: { + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + fontSize: 12, + color: tokens.textMuted, + mt: "6px", + display: "flex", + gap: "12px", + flexWrap: "wrap", + alignItems: "center", + }, + pill: { + textTransform: "uppercase", + letterSpacing: "0.12em", + fontWeight: 700, + color: tokens.textMuted, + }, + pillHardware: { + textTransform: "uppercase", + letterSpacing: "0.12em", + fontWeight: 700, + color: tokens.up, + }, + monitorUrl: { + fontFamily: MONO_STACK, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + maxWidth: 280, + fontSize: 12, + }, + + badge: (tone) => ({ + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + fontSize: 10, + fontWeight: 700, + padding: "4px 10px", + borderRadius: 0, + textTransform: "uppercase", + letterSpacing: "0.15em", + whiteSpace: "nowrap", + background: toneSoft(tone, tokens), + color: toneColor(tone, tokens), + }), + + heatmap: { + mt: "16px", + display: "grid", + gridTemplateColumns: "repeat(25, 1fr)", + gap: "2px", + height: 40, + }, + heatmapCell: (kind) => ({ + background: heatCellBg[kind], + opacity: kind === "empty" ? 0.5 : 1, + }), + + histogram: { + mt: "16px", + display: "grid", + gridTemplateColumns: "repeat(25, 1fr)", + gap: "2px", + alignItems: "flex-end", + height: 40, + }, + bar: (kind, heightPct) => ({ + background: barBg[kind], + minHeight: 3, + opacity: kind === "empty" ? 0.5 : 1, + height: `${heightPct}%`, + }), + chartStats: { + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + fontSize: 11, + color: tokens.textMuted, + fontVariantNumeric: "tabular-nums", + textTransform: "uppercase", + letterSpacing: "0.1em", + }, + + infra: { + display: "grid", + gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, + gap: "24px", + mt: "20px", + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + }, + infraEmpty: { mt: "20px", color: tokens.textMuted, fontSize: 13 }, + gauge: {}, + gaugeLabel: { + fontSize: 10, + color: tokens.textMuted, + textTransform: "uppercase", + letterSpacing: "0.18em", + fontWeight: 700, + }, + gaugeValue: { + fontSize: 28, + fontWeight: 700, + letterSpacing: "-0.02em", + mt: "4px", + fontVariantNumeric: "tabular-nums", + fontFamily: SERIF_STACK, + color: tokens.text, + }, + gaugeBar: { height: 2, background: tokens.border, mt: "10px" }, + gaugeFill: (level, widthPct) => ({ + display: "block", + height: "100%", + background: gaugeFillBg[level], + transition: "width 0.8s", + width: `${Math.max(0, Math.min(100, widthPct))}%`, + }), + gaugeSub: { + fontSize: 11, + color: tokens.textMuted, + mt: "8px", + fontVariantNumeric: "tabular-nums", + }, + + footer: { + textAlign: "center", + color: tokens.textMuted, + fontSize: 11, + mt: "56px", + textTransform: "uppercase", + letterSpacing: "0.2em", + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + "& a": { + color: tokens.text, + textDecoration: "none", + borderBottom: `1px solid ${tokens.text}`, + pb: "2px", + fontWeight: 700, + }, + }, + }; +}; diff --git a/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx new file mode 100644 index 000000000..461c5e0f5 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx @@ -0,0 +1,205 @@ +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, + monoFirstChar, + resolveOverallStatus, + statusBadgeKey, +} from "../shared/overallStatus"; +import { useStatusPageTheme } from "../StatusPageThemeProvider"; +import { modernStyles } from "./styles"; + +type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; + +interface Props { + statusPage: StatusPage; + monitors: StatusPageMonitor[]; +} + +export const ModernStatusPage = ({ statusPage, monitors }: Props) => { + const { t } = useTranslation(); + const { tokens, mode } = useStatusPageTheme(); + const styles = useMemo(() => modernStyles(tokens, mode === "dark"), [tokens, mode]); + const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); + + const overall = resolveOverallStatus(monitors, t, { iconSize: 20 }); + const logoSrc = statusPage.logo?.data + ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` + : null; + + return ( + + + + {logoSrc ? ( + + ) : ( + {monoFirstChar(statusPage.companyName)} + )} + {statusPage.companyName} + + + + + + + + {overall.message} + + + {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitors.length, + })} + + + {overall.icon} + + + {statusPage.showCharts && ( + + + setChartMode("heatmap")} + sx={styles.chartSwitchButton(chartMode === "heatmap")} + > + {t("pages.statusPages.monitorsList.chartTypeHeatmap")} + + setChartMode("histogram")} + sx={styles.chartSwitchButton(chartMode === "histogram")} + > + {t("pages.statusPages.monitorsList.chartTypeHistogram")} + + + + )} + + + {monitors.map((monitor) => { + const isHardware = monitor.type === "hardware"; + const showInfra = isHardware && statusPage.showInfrastructure !== false; + const showChart = !isHardware && statusPage.showCharts !== false; + const badgeTone = monitorBadgeTone(monitor.status); + + return ( + + + + {monitor.name} + + + {getMonitorTypeLabel(monitor.type, t)} + + {monitor.url && ( + + {monitor.url} + + )} + + + + {t(statusBadgeKey[monitor.status])} + + + + {showInfra && ( + + )} + {showChart && + (chartMode === "heatmap" ? ( + + ) : ( + + ))} + + ); + })} + + + + {t("pages.statusPages.footer.poweredBy")}{" "} + + Checkmate + + + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts new file mode 100644 index 000000000..ad7356451 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts @@ -0,0 +1,428 @@ +import type { SxProps, Theme } from "@mui/material/styles"; +import { keyframes } from "@mui/system"; +import type { StatusPageThemeTokens } from "../tokens"; +import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; +import { MONO_STACK, SANS_STACK } from "../shared/fontStacks"; + +export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty"; +export type ModernBarKind = "up" | "down" | "empty"; +export type ModernGaugeFill = "ok" | "warm" | "hot"; + +export interface ModernStyles { + page: SxProps; + top: SxProps; + brand: SxProps; + logoGrad: SxProps; + logoImg: SxProps; + hero: SxProps; + pulse: (tone: OverallTone) => SxProps; + statusCopy: SxProps; + heroTitle: SxProps; + heroSub: SxProps; + heroIcon: (tone: OverallTone) => SxProps; + chartSwitchWrap: SxProps; + chartSwitch: SxProps; + chartSwitchButton: (active: boolean) => SxProps; + monitorList: SxProps; + card: SxProps; + cardRow: SxProps; + cardLeft: SxProps; + monitorName: SxProps; + monitorMeta: SxProps; + pill: SxProps; + pillHardware: SxProps; + monitorUrl: SxProps; + badge: (tone: OverallTone) => SxProps; + heatmap: SxProps; + heatmapCell: (kind: ModernHeatCell) => SxProps; + histogram: SxProps; + bar: (kind: ModernBarKind, heightPct: number) => SxProps; + chartStats: SxProps; + infra: SxProps; + infraEmpty: SxProps; + gauge: SxProps; + gaugeLabel: SxProps; + gaugeValue: SxProps; + gaugeBar: SxProps; + gaugeFill: (level: ModernGaugeFill, widthPct: number) => SxProps; + gaugeSub: SxProps; + footer: SxProps; +} + +const lightShadow = + "0 2px 4px rgba(16, 24, 40, 0.04), 0 12px 32px rgba(16, 24, 40, 0.06)"; +const darkShadow = "0 2px 6px rgba(0, 0, 0, 0.5), 0 20px 40px rgba(0, 0, 0, 0.4)"; +const lightShadowHover = + "0 4px 8px rgba(16, 24, 40, 0.05), 0 16px 40px rgba(16, 24, 40, 0.08)"; +const darkShadowHover = "0 4px 10px rgba(0, 0, 0, 0.55), 0 24px 48px rgba(0, 0, 0, 0.45)"; + +const pillBase = { + fontSize: 10, + textTransform: "uppercase" as const, + letterSpacing: "0.08em", + padding: "3px 9px", + borderRadius: "999px", + fontWeight: 600, +}; + +const pulseKeyframes = keyframes` + 0% { transform: scale(1); opacity: 0.5; } + 100% { transform: scale(2.4); opacity: 0; } +`; + +const fadeInKeyframes = keyframes` + to { opacity: 1; transform: translateY(0); } +`; + +export const modernStyles = ( + tokens: StatusPageThemeTokens, + isDark: boolean +): ModernStyles => { + const cardShadow = isDark ? darkShadow : lightShadow; + const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover; + + const heatCellBg: Record = { + fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`, + med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`, + slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`, + down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`, + empty: tokens.border, + }; + + const barBg: Record = { + up: tokens.up, + down: tokens.down, + empty: tokens.border, + }; + + const gaugeFillBg: Record = { + ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`, + warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`, + hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`, + }; + + return { + page: { + flex: "1 0 auto", + maxWidth: 980, + width: "100%", + mx: "auto", + p: "56px 20px 80px", + fontFamily: SANS_STACK, + fontSize: 14, + lineHeight: 1.5, + color: tokens.text, + WebkitFontSmoothing: "antialiased", + }, + + top: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + mb: "32px", + }, + brand: { + display: "flex", + alignItems: "center", + gap: "12px", + fontWeight: 700, + fontSize: 15, + letterSpacing: "-0.01em", + color: tokens.text, + }, + logoGrad: { + width: 32, + height: 32, + borderRadius: "10px", + background: `linear-gradient(135deg, ${tokens.up}, ${tokens.upStrong})`, + display: "grid", + placeItems: "center", + color: "#fff", + fontWeight: 700, + fontSize: 14, + boxShadow: `0 4px 10px color-mix(in srgb, ${tokens.up} 40%, transparent)`, + }, + logoImg: { maxHeight: 32, maxWidth: 120, objectFit: "contain" }, + + hero: { + position: "relative", + borderRadius: "20px", + padding: "28px 32px", + mb: "24px", + display: "flex", + alignItems: "center", + gap: "20px", + background: tokens.surface, + border: `1px solid ${tokens.border}`, + boxShadow: cardShadow, + }, + pulse: (tone) => { + const c = toneColor(tone, tokens); + return { + position: "relative", + width: 14, + height: 14, + borderRadius: "50%", + background: c, + color: c, + flexShrink: 0, + "&::before, &::after": { + content: '""', + position: "absolute", + inset: 0, + borderRadius: "50%", + background: "currentColor", + opacity: 0.5, + animation: `${pulseKeyframes} 2s ease-out infinite`, + }, + "&::after": { animationDelay: "1s" }, + "@media (prefers-reduced-motion: reduce)": { + "&::before, &::after": { animation: "none" }, + }, + }; + }, + statusCopy: { flex: 1, minWidth: 0 }, + heroTitle: { + m: 0, + mb: "4px", + fontSize: 22, + fontWeight: 700, + letterSpacing: "-0.02em", + color: tokens.text, + }, + heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 }, + heroIcon: (tone) => ({ + color: toneColor(tone, tokens), + display: "flex", + alignItems: "center", + }), + + chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "14px" }, + chartSwitch: { + display: "inline-flex", + border: `1px solid ${tokens.border}`, + borderRadius: "999px", + background: tokens.surface, + p: "3px", + gap: "2px", + }, + chartSwitchButton: (active) => ({ + border: 0, + background: active ? tokens.upSoft : "transparent", + fontFamily: "inherit", + fontSize: 11, + padding: "6px 16px", + cursor: "pointer", + color: active ? tokens.up : tokens.textMuted, + fontWeight: 600, + textTransform: "uppercase", + letterSpacing: "0.08em", + transition: "background 0.2s, color 0.2s", + borderRadius: "999px", + "&:hover": { color: active ? tokens.up : tokens.text }, + }), + + monitorList: { + listStyle: "none", + m: 0, + p: 0, + display: "flex", + flexDirection: "column", + gap: "14px", + }, + card: { + background: tokens.surface, + border: `1px solid ${tokens.border}`, + borderRadius: "16px", + boxShadow: cardShadow, + 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: `${fadeInKeyframes} 0.5s forwards`, + "&:nth-of-type(1)": { animationDelay: "0.05s" }, + "&:nth-of-type(2)": { animationDelay: "0.1s" }, + "&:nth-of-type(3)": { animationDelay: "0.15s" }, + "&:nth-of-type(4)": { animationDelay: "0.2s" }, + "&:nth-of-type(5)": { animationDelay: "0.25s" }, + "&:nth-of-type(n+6)": { animationDelay: "0.3s" }, + "&:hover": { transform: "translateY(-2px)", boxShadow: cardHoverShadow }, + "@media (prefers-reduced-motion: reduce)": { + animation: "none", + opacity: 1, + transform: "none", + }, + }, + cardRow: { + display: "grid", + gridTemplateColumns: "1fr auto", + alignItems: "center", + gap: "20px", + p: "18px 24px", + }, + cardLeft: { minWidth: 0 }, + monitorName: { + fontWeight: 600, + fontSize: 15, + letterSpacing: "-0.005em", + color: tokens.text, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + monitorMeta: { + display: "flex", + gap: "10px", + alignItems: "center", + mt: "6px", + flexWrap: "wrap", + }, + pill: { + ...pillBase, + color: tokens.textMuted, + background: tokens.bg, + }, + pillHardware: { + ...pillBase, + color: tokens.up, + background: tokens.upSoft, + }, + monitorUrl: { + fontSize: 12, + color: tokens.textMuted, + fontFamily: MONO_STACK, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + maxWidth: 280, + }, + + badge: (tone) => ({ + fontSize: 11, + fontWeight: 700, + padding: "5px 12px", + borderRadius: "999px", + display: "inline-flex", + alignItems: "center", + gap: "6px", + whiteSpace: "nowrap", + background: toneSoft(tone, tokens), + color: toneColor(tone, tokens), + "&::before": { + content: '""', + width: 6, + height: 6, + borderRadius: "50%", + background: "currentColor", + }, + }), + + heatmap: { + padding: "0 24px 20px", + display: "grid", + gridTemplateColumns: "repeat(25, 1fr)", + gap: "3px", + height: 46, + }, + heatmapCell: (kind) => ({ + borderRadius: "3px", + background: heatCellBg[kind], + opacity: kind === "empty" ? 0.4 : 1, + transition: "transform 0.15s", + "&:hover": { transform: "scaleY(1.2)" }, + }), + + histogram: { + padding: "0 24px", + display: "grid", + gridTemplateColumns: "repeat(25, 1fr)", + gap: "3px", + alignItems: "flex-end", + height: 46, + }, + bar: (kind, heightPct) => ({ + background: barBg[kind], + borderRadius: "3px", + minHeight: 4, + opacity: kind === "empty" ? 0.4 : 1, + height: `${heightPct}%`, + transition: "transform 0.15s", + "&:hover": { transform: "scaleY(1.08)" }, + }), + chartStats: { + padding: "0 24px 20px", + fontSize: 11, + color: tokens.textMuted, + fontVariantNumeric: "tabular-nums", + fontWeight: 600, + }, + + infra: { + padding: "16px 24px 22px", + display: "grid", + gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, + gap: "14px", + }, + infraEmpty: { + padding: "16px 24px 22px", + color: tokens.textMuted, + fontSize: 13, + }, + gauge: { + border: `1px solid ${tokens.border}`, + borderRadius: "14px", + p: "14px 16px", + background: `linear-gradient(135deg, ${tokens.bg}, ${tokens.surface})`, + }, + gaugeLabel: { + fontSize: 10, + color: tokens.textMuted, + textTransform: "uppercase", + letterSpacing: "0.1em", + fontWeight: 700, + }, + gaugeValue: { + fontSize: 26, + fontWeight: 700, + letterSpacing: "-0.02em", + mt: "4px", + fontVariantNumeric: "tabular-nums", + color: tokens.text, + }, + gaugeBar: { + height: 6, + background: tokens.border, + borderRadius: "3px", + overflow: "hidden", + mt: "10px", + }, + gaugeFill: (level, widthPct) => ({ + display: "block", + height: "100%", + background: gaugeFillBg[level], + borderRadius: "3px", + transition: "width 0.8s cubic-bezier(0.2, 0.8, 0.2, 1)", + width: `${Math.max(0, Math.min(100, widthPct))}%`, + }), + gaugeSub: { + fontSize: 11, + color: tokens.textMuted, + mt: "8px", + fontVariantNumeric: "tabular-nums", + }, + + footer: { + textAlign: "center", + color: tokens.textMuted, + fontSize: 12, + mt: "48px", + "& a": { + color: tokens.up, + textDecoration: "underline", + textUnderlineOffset: "3px", + fontWeight: 700, + "&:hover": { color: tokens.upStrong }, + }, + }, + }; +}; diff --git a/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx new file mode 100644 index 000000000..5c5055b4c --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx @@ -0,0 +1,210 @@ +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, + monoFirstChar, + resolveOverallStatus, + statusBadgeKey, +} from "../shared/overallStatus"; +import { useStatusPageTheme } from "../StatusPageThemeProvider"; +import { refinedStyles } from "./styles"; + +type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; + +interface Props { + statusPage: StatusPage; + monitors: StatusPageMonitor[]; +} + +export const RefinedStatusPage = ({ statusPage, monitors }: Props) => { + const { t } = useTranslation(); + const { tokens } = useStatusPageTheme(); + const styles = useMemo(() => refinedStyles(tokens), [tokens]); + const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); + + const overall = resolveOverallStatus(monitors, t); + const logoSrc = statusPage.logo?.data + ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` + : null; + + return ( + + + + {logoSrc ? ( + + ) : ( + {monoFirstChar(statusPage.companyName)} + )} + + {statusPage.companyName} + + + + + + + + + {overall.message} + + + {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitors.length, + })} + + + {overall.icon} + + + {statusPage.showCharts && ( + + + setChartMode("heatmap")} + sx={styles.chartSwitchButton(chartMode === "heatmap")} + > + {t("pages.statusPages.monitorsList.chartTypeHeatmap")} + + setChartMode("histogram")} + sx={styles.chartSwitchButton(chartMode === "histogram")} + > + {t("pages.statusPages.monitorsList.chartTypeHistogram")} + + + + )} + + + {monitors.map((monitor) => { + const isHardware = monitor.type === "hardware"; + const showInfra = isHardware && statusPage.showInfrastructure !== false; + const showChart = !isHardware && statusPage.showCharts !== false; + const badgeTone = monitorBadgeTone(monitor.status); + + return ( + + + + {monitor.name} + + + {getMonitorTypeLabel(monitor.type, t)} + + {monitor.url && ( + + {monitor.url} + + )} + + + + {t(statusBadgeKey[monitor.status])} + + + + {showInfra && ( + + )} + {showChart && + (chartMode === "heatmap" ? ( + + ) : ( + + ))} + + ); + })} + + + + {t("pages.statusPages.footer.poweredBy")}{" "} + + Checkmate + + + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts new file mode 100644 index 000000000..52626b20c --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts @@ -0,0 +1,364 @@ +import type { SxProps, Theme } from "@mui/material/styles"; +import type { StatusPageThemeTokens } from "../tokens"; +import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; +import { MONO_STACK, SANS_STACK } from "../shared/fontStacks"; + +export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty"; +export type RefinedBarKind = "up" | "down" | "empty"; +export type RefinedGaugeFill = "ok" | "warm" | "hot"; + +export interface RefinedStyles { + page: SxProps; + top: SxProps; + brand: SxProps; + logoMono: SxProps; + logoImg: SxProps; + company: SxProps; + hero: SxProps; + statusDot: (tone: OverallTone) => SxProps; + statusCopy: SxProps; + heroTitle: SxProps; + heroSub: SxProps; + heroIcon: (tone: OverallTone) => SxProps; + chartSwitchWrap: SxProps; + chartSwitch: SxProps; + chartSwitchButton: (active: boolean) => SxProps; + monitorList: SxProps; + card: SxProps; + cardRow: SxProps; + cardLeft: SxProps; + monitorName: SxProps; + monitorMeta: SxProps; + pill: SxProps; + pillHardware: SxProps; + monitorUrl: SxProps; + badge: (tone: OverallTone) => SxProps; + heatmap: SxProps; + heatmapCell: (kind: RefinedHeatCell) => SxProps; + histogram: SxProps; + bar: (kind: RefinedBarKind, heightPct: number) => SxProps; + chartStats: SxProps; + infra: SxProps; + infraEmpty: SxProps; + gauge: SxProps; + gaugeLabel: SxProps; + gaugeValue: SxProps; + gaugeBar: SxProps; + gaugeFill: (level: RefinedGaugeFill, widthPct: number) => SxProps; + gaugeSub: SxProps; + footer: SxProps; +} + +const cardShadow = "0 1px 2px rgba(10, 16, 32, 0.06), 0 6px 18px rgba(10, 16, 32, 0.08)"; +const cardShadowHover = + "0 2px 4px rgba(16, 24, 40, 0.06), 0 10px 24px rgba(16, 24, 40, 0.06)"; + +const pillBase = { + fontSize: 10, + textTransform: "uppercase" as const, + letterSpacing: "0.08em", + padding: "2px 8px", + borderRadius: "999px", + fontWeight: 600, +}; + +export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => { + const heatCellBg: Record = { + fast: tokens.up, + med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`, + slow: tokens.warn, + down: tokens.down, + empty: tokens.border, + }; + + const barBg: Record = { + up: tokens.up, + down: tokens.down, + empty: tokens.border, + }; + + const gaugeFillBg: Record = { + ok: tokens.up, + warm: tokens.warn, + hot: tokens.down, + }; + + return { + page: { + flex: "1 0 auto", + maxWidth: 960, + width: "100%", + mx: "auto", + p: "48px 20px 80px", + fontFamily: SANS_STACK, + fontSize: 14, + lineHeight: 1.5, + color: tokens.text, + WebkitFontSmoothing: "antialiased", + }, + + top: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + mb: "28px", + }, + brand: { + display: "flex", + alignItems: "center", + gap: "10px", + fontWeight: 600, + letterSpacing: "-0.01em", + color: tokens.text, + }, + logoMono: { + width: 28, + height: 28, + borderRadius: "8px", + background: tokens.up, + display: "grid", + placeItems: "center", + color: "#fff", + fontWeight: 700, + fontSize: 13, + }, + logoImg: { maxHeight: 32, maxWidth: 120, objectFit: "contain" }, + company: { fontSize: 14 }, + + hero: { + background: tokens.surface, + border: `1px solid ${tokens.border}`, + borderRadius: tokens.radius, + padding: "22px 24px", + display: "flex", + alignItems: "center", + gap: "16px", + boxShadow: cardShadow, + mb: "20px", + }, + statusDot: (tone) => ({ + width: 10, + height: 10, + borderRadius: "50%", + background: toneColor(tone, tokens), + boxShadow: `0 0 0 4px ${toneSoft(tone, tokens)}`, + flexShrink: 0, + }), + statusCopy: { flex: 1, minWidth: 0 }, + heroTitle: { + m: 0, + mb: "2px", + fontSize: 17, + fontWeight: 600, + letterSpacing: "-0.01em", + color: tokens.text, + }, + heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 }, + heroIcon: (tone) => ({ + color: toneColor(tone, tokens), + display: "flex", + alignItems: "center", + }), + + chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "12px" }, + chartSwitch: { + display: "inline-flex", + border: `1px solid ${tokens.border}`, + borderRadius: "8px", + background: tokens.surface, + p: "3px", + gap: "2px", + }, + chartSwitchButton: (active) => ({ + border: 0, + background: active ? tokens.upSoft : "transparent", + fontFamily: "inherit", + fontSize: 11, + padding: "5px 14px", + cursor: "pointer", + color: active ? tokens.up : tokens.textMuted, + borderRadius: "5px", + transition: "background 0.15s ease, color 0.15s ease", + fontWeight: active ? 600 : 500, + "&:hover": { color: active ? tokens.up : tokens.text }, + }), + + monitorList: { + listStyle: "none", + m: 0, + p: 0, + display: "flex", + flexDirection: "column", + gap: "12px", + }, + card: { + background: tokens.surface, + border: `1px solid ${tokens.border}`, + borderRadius: tokens.radius, + boxShadow: cardShadow, + overflow: "hidden", + position: "relative", + transition: "transform 0.15s, box-shadow 0.15s", + "&:hover": { transform: "translateY(-1px)", boxShadow: cardShadowHover }, + }, + cardRow: { + display: "grid", + gridTemplateColumns: "1fr auto", + alignItems: "center", + gap: "16px", + p: "16px 20px", + }, + cardLeft: { minWidth: 0 }, + monitorName: { + fontWeight: 600, + fontSize: 14, + letterSpacing: "-0.005em", + color: tokens.text, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + monitorMeta: { + display: "flex", + gap: "10px", + alignItems: "center", + mt: "4px", + flexWrap: "wrap", + }, + pill: { + ...pillBase, + color: tokens.textMuted, + border: `1px solid ${tokens.border}`, + }, + pillHardware: { + ...pillBase, + color: tokens.up, + border: `1px solid ${tokens.border}`, + background: tokens.upSoft, + }, + monitorUrl: { + fontSize: 12, + color: tokens.textMuted, + fontFamily: MONO_STACK, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + maxWidth: 280, + }, + + badge: (tone) => ({ + fontSize: 11, + fontWeight: 600, + padding: "4px 10px", + borderRadius: "999px", + whiteSpace: "nowrap", + background: toneSoft(tone, tokens), + color: toneColor(tone, tokens), + }), + + heatmap: { + padding: "0 20px 16px", + display: "grid", + gridTemplateColumns: "repeat(25, 1fr)", + gap: "3px", + height: 42, + }, + heatmapCell: (kind) => ({ + borderRadius: "2px", + background: heatCellBg[kind], + opacity: kind === "empty" ? 0.4 : 1, + transition: "transform 0.15s", + "&:hover": { transform: "scaleY(1.15)" }, + }), + + histogram: { + padding: "0 20px", + display: "grid", + gridTemplateColumns: "repeat(25, 1fr)", + gap: "3px", + alignItems: "flex-end", + height: 42, + }, + bar: (kind, heightPct) => ({ + background: barBg[kind], + borderRadius: "2px", + minHeight: 3, + opacity: kind === "empty" ? 0.4 : 1, + height: `${heightPct}%`, + }), + chartStats: { + padding: "0 20px 16px", + fontSize: 11, + color: tokens.textMuted, + fontVariantNumeric: "tabular-nums", + }, + + infra: { + padding: "14px 20px 18px", + display: "grid", + gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, + gap: "12px", + }, + infraEmpty: { + padding: "14px 20px 18px", + color: tokens.textMuted, + fontSize: 13, + }, + gauge: { + border: `1px solid ${tokens.border}`, + borderRadius: "10px", + p: "12px 14px", + background: tokens.bg, + }, + gaugeLabel: { + fontSize: 11, + color: tokens.textMuted, + textTransform: "uppercase", + letterSpacing: "0.08em", + fontWeight: 600, + }, + gaugeValue: { + fontSize: 20, + fontWeight: 600, + letterSpacing: "-0.01em", + mt: "2px", + fontVariantNumeric: "tabular-nums", + color: tokens.text, + }, + gaugeBar: { + height: 4, + background: tokens.border, + borderRadius: "2px", + overflow: "hidden", + mt: "8px", + }, + gaugeFill: (level, widthPct) => ({ + display: "block", + height: "100%", + background: gaugeFillBg[level], + borderRadius: "2px", + transition: "width 0.6s", + width: `${Math.max(0, Math.min(100, widthPct))}%`, + }), + gaugeSub: { + fontSize: 11, + color: tokens.textMuted, + mt: "6px", + fontVariantNumeric: "tabular-nums", + }, + + footer: { + textAlign: "center", + color: tokens.textMuted, + fontSize: 12, + mt: "40px", + "& a": { + color: tokens.up, + textDecoration: "underline", + textUnderlineOffset: "3px", + fontWeight: 600, + "&:hover": { color: tokens.upStrong || tokens.up }, + }, + }, + }; +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx new file mode 100644 index 000000000..8e34dff36 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx @@ -0,0 +1,94 @@ +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 type { SxProps, Theme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/store"; +import type { CheckSnapshot } from "@/Types/Check"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; + +const CELLS = 25; + +export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty"; + +interface Props { + checks: CheckSnapshot[]; + containerSx: SxProps; + cellSx: (kind: HeatCellKind) => SxProps; +} + +const classify = (check: CheckSnapshot): Exclude => { + 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, containerSx, cellSx }: Props) => { + const { t } = useTranslation(); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + + const source = checks.slice(0, CELLS).reverse(); + const padded: (CheckSnapshot | null)[] = [ + ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null), + ...source, + ]; + + return ( + + {padded.map((check, i) => { + if (!check) { + return ( + + ); + } + const kind = classify(check); + const tooltipContent = ( + + + {check.status + ? `${check.responseTime} ms` + : t("pages.statusPages.monitorsList.chart.downTooltip")} + + + {formatDateWithTz( + check.createdAt, + "ddd, MMMM D, YYYY, HH:mm A", + uiTimezone + )} + + + ); + return ( + + + + ); + })} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx new file mode 100644 index 000000000..54036ebfd --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -0,0 +1,116 @@ +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 type { SxProps, Theme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/store"; +import type { CheckSnapshot } from "@/Types/Check"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; + +const CELLS = 25; +const MIN_HEIGHT_PCT = 6; + +export type BarKind = "up" | "down" | "empty"; + +interface Props { + checks: CheckSnapshot[]; + containerSx: SxProps; + barSx: (kind: BarKind, heightPct: number) => SxProps; + statsSx: SxProps; + statsGap?: number; +} + +const tone = (check: CheckSnapshot): Exclude => + check.status ? "up" : "down"; + +export const ThemedHistogram = ({ + checks, + containerSx, + barSx, + statsSx, + statsGap = 1, +}: Props) => { + const { t } = useTranslation(); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + + const { padded, max, avg, peak } = useMemo(() => { + const source = checks.slice(0, CELLS).reverse(); + const out: (CheckSnapshot | null)[] = [ + ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null), + ...source, + ]; + const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0); + const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1; + const avgRt = valid.length + ? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length) + : 0; + return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 }; + }, [checks]); + + return ( + + + {padded.map((check, i) => { + if (!check) { + return ( + + ); + } + const height = Math.max( + MIN_HEIGHT_PCT, + Math.round((check.responseTime / max) * 100) + ); + const tooltipContent = ( + + + {check.status + ? `${check.responseTime} ms` + : t("pages.statusPages.monitorsList.chart.downTooltip")} + + + {formatDateWithTz( + check.createdAt, + "ddd, MMMM D, YYYY, HH:mm A", + uiTimezone + )} + + + ); + return ( + + + + ); + })} + + + {t("pages.statusPages.monitorsList.chart.avg", { value: avg })} + {t("pages.statusPages.monitorsList.chart.max", { value: peak })} + + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx new file mode 100644 index 000000000..01804a322 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx @@ -0,0 +1,105 @@ +import Box from "@mui/material/Box"; +import prettyBytes from "pretty-bytes"; +import type { SxProps, Theme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; +import type { Monitor } from "@/Types/Monitor"; + +export type GaugeFillLevel = "ok" | "warm" | "hot"; + +export interface InfraSx { + containerSx: SxProps; + emptySx: SxProps; + gaugeSx: SxProps; + gaugeLabelSx: SxProps; + gaugeValueSx: SxProps; + gaugeBarSx: SxProps; + gaugeFillSx: (level: GaugeFillLevel, widthPct: number) => SxProps; + gaugeSubSx: SxProps; +} + +interface Props { + monitor: Monitor & { checks?: Monitor["recentChecks"] }; + sxApi: InfraSx; +} + +interface Gauge { + key: string; + label: string; + value: number; + sub: string; +} + +const PCT = 100; + +const heatLevel = (value: number): GaugeFillLevel => + value > 85 ? "hot" : value > 70 ? "warm" : "ok"; + +export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => { + const { t } = useTranslation(); + const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0]; + + const renderEmpty = () => ( + {t("pages.statusPages.monitorsList.noData")} + ); + + if (!latest) return renderEmpty(); + + 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 renderEmpty(); + + return ( + + {gauges.map((g) => ( + + {g.label} + {g.value.toFixed(0)}% + + + + {g.sub} + + ))} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/fontStacks.ts b/client/src/Pages/StatusPage/Status/themes/shared/fontStacks.ts new file mode 100644 index 000000000..2ad63fd07 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/fontStacks.ts @@ -0,0 +1,11 @@ +export const SANS_STACK = + '-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif'; + +export const BOLD_SANS_STACK = `ui-sans-serif, ${SANS_STACK}`; + +export const SERIF_STACK = 'Georgia, "Iowan Old Style", "Palatino Linotype", serif'; + +export const EDITORIAL_SECONDARY_SANS_STACK = + '-apple-system, "Helvetica Neue", Arial, sans-serif'; + +export const MONO_STACK = "ui-monospace, Menlo, monospace"; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/overallStatus.tsx b/client/src/Pages/StatusPage/Status/themes/shared/overallStatus.tsx new file mode 100644 index 000000000..51681bd79 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/overallStatus.tsx @@ -0,0 +1,167 @@ +import type { ReactNode } from "react"; +import { + AlertTriangle, + CircleCheck, + CircleX, + Loader, + PauseCircle, + ShieldAlert, + Wrench, +} from "lucide-react"; +import type { Monitor, MonitorStatus } from "@/Types/Monitor"; +import type { StatusPageThemeTokens } from "../tokens"; + +export type OverallTone = "up" | "warn" | "down"; + +export const toneColor = (tone: OverallTone, t: StatusPageThemeTokens): string => + tone === "down" ? t.down : tone === "warn" ? t.warn : t.up; + +export const toneSoft = (tone: OverallTone, t: StatusPageThemeTokens): string => + tone === "down" ? t.downSoft : tone === "warn" ? t.warnSoft : t.upSoft; + +export interface OverallStatus { + tone: OverallTone; + message: string; + icon: ReactNode; +} + +type StatusPageMonitor = Pick; + +interface Options { + iconSize?: number; + // Optional override for the `allUp` message key (editorial uses a more + // formal sentence). + allUpKey?: string; +} + +export const resolveOverallStatus = ( + monitors: StatusPageMonitor[], + t: (key: string) => string, + options: Options = {} +): OverallStatus => { + const size = options.iconSize ?? 18; + const allUpMessage = t(options.allUpKey ?? "pages.statusPages.statusBar.allUp"); + + if (monitors.length === 0) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.noMonitors"), + icon: , + }; + } + + const allOf = (...statuses: MonitorStatus[]) => + monitors.every((m) => statuses.includes(m.status)); + const someOf = (...statuses: MonitorStatus[]) => + monitors.some((m) => statuses.includes(m.status)); + const noneOf = (...statuses: MonitorStatus[]) => + monitors.every((m) => !statuses.includes(m.status)); + + if (allOf("up")) { + return { tone: "up", message: allUpMessage, icon: }; + } + if (allOf("breached")) { + return { + tone: "down", + message: t("pages.statusPages.statusBar.allBreached"), + icon: , + }; + } + if (allOf("maintenance")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.allMaintenance"), + icon: , + }; + } + if (allOf("down")) { + return { + tone: "down", + message: t("pages.statusPages.statusBar.allDown"), + icon: , + }; + } + if (allOf("paused")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.allPaused"), + icon: , + }; + } + if (allOf("initializing")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.allInitializing"), + icon: , + }; + } + if (someOf("breached") && someOf("down")) { + return { + tone: "down", + message: t("pages.statusPages.statusBar.breachedAndDown"), + icon: , + }; + } + if (someOf("breached")) { + return { + tone: "down", + message: t("pages.statusPages.statusBar.breached"), + icon: , + }; + } + if (someOf("maintenance") && someOf("down")) { + return { + tone: "down", + message: t("pages.statusPages.statusBar.maintenanceAndDown"), + icon: , + }; + } + if (someOf("maintenance") && noneOf("down")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.maintenance"), + icon: , + }; + } + if (someOf("down")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.degraded"), + icon: , + }; + } + if (someOf("paused")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.partiallyPaused"), + icon: , + }; + } + if (someOf("initializing")) { + return { + tone: "up", + message: t("pages.statusPages.statusBar.initializing"), + icon: , + }; + } + return { + tone: "warn", + message: t("pages.statusPages.statusBar.unknown"), + icon: , + }; +}; + +export const statusBadgeKey: Record = { + up: "pages.statusPages.monitorsList.status.up", + down: "pages.statusPages.monitorsList.status.down", + breached: "pages.statusPages.monitorsList.status.breached", + maintenance: "pages.statusPages.monitorsList.status.maintenance", + paused: "pages.statusPages.monitorsList.status.paused", + initializing: "pages.statusPages.monitorsList.status.initializing", +}; + +export const monoFirstChar = (s?: string): string => + (s?.trim().charAt(0) || "?").toUpperCase(); + +export const monitorBadgeTone = (status: MonitorStatus): OverallTone => + status === "up" ? "up" : status === "down" || status === "breached" ? "down" : "warn"; diff --git a/client/src/Pages/StatusPage/Status/themes/tokens.ts b/client/src/Pages/StatusPage/Status/themes/tokens.ts new file mode 100644 index 000000000..f84b6f100 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/tokens.ts @@ -0,0 +1,199 @@ +import type { StatusPageTheme } from "@/Types/StatusPage"; + +export interface StatusPageThemeTokens { + bg: string; + surface: string; + border: string; + text: string; + textMuted: string; + up: string; + upStrong: string; + upSoft: string; + degraded: string; + degradedSoft: string; + down: string; + downSoft: string; + warn: string; + warnSoft: string; + radius: string; + fontFamily?: string; + headingFontFamily?: string; + headingWeight?: number; + headingLetterSpacing?: string; + chartBarRadius?: string; + pulseStatusDot?: boolean; + staggeredCardFadeIn?: boolean; + conicLogo?: boolean; + heroSize?: "default" | "large"; + cardStyle?: "card" | "hairline"; +} + +type ThemeVariants = { light: StatusPageThemeTokens; dark: StatusPageThemeTokens }; + +const refined: ThemeVariants = { + light: { + bg: "#f4f6fa", + surface: "#ffffff", + border: "#dce1ea", + text: "#0a1020", + textMuted: "#4b5768", + up: "#0f8a6d", + upStrong: "#0c7359", + upSoft: "#d6f1e6", + degraded: "#c2630a", + degradedSoft: "#fde7bf", + down: "#d11f2f", + downSoft: "#fdd9dc", + warn: "#e08a0b", + warnSoft: "#fdebc5", + radius: "12px", + }, + dark: { + bg: "#070b11", + surface: "#131a24", + border: "#253142", + text: "#f1f5fb", + textMuted: "#9aa7bd", + up: "#2fd7a2", + upStrong: "#1fb487", + upSoft: "rgba(47,215,162,0.15)", + degraded: "#e69138", + degradedSoft: "rgba(230,145,56,0.18)", + down: "#ff5b6b", + downSoft: "rgba(255,91,107,0.18)", + warn: "#f0a837", + warnSoft: "rgba(240,168,55,0.18)", + radius: "12px", + }, +}; + +const modern: ThemeVariants = { + light: { + bg: "#fafbfc", + surface: "#ffffff", + border: "#eceef2", + text: "#0b1220", + textMuted: "#6b7280", + up: "#10a37f", + upStrong: "#0b8a6a", + upSoft: "#d7f3e9", + degraded: "#d97706", + degradedSoft: "#fef3c7", + down: "#dc2626", + downSoft: "#fee2e2", + warn: "#f59e0b", + warnSoft: "#fef3c7", + radius: "16px", + pulseStatusDot: true, + staggeredCardFadeIn: true, + }, + dark: { + bg: "#0a0d12", + surface: "#10151c", + border: "#1b2430", + text: "#e8edf5", + textMuted: "#8a95a8", + up: "#10a37f", + upStrong: "#0b8a6a", + upSoft: "rgba(16,163,127,0.18)", + degraded: "#d97706", + degradedSoft: "rgba(217,119,6,0.22)", + down: "#dc2626", + downSoft: "rgba(220,38,38,0.22)", + warn: "#f59e0b", + warnSoft: "rgba(245,158,11,0.2)", + radius: "16px", + pulseStatusDot: true, + staggeredCardFadeIn: true, + }, +}; + +const bold: ThemeVariants = { + light: { + bg: "#f5f6f9", + surface: "#ffffff", + border: "#e6e8ee", + text: "#0b1220", + textMuted: "#6b7280", + up: "#10a37f", + upStrong: "#0b8a6a", + upSoft: "rgba(16,163,127,0.12)", + degraded: "#f59e0b", + degradedSoft: "rgba(245,158,11,0.14)", + down: "#f43f5e", + downSoft: "rgba(244,63,94,0.12)", + warn: "#f59e0b", + warnSoft: "rgba(245,158,11,0.14)", + radius: "18px", + heroSize: "large", + conicLogo: true, + }, + dark: { + bg: "#05070a", + surface: "#0c1117", + border: "#1a2330", + text: "#eef2f7", + textMuted: "#7a8699", + up: "#22d3a6", + upStrong: "#10a37f", + upSoft: "rgba(34,211,166,0.14)", + degraded: "#f59e0b", + degradedSoft: "rgba(245,158,11,0.14)", + down: "#f43f5e", + downSoft: "rgba(244,63,94,0.16)", + warn: "#f59e0b", + warnSoft: "rgba(245,158,11,0.14)", + radius: "18px", + heroSize: "large", + conicLogo: true, + }, +}; + +const editorial: ThemeVariants = { + light: { + bg: "#fbfaf7", + surface: "#ffffff", + // Darkened from mockup's #e9e4d8 (~1.3:1 on #fbfaf7) to pass WCAG AA 3:1 for UI borders. + border: "#d4cdb8", + text: "#1a1a1a", + textMuted: "#6b675e", + up: "#2c6a4f", + upStrong: "#205239", + upSoft: "#dfeee4", + degraded: "#a46200", + degradedSoft: "#f7ecd4", + down: "#9a2a2a", + downSoft: "#f5dede", + warn: "#a46200", + warnSoft: "#f7ecd4", + radius: "6px", + headingFontFamily: 'Georgia, "Iowan Old Style", "Palatino Linotype", serif', + cardStyle: "hairline", + }, + dark: { + bg: "#141310", + surface: "#1c1a15", + border: "#3a352b", + text: "#f0ece3", + textMuted: "#9e988a", + up: "#6fbf96", + upStrong: "#4fa37a", + upSoft: "rgba(44,106,79,0.22)", + degraded: "#d79a3b", + degradedSoft: "rgba(164,98,0,0.22)", + down: "#d87a7a", + downSoft: "rgba(154,42,42,0.22)", + warn: "#d79a3b", + warnSoft: "rgba(164,98,0,0.22)", + radius: "6px", + headingFontFamily: 'Georgia, "Iowan Old Style", "Palatino Linotype", serif', + cardStyle: "hairline", + }, +}; + +export const themeTokens: Record = { + refined, + modern, + bold, + editorial, +}; diff --git a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx index 9c9938c04..fccf6105b 100644 --- a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx +++ b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx @@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material/styles"; import { useNavigate } from "react-router-dom"; -import type { StatusPage } from "@/Types/StatusPage"; +import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage"; interface StatusPagesTableProps { data: StatusPage[]; @@ -53,7 +53,7 @@ export const StatusPagesTable = ({ const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => { if (row.isPublished) { e.stopPropagation(); - const url = `/status/public/${row.url}`; + const url = `${PUBLIC_STATUS_PAGE_PREFIX}/${row.url}`; window.open(url, "_blank", "noopener,noreferrer"); } }; diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts index 6fe641210..fe47821c5 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -21,6 +21,25 @@ 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 const resolveStatusPageTheme = ( + value: string | null | undefined +): StatusPageTheme => + STATUS_PAGE_THEMES.find((t) => t === value) ?? DEFAULT_STATUS_PAGE_THEME; + +export const resolveStatusPageThemeMode = ( + value: string | null | undefined +): StatusPageThemeMode => + STATUS_PAGE_THEME_MODES.find((m) => m === value) ?? DEFAULT_STATUS_PAGE_THEME_MODE; +export const PUBLIC_STATUS_PAGE_PREFIX = "/status/public"; + export interface StatusPage { id: string; userId: string; @@ -43,6 +62,8 @@ export interface StatusPage { showAdminLoginLink: boolean; showInfrastructure: boolean; customCSS: string; + theme: StatusPageTheme; + themeMode: StatusPageThemeMode; createdAt: string; updatedAt: string; } diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index 43d7198f0..8b41b7780 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage"; export const statusPageSchema = z.object({ companyName: z @@ -25,6 +26,8 @@ export const statusPageSchema = z.object({ showAdminLoginLink: z.boolean(), showInfrastructure: z.boolean(), customCSS: z.string().optional(), + theme: z.enum(STATUS_PAGE_THEMES).optional(), + themeMode: z.enum(STATUS_PAGE_THEME_MODES).optional(), logo: z .object({ data: z.string(), diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index eb8476d87..11fa81309 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -1241,7 +1241,8 @@ "description": "إزالة جميع المراقبات من نظامك.", "dialog": { "title": "إزالة جميع المراقبات؟", - "description": "لا يمكن التراجع عن هذا." + "description": "لا يمكن التراجع عن هذا الإجراء.", + "paragraph": "سيتم أيضًا إزالة جميع سجلات الفحص والحوادث ونوافذ الصيانة المجدولة وأي صفحات حالة كانت تعتمد على هذه المراقبات. صدّر المراقبات أولًا للاحتفاظ بنسخة احتياطية من بياناتها." } }, "exportMonitors": { diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index 2c99e485a..454f0b673 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -1241,7 +1241,8 @@ "description": "Odstranit všechny monitory ze systému.", "dialog": { "title": "Odebrat všechny monitory?", - "description": "Tuto akci nelze vrátit zpět." + "description": "Tuto akci nelze vrátit zpět.", + "paragraph": "Smaže se také veškerá historie kontrol, incidenty, naplánovaná údržbová okna a stavové stránky, které tyto monitory používaly. Pokud si chcete data zachovat, nejprve monitory exportujte." } }, "exportMonitors": { diff --git a/client/src/locales/de.json b/client/src/locales/de.json index e01243670..8090aa5f3 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -1241,7 +1241,8 @@ "description": "Alle Monitore aus dem System entfernen.", "dialog": { "title": "Alle Monitore entfernen?", - "description": "Dies kann nicht rückgängig gemacht werden." + "description": "Diese Aktion kann nicht rückgängig gemacht werden.", + "paragraph": "Dabei werden auch der gesamte Prüfverlauf, alle Vorfälle, geplante Wartungsfenster sowie Statusseiten entfernt, die diese Monitore verwenden. Exportiere die Monitore zuerst, wenn du eine Sicherung der Daten behalten möchtest." } }, "exportMonitors": { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 5aada5776..3c632ef96 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1241,7 +1241,8 @@ "description": "Remove all monitors from your system.", "dialog": { "title": "Remove all monitors?", - "description": "This cannot be undone." + "description": "This cannot be undone.", + "paragraph": "All check history, incidents, scheduled maintenance windows, and any status pages that referenced these monitors will be removed too. Export your monitors first to keep a backup of their data." } }, "exportMonitors": { @@ -1275,6 +1276,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", @@ -1291,6 +1306,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", @@ -1322,6 +1339,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", @@ -1379,6 +1403,35 @@ } } }, + "theme": { + "title": "Page theme", + "description": "Choose the visual style for your public status page.", + "label": "Theme", + "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", + "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.", diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 27a99da8a..506c317e3 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -1248,7 +1248,8 @@ "description": "Eliminar todos los monitores del sistema.", "dialog": { "title": "¿Eliminar todos los monitores?", - "description": "Esto no se puede deshacer." + "description": "Esta acción no se puede deshacer.", + "paragraph": "También se eliminarán todo el historial de comprobaciones, los incidentes, las ventanas de mantenimiento programadas y las páginas de estado que usaban estos monitores. Exporta los monitores primero si quieres conservar una copia de seguridad de sus datos." } }, "exportMonitors": { diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index a8c5780a6..6238837f2 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -1241,7 +1241,8 @@ "description": "Poista kaikki monitorit järjestelmästäsi.", "dialog": { "title": "Poistetaanko kaikki monitorit?", - "description": "Tätä ei voi kumota." + "description": "Tätä toimintoa ei voi kumota.", + "paragraph": "Mukana poistuvat myös kaikki tarkistushistoria, häiriöt, ajoitetut huoltoikkunat sekä tilasivut, jotka käyttivät näitä monitoreja. Vie monitorit ensin, jos haluat säilyttää varmuuskopion niiden tiedoista." } }, "exportMonitors": { diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 8d1ac57b1..306a2bd61 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -1241,7 +1241,8 @@ "description": "Supprimer tous les moniteurs de votre système.", "dialog": { "title": "Supprimer tous les moniteurs ?", - "description": "Cette action est irréversible." + "description": "Cette action est irréversible.", + "paragraph": "L'historique des vérifications, les incidents, les fenêtres de maintenance planifiées et les pages de statut qui utilisaient ces moniteurs seront également supprimés. Exportez d'abord les moniteurs si vous souhaitez conserver une sauvegarde de leurs données." } }, "exportMonitors": { diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index f9766228e..923213a46 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -1241,7 +1241,8 @@ "description": "システムからすべてのモニターを削除します。", "dialog": { "title": "すべてのモニターを削除しますか?", - "description": "この操作は元に戻せません。" + "description": "この操作は元に戻せません。", + "paragraph": "これらのモニターに関連するチェック履歴、インシデント、予定されたメンテナンスウィンドウ、ステータスページもすべて削除されます。データのバックアップを残したい場合は、先にモニターをエクスポートしてください。" } }, "exportMonitors": { diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index f2a389db6..f7b7c4cbc 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -1241,7 +1241,8 @@ "description": "Remover todos os monitores do seu sistema.", "dialog": { "title": "Remover todos os monitores?", - "description": "Isso não pode ser desfeito." + "description": "Esta ação não pode ser desfeita.", + "paragraph": "Todo o histórico de verificações, incidentes, janelas de manutenção agendadas e páginas de status que usavam esses monitores também serão removidos. Exporte os monitores antes se quiser manter uma cópia de segurança dos dados." } }, "exportMonitors": { diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index 44fd8d2a4..1a5d12452 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -1241,7 +1241,8 @@ "description": "Удалить все мониторы из вашей системы.", "dialog": { "title": "Удалить все мониторы?", - "description": "Это действие нельзя отменить." + "description": "Это действие нельзя отменить.", + "paragraph": "Также будут удалены вся история проверок, инциденты, запланированные окна обслуживания и страницы состояния, использовавшие эти мониторы. Сначала экспортируйте мониторы, если хотите сохранить резервную копию их данных." } }, "exportMonitors": { diff --git a/client/src/locales/th.json b/client/src/locales/th.json index e66dbbc86..433ee10cd 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -1241,7 +1241,8 @@ "description": "ลบมอนิเตอร์ทั้งหมดจากระบบของคุณ", "dialog": { "title": "ลบมอนิเตอร์ทั้งหมด?", - "description": "ไม่สามารถย้อนกลับได้" + "description": "การกระทำนี้ไม่สามารถย้อนกลับได้", + "paragraph": "ประวัติการตรวจสอบทั้งหมด เหตุการณ์ ช่วงเวลาบำรุงรักษาที่กำหนดไว้ และหน้าสถานะที่ใช้มอนิเตอร์เหล่านี้จะถูกลบไปด้วย ส่งออกมอนิเตอร์ก่อนหากต้องการเก็บสำเนาข้อมูลสำรองไว้" } }, "exportMonitors": { diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index a75572117..a77dd8eb3 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -1241,7 +1241,8 @@ "description": "Sistemdeki tüm monitörleri kaldır.", "dialog": { "title": "Tüm monitörler kaldırılsın mı?", - "description": "Bu işlem geri alınamaz." + "description": "Bu işlem geri alınamaz.", + "paragraph": "Tüm kontrol geçmişi, olaylar, planlanmış bakım pencereleri ve bu monitörleri kullanan durum sayfaları da kaldırılacaktır. Verilerin yedeğini saklamak istiyorsanız, önce monitörleri dışa aktarın." } }, "exportMonitors": { diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index ca5b33dc4..5b467e4b4 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -1241,7 +1241,8 @@ "description": "Видалити всі монітори з вашої системи.", "dialog": { "title": "Видалити всі монітори?", - "description": "Це не можна скасувати." + "description": "Цю дію не можна скасувати.", + "paragraph": "Також будуть видалені вся історія перевірок, інциденти, заплановані вікна обслуговування та сторінки статусу, які використовували ці монітори. Спочатку експортуйте монітори, якщо хочете зберегти резервну копію їхніх даних." } }, "exportMonitors": { diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 61427389d..dc3e804de 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -1241,7 +1241,8 @@ "description": "Xóa tất cả các monitor khỏi hệ thống của bạn.", "dialog": { "title": "Xóa tất cả trình giám sát?", - "description": "Không thể hoàn tác." + "description": "Hành động này không thể hoàn tác.", + "paragraph": "Toàn bộ lịch sử kiểm tra, sự cố, cửa sổ bảo trì đã lên lịch và các trang trạng thái sử dụng những trình giám sát này cũng sẽ bị xóa. Hãy xuất trình giám sát trước nếu bạn muốn giữ một bản sao lưu dữ liệu." } }, "exportMonitors": { diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index fe66cfcca..ed5e70c1e 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -1241,7 +1241,8 @@ "description": "从系统中移除所有监视器。", "dialog": { "title": "移除所有监控?", - "description": "此操作无法撤销。" + "description": "此操作无法撤销。", + "paragraph": "所有检查历史、事件、计划维护窗口以及使用这些监控的状态页面也将一并被移除。如需保留数据备份,请先导出监控。" } }, "exportMonitors": { diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index 388ad4789..ff6e9f6dd 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -1241,7 +1241,8 @@ "description": "從系統中移除所有監視器。", "dialog": { "title": "移除所有監控?", - "description": "此操作無法復原。" + "description": "此操作無法復原。", + "paragraph": "所有檢查歷史、事件、排定的維護時段以及使用這些監控的狀態頁面也將一併被移除。如需保留資料備份,請先匯出監控。" } }, "exportMonitors": { diff --git a/docs/frontend-conventions.md b/docs/frontend-conventions.md new file mode 100644 index 000000000..4f14c555b --- /dev/null +++ b/docs/frontend-conventions.md @@ -0,0 +1,211 @@ +# Frontend conventions + +Style and structural rules the client codebase follows. These are extracted from review feedback on the PR series #3591–#3596 and are non-negotiable for new code. They exist to keep the codebase greppable, maintainable, and consistent with the existing theme. + +> If you're touching a file in `client/src`, every rule below applies. The "why" matters as much as the "what" — read the reasoning so you can judge edge cases. + +--- + +## 1. Prefer MUI native (system) props over `sx` + +MUI components accept layout, spacing, color, border, typography and sizing values as top-level props. Use those. Reach for `sx` only when no native prop exists for the value you need (e.g. `textDecoration`, pseudo-selectors, complex selectors, conditional CSS). + +**Why:** native props are easier to read in JSX, easier to grep, easier to refactor, and don't force the reader to mentally parse a nested object literal for things that are conceptually one attribute. A `sx` block also can't be split-line-edited cleanly when most of its keys are simple primitives. + +**How to apply:** any time you write `sx={{ … }}`, ask whether each key has a native equivalent. If yes, hoist it. + +### Canonical mappings + +| `sx` key | Native prop | +|---|---| +| `color` | `color` | +| `backgroundColor` | `bgcolor` | +| `borderRadius` | `borderRadius` | +| `border`, `borderTop`, … | `border`, `borderTop`, … | +| `width`, `height`, `minHeight`, `maxWidth` | same names as native | +| `p`, `pt`, `pb`, `px`, `py` | same names as native | +| `m`, `mt`, `mb`, `mx`, `my` | same names as native | +| `display` | `display` | +| `textAlign` | `textAlign` | +| `fontWeight`, `fontSize`, `lineHeight` | same names as native | +| `gap` (on Stack) | `gap` | + +### Examples + +```tsx +// BAD — everything inside sx + + +// GOOD — every key that has a native prop is hoisted + +``` + +```tsx +// BAD + + +// GOOD — hoist what you can; drop fontSize:13 because it's the body default + +``` + +### When `sx` is still correct + +Keep `sx` for things that don't have a native prop: + +```tsx + +``` + +Pseudo-selectors (`"&:hover"`), `transition`, `cursor`, `outline`, `overflow` (when not on Box/Stack), media queries, and conditional spread objects all stay in `sx`. + +--- + +## 2. Use the full theme path for colors, never the shorthand string + +Prop forms like `color="text.secondary"`, `color="primary.main"`, `bgcolor="background.paper"` work, but they are **forbidden in this codebase**. + +**Why:** the shorthand is invisible to grep. If a future change needs to find every place that consumes `theme.palette.text.secondary` — to retheme, to swap, to audit — the shorthand strings are missed. That is a maintenance trap. It's worth a few extra characters at every callsite to keep the codebase searchable. + +**How to apply:** always destructure or reference the theme value explicitly. + +```tsx +// BAD +… +… + +// GOOD +const theme = useTheme(); +… +… +``` + +This applies to every theme-aware string prop (`color`, `bgcolor`, `borderColor`, etc.), and inside `sx` if you really need it there. + +--- + +## 3. No hardcoded numeric or string literals — use tokens + +The theme exposes tokens for every kind of magic value. Use them. Reserve raw numbers for values that are genuinely one-off (and even then, push back on yourself first). + +**Why:** tokens are the single source of truth. A literal `2` in a `mt` prop is invisible — it could mean `LAYOUT.XXS`, a rounding correction, or a thinko. The reader can't tell. With a token, intent is in the name. + +**How to apply:** before writing a literal, check whether one of these tokens covers it. If it nearly does but not exactly, extend the token rather than hardcoding (Alex did exactly this — he added `LAYOUT.XXS = 2` rather than letting `mt: "2px"` stand). + +### Token sources + +- **Spacing / layout** — `client/src/Utils/Theme/constants.ts` + - `LAYOUT.XXS = 2` · `LAYOUT.XS = 4` · `LAYOUT.SM = 6` · `LAYOUT.MD = 8` · `LAYOUT.LG = 10` · `LAYOUT.XL = 12` · `LAYOUT.XXL = 16` + - `SPACING` for `theme.spacing(...)` multipliers +- **Typography sizes** — `client/src/Utils/Theme/Palette.ts` → `typographyLevels` + - `xs / s / m / l / xl / xxl` (rem values) +- **Hover / interaction** — `HOVER.DARKEN` etc. +- **Shape** — `theme.shape.borderRadius` (don't write `borderRadius: 1` or `borderRadius: 4`) +- **Colors** — `theme.palette.*` (rule 2 above) + +### Examples + +```tsx +// BAD + + + + + // 13 is the body default — drop the prop entirely + +// GOOD + + + + + // inherits 13px from theme +``` + +### Token naming gotcha + +`typographyLevels.xl` was historically used for both 18px and 23px callsites. Don't reuse a token for two different purposes — split it (`xl` = 18, `xxl` = 23) and update every callsite. If you find an ambiguous token while editing, fix it the same way. + +--- + +## 4. Use `useTheme()` inside components, don't import the theme module + +The `theme` object exported from `@/Utils/Theme/Theme` is the *factory output*, not a live, mode-aware instance. Importing it directly couples a component to the wrong reference and breaks dark-mode reactivity. It also breaks the build at import time in some module-graph configurations (this happened in PR #3596 and required a follow-up commit). + +**Why:** themes can change at runtime (mode toggle), and the hook is the only correct way to read the active theme. Imports also create circular-graph risk because `Theme.ts` itself depends on `Palette.ts`. + +**How to apply:** inside any component or component-helper that needs theme values: + +```tsx +// BAD +import { theme } from "@/Utils/Theme/Theme"; +const SectionHeading = ({ children }) => ( + {children} +); + +// GOOD +import { useTheme } from "@mui/material/styles"; +const SectionHeading = ({ children }) => { + const theme = useTheme(); + return {children}; +}; +``` + +Module-level utility files that don't render React (pure helpers) should accept `theme` as a parameter rather than importing it. + +--- + +## 5. Discriminated unions: pair the runtime list with the type + +When you have a closed set of string literals that's used both as a type and as runtime data (severities, statuses, kinds), declare the runtime tuple first and derive the type from it. Don't declare them as two parallel literals that can drift. + +**Why:** if the list and the type aren't tied to each other, adding a new value to one and forgetting the other compiles fine and breaks at runtime. Deriving the type from the tuple makes them inseparable. + +```tsx +// BAD +type Severity = "info" | "warning" | "error"; + +// GOOD +export const Severities = ["info", "warning", "error"] as const; +export type Severity = (typeof Severities)[number]; +``` + +Export both when callers need to iterate (e.g. for a Storybook stories matrix or a select dropdown). + +--- + +## Pre-PR checklist + +Before you open a PR, scan your diff for these: + +- [ ] Any `sx={{ … }}` block that contains keys with a native-prop equivalent (rule 1) +- [ ] Any `color="text.X"`, `bgcolor="background.X"`, or any short-string palette path (rule 2) +- [ ] Any raw number or px-string in spacing / margin / padding / fontSize / borderRadius props that should be a token (rule 3) +- [ ] Any `import { theme } from "@/Utils/Theme/Theme"` inside a component (rule 4) +- [ ] Any `type X = "a" | "b" | "c"` for a closed set used at runtime (rule 5) +- [ ] Run `npm run build` and `npm run format-check` in both `client/` and `server/` (per global instructions) + +A clean PR on these axes is a PR that ships without a normalize follow-up. diff --git a/docs/plans/status-page-themes.md b/docs/plans/status-page-themes.md deleted file mode 100644 index f7fb97331..000000000 --- a/docs/plans/status-page-themes.md +++ /dev/null @@ -1,303 +0,0 @@ -# Plan: Selectable public status page themes - -## Goal -Let each status page owner pick from four visual themes for their public status page (`/status/public/:url`). The choice is saved on the `StatusPage` document and rendered without any change to the data flow, routes, or component tree. Also add a light/dark toggle to the public page (not currently possible). - -Themes: -- `refined` (default, closest to today) -- `modern` -- `bold` -- `editorial` - -Mockups to match: `/Users/gorkemcetin/checkmate-web/public/status-designs/01-refined.html` through `04-editorial.html`. - -## Non-goals -- No changes to monitor scheduling, repositories, queue, or status computation -- **No functionality added and no functionality removed.** Every theme renders exactly the same data and the same interactions as the current public status page — company name, logo, overall status banner with all 7 status variants (up / degraded / down / breached / maintenance / paused / initializing), monitor list with name/type/URL/status badge, heatmap/histogram chart toggle, and hardware monitor gauges (CPU/memory/disk/temperature). All existing config toggles (`showCharts`, `showUptimePercentage`, `showInfrastructure`, `isPublished`, `customCSS`, `logo`, `timezone`, `color`) continue to work identically under every theme. This is a presentation-layer refresh — nothing more, nothing less -- No per-theme feature parity drift — if a feature isn't in the real product today, it stays out of all four themes. If it's in the product today, it must work under all four themes -- No custom theme editor — fixed set of four -- No repurposing of the existing unused `color` field on `StatusPage`; it stays as-is -- `customCSS` is out of scope here; it already exists and is applied after theme tokens - -## Principles -- **Tokens, not forks.** All four themes are the same component tree rendered with different CSS custom properties plus a small per-theme structural override map. No duplicate `MonitorsList`, `StatusBar`, etc. -- **Defaults stay invisible.** Existing status pages render `refined`, which is visually equivalent to today. No migration required. -- **Presentation layer only.** Backend change is two fields + one validator update. -- **Owner's choice wins on the public page.** If the admin forces `themeMode="light"`, visitor preference and visitor OS setting are ignored for that page. -- **Visitors do not change theme mode.** When `themeMode="auto"`, the visitor's OS preference (`prefers-color-scheme`) decides light vs dark — and that's final. No in-page toggle is exposed to end-visitors. The admin-forced modes (`light`, `dark`) still lock the page to that mode. - ---- - -## Part 1 — Backend (PR 1) - -### 1.1 Model -**File:** `server/src/db/models/StatusPage.ts` - -Add two fields: - -```ts -theme: { - type: String, - enum: ["refined", "modern", "bold", "editorial"], - default: "refined", -}, -themeMode: { - type: String, - enum: ["auto", "light", "dark"], - default: "auto", -}, -``` - -No migration needed. Mongoose applies defaults on read for missing fields on existing documents. - -### 1.2 Zod validator -**File:** `server/src/validation/statusPageValidation.ts` - -Extend `createStatusPageBodyValidation` (same schema reused for update): - -```ts -theme: z.enum(["refined", "modern", "bold", "editorial"]).optional(), -themeMode: z.enum(["auto", "light", "dark"]).optional(), -``` - -Optional so older admin clients keep working. - -### 1.3 Controller -**File:** `server/src/controllers/statusPageController.ts` - -- `createStatusPage` / `updateStatusPage` — already spread the validated body; confirm in review no explicit allow-list strips the new fields. -- `getStatusPageByUrl` — returns the whole document; confirm `theme` and `themeMode` appear in the JSON response (Mongoose defaults will populate them for legacy docs). - -### 1.4 OpenAPI / Swagger -**File:** `server/openapi.json` - -Add `theme` and `themeMode` to the StatusPage schema definition and to the create/update request bodies. Include enum values and defaults. - -### 1.5 Feature flag (rollback safety) -**File:** `server/src/config/` (or wherever env flags live) - -Add `STATUS_PAGE_THEMES_ENABLED` env var, default `true`. If the existing codebase already follows a convention (e.g. `FEATURE_*` prefix), rename to match during review — grep `server/src/config` before merging PR 1. When `false`, the controller strips `theme`/`themeMode` from writes and always responds with `theme: "refined"`, `themeMode: "auto"` on reads. Lets us disable the feature server-side without a rollback if something breaks in prod. - -### 1.6 Backend tests -**File:** `server/tests/statusPage.test.ts` (create if missing — check existing patterns) - -Specific cases: -- POST create with no `theme` → response and DB both have `theme: "refined"`, `themeMode: "auto"` -- POST create with `theme: "modern"`, `themeMode: "dark"` → persists -- PATCH update changes `theme` and `themeMode` → persists -- POST with `theme: "invalid"` → 400 -- POST with `themeMode: "invalid"` → 400 -- GET by URL on a legacy document (no `theme` field written) → response has defaults populated -- With `STATUS_PAGE_THEMES_ENABLED=false`, create with `theme: "modern"` → stored value is `"refined"` (or field not stored) and response reflects refined - ---- - -## Part 2 — Frontend (PR 2) - -### 2.1 Client type -**File:** wherever `StatusPage` is typed on the client (likely `client/src/Utils/...` or inline in `Pages/StatusPage/Create/index.tsx`) - -Add: - -```ts -theme?: "refined" | "modern" | "bold" | "editorial"; -themeMode?: "auto" | "light" | "dark"; -``` - -Both optional for backward compat with API responses from older servers during rollout. - -### 2.2 Theme tokens module -**New file:** `client/src/Pages/StatusPage/Status/themes/tokens.ts` - -```ts -export type StatusPageTheme = "refined" | "modern" | "bold" | "editorial"; -export type StatusPageThemeMode = "auto" | "light" | "dark"; - -export interface StatusPageThemeTokens { - bg: string; surface: string; border: string; - text: string; textMuted: string; - up: 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; - // per-theme structural flags - pulseStatusDot?: boolean; - staggeredCardFadeIn?: boolean; - conicLogo?: boolean; - heroSize?: "default" | "large"; - cardStyle?: "card" | "hairline"; // hairline = editorial -} - -export const themeTokens: Record< - StatusPageTheme, - { light: StatusPageThemeTokens; dark: StatusPageThemeTokens } ->; -``` - -**Source of truth:** port exact CSS variable values from each mockup's `:root` and `[data-theme="dark"]` blocks. These are the same HTML files already at `/Users/gorkemcetin/checkmate-web/public/status-designs/` so the values are pinned. - -### 2.3 Theme provider wrapper -**New file:** `client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx` - -Responsibilities: -- Read `theme` + `themeMode` from props (passed by the parent `Status` page from the fetched status page document) -- Resolve the active mode with this precedence: admin-forced mode (`themeMode !== "auto"`) > `prefers-color-scheme` > light. Visitors do not have a per-page override. -- Flatten the token object to inline CSS variables (`--sp-bg`, `--sp-up`, etc.) on a wrapper `
` -- Expose a React context with `{ theme, mode, setMode, tokens }` so components can read structural flags without prop drilling - -### 2.4 Theme override stylesheet -**New file:** `client/src/Pages/StatusPage/Status/themes/theme-overrides.css` - -Scoped stylesheet, selectors all prefixed with `[data-status-theme="..."]`. Covers the three structural differences that aren't expressible as token swaps: - -- `[data-status-theme="editorial"]` — serif heading font (**Georgia**, system font, no webfont to keep bundle lean), monitor cards become hairline-divided rows (no card borders, no shadow, bottom border only), flat badges, no rounded corners on charts -- `[data-status-theme="bold"]` — larger hero typography, conic-gradient logo treatment **only when no custom logo is uploaded**. Fallback visual: a conic-gradient disc containing the first letter of the status page's `companyName` as a monogram (matches the "A" shown in the mockup). When a custom logo is uploaded, render it plainly and skip the conic flair -- `[data-status-theme="modern"]` — pulse animation on the status dot, staggered fade-in on monitor cards - -All the simple color/radius/spacing differences stay in tokens, not here. - -### 2.5 Wire up the public page -**Files to touch (styling only, no structural changes):** -- `client/src/Pages/StatusPage/Status/index.tsx` — wrap `StatusPageView` in `StatusPageThemeProvider`, pass `theme`/`themeMode` from the fetched document -- `client/src/Pages/StatusPage/Status/Components/StatusBar.tsx` -- `client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx` -- `client/src/Pages/StatusPage/Status/Components/MonitorHeader.tsx` (confirm path in review — Explore said "if exists") -- `client/src/Pages/StatusPage/Status/Components/MonitorContent.tsx` -- `client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx` -- `client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx` - -Rule: within this subtree, read status-page-specific colors from `var(--sp-up)`, `var(--sp-surface)`, etc. instead of `theme.palette.*`. MUI theme is still used for spacing, breakpoints, and typography defaults. - -### 2.6 Chart components — explicit decision -**Files:** `client/src/Components/common/charts/HistogramResponseTime.tsx`, `HeatmapResponseTime.tsx` - -These are shared with non-status-page screens. Two options: -1. Leave them reading MUI theme (simplest). Accept that on `bold` dark theme the chart bars may look slightly off-brand versus the mockups. -2. Accept an optional `paletteOverride` prop and pass `{ up, degraded, down }` from the status page theme when rendered inside `StatusPageThemeProvider`. - -**Decision:** go with (2). It's a few lines of prop plumbing and keeps the mockups looking right. The components stay backwards-compatible because `paletteOverride` is optional. - -### 2.7 Public-page light/dark behaviour (no visitor toggle) -The public page does **not** expose a visitor-facing mode toggle. Per the "Visitors do not change theme mode" principle: -- `themeMode="auto"` → page reads `prefers-color-scheme` at load and **live-updates** via a `matchMedia` listener if the visitor's OS flips mode mid-session -- `themeMode="light"` or `"dark"` → page is locked to that mode regardless of visitor setting - -The admin dashboard keeps its global light/dark toggle (unchanged). No new `ThemeModeToggle` component is built; no localStorage key is added. - -### 2.8 Admin selector with previews -**File:** `client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx` (confirm in review — may be elsewhere in the `Create/` tree) - -- **Access control:** the picker is shown to users who can already edit the status page (admins and team admins — gate on the existing permission check, don't add a new one). Anyone who can save other status page settings can pick a theme. -- Card-style radio group, 2×2 grid on desktop, 1-column on mobile -- Each option card shows: - - Theme name (translated) - - 1-line description - - A **side-by-side light + dark preview** so admins see both modes at a glance (two SVGs per theme rendered next to each other within the card, labelled "Light" / "Dark") - - On hover, the card enlarges the currently-hovered preview (light or dark depending on cursor position half) — required in PR 2 - - Radio indicator -- Second form control below: `themeMode` dropdown with three options (auto / light / dark), translated -- Hook into the existing form state, write `theme` + `themeMode` to the create/update payload - -### 2.9 Preview thumbnails -**Assets:** `client/src/assets/status-themes/{refined,modern,bold,editorial}-{light,dark}.svg` (8 files total) - -- Hand-authored SVG, ~260×160 each (sized to sit side-by-side inside an admin card), illustrating the key visual traits of its theme (a simulated status banner + one monitor row with a tiny heatmap) -- Each ≤4KB, no embedded fonts -- Both variants per theme are always rendered in the picker card (not conditionally by admin UI mode) so admins compare light and dark without changing their UI preference -- Committed directly — no bundled screenshots, no external fetches - -### 2.10 i18n -**File:** `client/src/locales/en/translation.json` — other locales follow existing contribution flow - -Keys to add: -- `statusPage.settings.theme.label` -- `statusPage.settings.theme.description` -- `statusPage.settings.theme.options.refined.name` / `.description` -- `statusPage.settings.theme.options.modern.name` / `.description` -- `statusPage.settings.theme.options.bold.name` / `.description` -- `statusPage.settings.theme.options.editorial.name` / `.description` -- `statusPage.settings.themeMode.label` -- `statusPage.settings.themeMode.description` -- `statusPage.settings.themeMode.auto` / `.light` / `.dark` -- `statusPage.settings.theme.previewLightLabel` -- `statusPage.settings.theme.previewDarkLabel` - -### 2.11 Frontend tests -**Component tests (existing test setup):** -- Admin form: selecting a theme card updates form state and survives submit -- Admin form: `themeMode` dropdown changes payload -- `StatusPageThemeProvider`: `auto` mode resolves to `prefers-color-scheme`; admin-forced `light`/`dark` modes win over the OS setting -- Mode resolution: `themeMode="auto"` picks up `prefers-color-scheme`; `themeMode="light"` and `"dark"` lock the mode regardless of OS setting -- Admin picker shows both light + dark preview SVGs for every theme, regardless of admin UI mode -- Jest/Vitest snapshot tests (plain, no visual-regression tool): `Status/index.tsx` rendered under each of `refined | modern | bold | editorial` × `light | dark`. 8 snapshots via a single parametrised test. - -### 2.12 Accessibility checks -Run axe (or the existing a11y test harness) against each theme × mode combination before merging. Required passes: -- Body text ≥ 4.5:1 contrast -- Badges, UI component borders, and chart cells ≥ 3:1 contrast -- Editorial in particular: verify the hairline divider between monitor rows clears 3:1 (beige `#e9e4d8` on `#fbfaf7` is ~1.3:1, which fails — darken to ~`#d4cdb8` or heavier) - -Adjust tokens if any combination fails. Don't ship failing themes. - ---- - -## Rollout - -- **PR 1 (backend)** — model + validator + controller confirmation + OpenAPI + feature flag + tests. No visible change. Shippable independently; older clients keep working; theme is always `refined` in responses because the client ignores the field until PR 2 lands. -- **PR 2 (frontend)** — theme tokens + provider + wire-up + chart palette override + admin picker with previews + public toggle + i18n + tests + axe pass. - -Two PRs, small-enough individually to review carefully. - ---- - -## Risks and mitigations - -| Risk | Mitigation | -| --- | --- | -| Four themes = 4× visual-regression surface going forward | Parametrised snapshot tests in PR 2 cover all 4 themes × both modes × key status states. Regressions caught at PR time. | -| Editorial hairline dividers fail WCAG 3:1 for UI borders | Axe pass in 2.12; darken divider token until it clears. | -| Older clients posting create/update without `theme` break | Validator makes fields optional; model default handles it. Covered by backend test 1.6. | -| `customCSS` collides with theme tokens | Themes set CSS variables on a wrapper; `customCSS` is injected inline after and can override any `--sp-*` variable. No code change. Document in settings help text. | -| Shared chart components look off-brand on bold/modern dark | Decided in 2.6: pass an optional `paletteOverride` prop from the status page subtree. | -| Rollback needed if feature misbehaves in prod | Feature flag `STATUS_PAGE_THEMES_ENABLED` in 1.5 disables writes and forces `refined` on reads. | -| Admin picker previews go stale as themes drift | SVGs live next to token file; note in `tokens.ts` header comment: "If you change tokens here, update the preview SVGs." A future improvement is to render previews from tokens dynamically; out of scope. | -| Editorial structural override CSS leaks outside status page | All selectors prefixed with `[data-status-theme="..."]`. The wrapper div is scoped to `Status/index.tsx`, so the attribute only exists inside that subtree. | - ---- - -## Out-of-scope parking lot -- Custom theme editor with user-chosen colors -- Per-monitor theme overrides -- Status-page-level favicon + meta theme-color sync -- Live preview pane in admin while editing -- Dynamic preview rendering from tokens (replaces hand-authored SVGs) - ---- - -## Verification checklist before merging each PR - -### PR 1 (backend) -- [ ] `theme` + `themeMode` fields added with correct defaults -- [ ] Zod validator accepts valid enums, rejects invalid values -- [ ] Legacy docs (no theme field) return `"refined"`/`"auto"` on GET -- [ ] OpenAPI spec updated and regenerated -- [ ] Feature flag present and tested in both states -- [ ] All tests enumerated in 1.6 pass - -### PR 2 (frontend) -- [ ] **Functional parity audit:** walk the pre-change public status page and the post-change page under each of the four themes. Confirm every visible data point, every config toggle, and every interaction (chart type switch, heatmap tooltip, hardware gauge rendering, status variant coloring, logo display, URL display, `customCSS` injection) behaves identically. No feature missing from any theme. No new feature present in any theme. -- [ ] All four mockups render faithfully at `/status-designs/0N-*.html` parity -- [ ] Default case (no theme set) renders indistinguishably from pre-change public page -- [ ] No MUI theme mutations leak outside `Pages/StatusPage/Status/` -- [ ] Admin picker previews render in both light and dark admin UI modes -- [ ] Public page resolves mode correctly: `auto` → OS preference; `light`/`dark` → locked; no visitor-facing toggle exists -- [ ] Bold theme's conic-logo treatment applies only when no custom logo is uploaded; custom logos render plainly -- [ ] Axe passes AA on all 4 × 2 theme/mode combinations, including for chart cells -- [ ] i18n keys added to `en` and loaded without warnings -- [ ] Snapshot tests in place for all theme × mode combinations diff --git a/server/src/config/services.ts b/server/src/config/services.ts index c7e1a4469..b6cbb3a30 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -348,7 +348,7 @@ export const initializeServices = async ({ incidentsRepository, }); - const statusPageService = new StatusPageService(statusPagesRepository); + const statusPageService = new StatusPageService(statusPagesRepository, settingsService); const services = { settingsService, diff --git a/server/src/controllers/statusPageController.ts b/server/src/controllers/statusPageController.ts index 706ee5ebd..190c3a9fd 100644 --- a/server/src/controllers/statusPageController.ts +++ b/server/src/controllers/statusPageController.ts @@ -12,7 +12,6 @@ import { IStatusPageService } from "@/service/business/statusPageService.js"; import { IMonitorsRepository } from "@/repositories/index.js"; import { ISettingsService } from "@/service/system/settingsService.js"; import { NormalizeData } from "@/utils/dataUtils.js"; -import { applyDefaultTheme, withoutThemeFields } from "@/controllers/statusPageThemeFlag.js"; const SERVICE_NAME = "statusPageController"; @@ -47,16 +46,14 @@ class StatusPageController implements IStatusPageController { imageValidation.parse(req.file); } - const themesEnabled = this.settingsService.areStatusPageThemesEnabled(); - const body = themesEnabled ? req.body : withoutThemeFields(req.body); const teamId = requireTeamId(req?.user?.teamId); const userId = requireUserId(req?.user?.id); - const statusPage = await this.statusPageService.createStatusPage(userId, teamId, req.file, body); + const statusPage = await this.statusPageService.createStatusPage(userId, teamId, req.file, req.body); return res.status(200).json({ success: true, msg: "Status page created successfully", - data: themesEnabled ? statusPage : applyDefaultTheme(statusPage), + data: statusPage, }); } catch (error) { next(error); @@ -70,21 +67,19 @@ class StatusPageController implements IStatusPageController { imageValidation.parse(req.file); } - const themesEnabled = this.settingsService.areStatusPageThemesEnabled(); - const body = themesEnabled ? req.body : withoutThemeFields(req.body); const teamId = requireTeamId(req?.user?.teamId); const statusPageId = req.params.id as string; if (!statusPageId) { throw new AppError({ message: "Status page ID is required", status: 400 }); } - const statusPage = await this.statusPageService.updateStatusPage(statusPageId, teamId, req.file, body); + const statusPage = await this.statusPageService.updateStatusPage(statusPageId, teamId, req.file, req.body); if (statusPage === null) { throw new AppError({ message: "Status page not found", status: 404 }); } res.status(200).json({ success: true, msg: "Status page updated successfully", - data: themesEnabled ? statusPage : applyDefaultTheme(statusPage), + data: statusPage, }); } catch (error) { next(error); @@ -130,13 +125,11 @@ class StatusPageController implements IStatusPageController { } return { ...monitor, checks: normalizedChecks }; }); - - const themesEnabled = this.settingsService.areStatusPageThemesEnabled(); return res.status(200).json({ success: true, msg: "Status page retrieved successfully", data: { - statusPage: themesEnabled ? statusPage : applyDefaultTheme(statusPage), + statusPage, monitors: normalizedMonitors, }, }); @@ -149,11 +142,10 @@ class StatusPageController implements IStatusPageController { const teamId = requireTeamId(req.user?.teamId); const statusPages = await this.statusPageService.getStatusPagesByTeamId(teamId); - const themesEnabled = this.settingsService.areStatusPageThemesEnabled(); return res.status(200).json({ success: true, msg: "Status pages retrieved successfully", - data: themesEnabled ? statusPages : statusPages.map((sp) => applyDefaultTheme(sp)), + data: statusPages, }); } catch (error) { next(error); diff --git a/server/src/service/business/statusPageService.ts b/server/src/service/business/statusPageService.ts index 971f6897f..179461f25 100644 --- a/server/src/service/business/statusPageService.ts +++ b/server/src/service/business/statusPageService.ts @@ -1,5 +1,7 @@ import { type IStatusPagesRepository } from "@/repositories/index.js"; -import { StatusPage } from "@/types/index.js"; +import { ISettingsService } from "@/service/system/settingsService.js"; +import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, StatusPage } from "@/types/index.js"; + export interface IStatusPageService { createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; getStatusPageByUrl(url: string): Promise; @@ -11,29 +13,52 @@ export interface IStatusPageService { export class StatusPageService implements IStatusPageService { private statusPagesRepository: IStatusPagesRepository; - constructor(statusPagesRepository: IStatusPagesRepository) { + private settingsService: ISettingsService; + constructor(statusPagesRepository: IStatusPagesRepository, settingsService: ISettingsService) { this.statusPagesRepository = statusPagesRepository; + this.settingsService = settingsService; } + private withoutThemeFields = (data: Partial): Partial => { + const { theme: _theme, themeMode: _themeMode, ...rest } = data; + return rest; + }; + + private applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({ + ...statusPage, + theme: DEFAULT_STATUS_PAGE_THEME, + themeMode: DEFAULT_STATUS_PAGE_THEME_MODE, + }); + + private normalizeTheme = (statusPage: StatusPage): StatusPage => + this.settingsService.areStatusPageThemesEnabled() ? statusPage : this.applyDefaultTheme(statusPage); + + private normalizeInput = (data: Partial): Partial => + this.settingsService.areStatusPageThemesEnabled() ? data : this.withoutThemeFields(data); + createStatusPage = async ( userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial ): Promise => { - return await this.statusPagesRepository.create(userId, teamId, image, data); + const created = await this.statusPagesRepository.create(userId, teamId, image, this.normalizeInput(data)); + return this.normalizeTheme(created); }; getStatusPageByUrl = async (url: string): Promise => { - return await this.statusPagesRepository.findByUrl(url); + const statusPage = await this.statusPagesRepository.findByUrl(url); + return this.normalizeTheme(statusPage); }; getStatusPagesByTeamId = async (teamId: string): Promise => { - return await this.statusPagesRepository.findByTeamId(teamId); + const statusPages = await this.statusPagesRepository.findByTeamId(teamId); + return statusPages.map((sp) => this.normalizeTheme(sp)); }; updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { - return await this.statusPagesRepository.updateById(id, teamId, image, data); + const updated = await this.statusPagesRepository.updateById(id, teamId, image, this.normalizeInput(data)); + return this.normalizeTheme(updated); }; deleteStatusPage = async (statusPageId: string, teamId: string): Promise => { diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts index d50fd92f7..2deac9897 100644 --- a/server/test/unit/services/statusPageService.test.ts +++ b/server/test/unit/services/statusPageService.test.ts @@ -1,7 +1,26 @@ import { describe, expect, it, jest } from "@jest/globals"; import { StatusPageService } from "../../../src/service/business/statusPageService.ts"; import type { IStatusPagesRepository } from "../../../src/repositories/status-pages/IStatusPagesRepository.ts"; -import { makeStatusPage } from "../../helpers/makeStatusPage.ts"; +import type { ISettingsService } from "../../../src/service/system/settingsService.ts"; +import type { StatusPage } from "../../../src/types/index.ts"; +import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeStatusPage = (overrides?: Partial): StatusPage => + ({ + id: "sp-1", + teamId: "team-1", + userId: "user-1", + url: "my-status-page", + companyName: "Test Co", + monitors: ["mon-1"], + theme: "modern", + themeMode: "dark", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as StatusPage; const createRepo = () => ({ @@ -13,18 +32,24 @@ const createRepo = () => removeMonitorFromStatusPages: jest.fn().mockResolvedValue(1), }) as unknown as jest.Mocked; -const createService = () => { +const createSettingsService = (themesEnabled: boolean) => + ({ + areStatusPageThemesEnabled: jest.fn().mockReturnValue(themesEnabled), + }) as unknown as jest.Mocked; + +const createService = (themesEnabled = true) => { const repo = createRepo(); - const service = new StatusPageService(repo); - return { service, repo }; + const settingsService = createSettingsService(themesEnabled); + const service = new StatusPageService(repo, settingsService); + return { service, repo, settingsService }; }; // ── Tests ──────────────────────────────────────────────────────────────────── describe("StatusPageService", () => { describe("createStatusPage", () => { - it("delegates to repository with all parameters", async () => { - const { service, repo } = createService(); + it("delegates to repository with all parameters when themes enabled", async () => { + const { service, repo } = createService(true); const data = { companyName: "New Co" }; const file = { originalname: "logo.png" } as Express.Multer.File; @@ -35,39 +60,75 @@ describe("StatusPageService", () => { }); it("passes undefined image when not provided", async () => { - const { service, repo } = createService(); + const { service, repo } = createService(true); await service.createStatusPage("user-1", "team-1", undefined, {}); expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", undefined, {}); }); + + it("strips theme fields from input and applies defaults to result when themes disabled", async () => { + const { service, repo } = createService(false); + const data = { companyName: "New Co", theme: "bold" as const, themeMode: "light" as const }; + + const result = await service.createStatusPage("user-1", "team-1", undefined, data); + + expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", undefined, { companyName: "New Co" }); + expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + }); }); describe("getStatusPageByUrl", () => { it("delegates to repository", async () => { - const { service, repo } = createService(); + const { service, repo } = createService(true); const result = await service.getStatusPageByUrl("my-status-page"); expect(repo.findByUrl).toHaveBeenCalledWith("my-status-page"); expect(result).toEqual(makeStatusPage()); }); + + it("applies default theme when themes disabled", async () => { + const { service } = createService(false); + + const result = await service.getStatusPageByUrl("my-status-page"); + + expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + }); }); describe("getStatusPagesByTeamId", () => { it("delegates to repository", async () => { - const { service, repo } = createService(); + const { service, repo } = createService(true); const result = await service.getStatusPagesByTeamId("team-1"); expect(repo.findByTeamId).toHaveBeenCalledWith("team-1"); expect(result).toEqual([makeStatusPage()]); }); + + it("applies default theme to every result when themes disabled", async () => { + const { service, repo } = createService(false); + (repo.findByTeamId as jest.Mock).mockResolvedValue([ + makeStatusPage({ id: "sp-1", theme: "bold" }), + makeStatusPage({ id: "sp-2", theme: "editorial" }), + ]); + + const result = await service.getStatusPagesByTeamId("team-1"); + + expect(result).toHaveLength(2); + for (const sp of result) { + expect(sp.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(sp.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + } + }); }); describe("updateStatusPage", () => { it("delegates to repository with all parameters", async () => { - const { service, repo } = createService(); + const { service, repo } = createService(true); const updated = makeStatusPage({ companyName: "Updated Co" }); (repo.updateById as jest.Mock).mockResolvedValue(updated); const file = { originalname: "new-logo.png" } as Express.Multer.File; @@ -78,19 +139,30 @@ describe("StatusPageService", () => { expect(result).toBe(updated); }); - it("passes theme and themeMode through to the repository", async () => { - const { service, repo } = createService(); + it("passes theme and themeMode through to the repository when themes enabled", async () => { + const { service, repo } = createService(true); const patch = { theme: "modern" as const, themeMode: "dark" as const }; await service.updateStatusPage("sp-1", "team-1", undefined, patch); expect(repo.updateById).toHaveBeenCalledWith("sp-1", "team-1", undefined, patch); }); + + it("strips theme fields from input and applies defaults to result when themes disabled", async () => { + const { service, repo } = createService(false); + const patch = { companyName: "Updated Co", theme: "modern" as const, themeMode: "dark" as const }; + + const result = await service.updateStatusPage("sp-1", "team-1", undefined, patch); + + expect(repo.updateById).toHaveBeenCalledWith("sp-1", "team-1", undefined, { companyName: "Updated Co" }); + expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + }); }); describe("deleteStatusPage", () => { it("delegates to repository and returns deleted page", async () => { - const { service, repo } = createService(); + const { service, repo } = createService(true); const result = await service.deleteStatusPage("sp-1", "team-1");