Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
056e40be8f | ||
|
|
7f4282ab1f | ||
|
|
fcf2557a2f | ||
|
|
bdf1e13ce8 | ||
|
|
4de2ba4b25 | ||
|
|
d5bf45e2a9 | ||
|
|
6f988cc688 | ||
|
|
8fbbb1dd4b | ||
|
|
baa4d693ae | ||
|
|
e11553c218 | ||
|
|
e71892d0bc | ||
|
|
100e569aa8 | ||
|
|
343167012e | ||
|
|
5330fc3183 | ||
|
|
df7e348366 | ||
|
|
b2c3e08db3 | ||
|
|
8ca62db317 | ||
|
|
3bb767b780 | ||
|
|
20d27cb984 | ||
|
|
0322a96090 | ||
|
|
3dbad1743a | ||
|
|
f4c3ee16db | ||
|
|
a7d5a84206 | ||
|
|
e86734cee9 | ||
|
|
cc84c37a2d | ||
|
|
e85e9dc2d4 | ||
|
|
1398ab6721 | ||
|
|
d32ea7f8c3 | ||
|
|
edeed00be6 | ||
|
|
4c7e96122f | ||
|
|
45c02e0f7f | ||
|
|
aa7051095a | ||
|
|
fcb4fbf277 | ||
|
|
74a12754b6 | ||
|
|
fe8213f8bb | ||
|
|
cf5691e250 | ||
|
|
f3b4b908fd | ||
|
|
f58456961b | ||
|
|
91bdc662ca | ||
|
|
53e04e6af2 | ||
|
|
301942d0eb | ||
|
|
29bb1615f5 | ||
|
|
afc3ad237f | ||
|
|
e0ac040063 | ||
|
|
f895125145 | ||
|
|
63031e938e | ||
|
|
4b82129199 | ||
|
|
ccd176ba0b | ||
|
|
2dd9218910 | ||
|
|
5792ca624e | ||
|
|
5716b5eea4 | ||
|
|
c89dbebe98 | ||
|
|
5d5d1dbb23 | ||
|
|
93505f006c | ||
|
|
a6204b7bf5 | ||
|
|
9c21d71c44 | ||
|
|
fef3f2c294 | ||
|
|
a617bd340a | ||
|
|
23061e31d1 | ||
|
|
34b1a9cdc2 | ||
|
|
bab65770ce | ||
|
|
ed3645c3d0 | ||
|
|
0654e4adfa | ||
|
|
54c0e089a8 | ||
|
|
aee81a03fa | ||
|
|
40bcf7f6ac | ||
|
|
e908d34b3a | ||
|
|
a706e68c6a | ||
|
|
a2786a77ec | ||
|
|
a5af4e48df | ||
|
|
d80d939bd8 | ||
|
|
5874efff3c | ||
|
|
ab9e722caa | ||
|
|
4f8f2f1422 | ||
|
|
c6c50063d8 | ||
|
|
b95d8886b9 | ||
|
|
163f5ec1ec | ||
|
|
959e2ff7b8 | ||
|
|
2f8765cf3e | ||
|
|
5a72a11ac2 | ||
|
|
66418bf591 | ||
|
|
944ee74a1d | ||
|
|
ad964446de |
No files matched your search
@@ -12,20 +12,22 @@ 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
|
||||
npm install
|
||||
npm run dev # Start with hot-reload (nodemon + tsx) at http://localhost:52345
|
||||
npm run build # TypeScript compile + path alias resolution
|
||||
npm run test # Run Mocha tests with c8 coverage
|
||||
npm run test # Run Jest tests with coverage
|
||||
npm run lint # ESLint v9
|
||||
npm run lint-fix # Auto-fix lint issues
|
||||
npm run format # Prettier formatting
|
||||
@@ -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
|
||||
@@ -174,12 +184,13 @@ t('your.key') // Never hardcode UI strings
|
||||
- Both use ESLint with strict settings
|
||||
|
||||
### Testing
|
||||
Server tests use Mocha + Chai + Sinon:
|
||||
Server tests use Jest (with `--experimental-vm-modules` for ESM):
|
||||
```bash
|
||||
npm test # Run all tests with coverage
|
||||
npm test -- --grep "pattern" # Run specific tests
|
||||
npm test # Run all tests with coverage
|
||||
npm test -- -t "pattern" # Run tests matching name pattern
|
||||
npm test -- path/to/file.test.ts # Run a specific file
|
||||
```
|
||||
Test files: `server/tests/**/*.test.js`
|
||||
Test files: `server/test/**/*.test.ts`
|
||||
|
||||
## Database Models
|
||||
|
||||
|
||||
+2
-16
@@ -255,20 +255,6 @@ docker rm uptime_database_mongo
|
||||
|
||||
Start with [good first issues](https://github.com/bluewave-labs/checkmate/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
|
||||
|
||||
### Improve the documentation?
|
||||
|
||||
Docs live in [checkmate-documentation](https://github.com/bluewave-labs/checkmate-documentation). You can fix typos, add guides, or explain features better.
|
||||
|
||||
### Help with translations?
|
||||
|
||||
We use [PoEditor](https://poeditor.com) for translations. You can:
|
||||
|
||||
- [Sign up and join your language team](https://poeditor.com/join/project/lRUoGZFCsJ).
|
||||
- Translate UI strings.
|
||||
- Ask questions on Discord in the relevant #translations channel.
|
||||
|
||||
Make sure all new UI strings in code use `t('key')`.
|
||||
|
||||
### Submit a pull request?
|
||||
|
||||
Follow the [pull request checklist](#pull-request-checklist). Your PR should:
|
||||
@@ -283,7 +269,7 @@ Follow the [pull request checklist](#pull-request-checklist). Your PR should:
|
||||
|
||||
## Code guidelines
|
||||
|
||||
- Use ESLint and Prettier (`npm run lint`).
|
||||
- Use ESLint and Prettier. Run `npm run lint` and `npm run format-check` in both `client` and `server`. If `format-check` reports issues, fix them with `npm run format` before committing.
|
||||
- Follow naming conventions: `camelCase` for variables, `PascalCase` for components, `UPPER_CASE` for constants.
|
||||
- No hard-coded strings — use `t('your.key')` for everything visible.
|
||||
- Use the shared theme and components. No magic numbers or hardcoded styles.
|
||||
@@ -304,7 +290,7 @@ Before submitting your pull request, please confirm the following:
|
||||
- You used the shared theme for any styling — no magic numbers or inline styles.
|
||||
- The pull request addresses only one issue or topic.
|
||||
- You added screenshots or a video for any UI-related changes.
|
||||
- Your code passes linting and has no TypeScript errors.
|
||||
- Your code passes `npm run lint`, `npm run format-check`, and `npm run build` in both `client` and `server` with no errors.
|
||||
|
||||
If one or more of these are missing, we may ask you to update your pull request before we can merge it.
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
|
||||
[](https://www.pikapods.com/pods?run=checkmate)
|
||||
|
||||
<img width="1660" alt="image" src="https://github.com/user-attachments/assets/b748f36d-a271-4965-ad0a-18bf153bbee7" />
|
||||
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
|
||||
|
||||
<br>
|
||||
|
||||
|
||||
This repository contains both the frontend and the backend of Checkmate, an open-source, self-hosted monitoring tool for tracking server hardware, uptime, response times, and incidents in real-time with beautiful visualizations. Checkmate regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.
|
||||
|
||||
@@ -24,8 +27,6 @@ Checkmate also has an agent, called [Capture](https://github.com/bluewave-labs/c
|
||||
|
||||
Checkmate has been stress-tested with 1000+ active monitors without any particular issues or performance bottlenecks.
|
||||
|
||||
**If you would like to sponsor a feature, [see this link](https://checkmate.so/sponsored-features).**
|
||||
|
||||
## 📚 Table of contents
|
||||
|
||||
- [📦 Demo](#demo)
|
||||
@@ -38,7 +39,6 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
|
||||
- [🏗️ Tech stack](#tech-stack)
|
||||
- [🔗 A few links](#a-few-links)
|
||||
- [🤝 Contributing](#contributing)
|
||||
- [💰 Our sponsors](#our-sponsors)
|
||||
|
||||
|
||||
## Demo
|
||||
@@ -89,30 +89,17 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
||||
## Features
|
||||
|
||||
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
|
||||
- Website monitoring
|
||||
- Several monitoring options: Uptime, Docker, Ping, SSL, Port, Game server
|
||||
- Page speed monitoring
|
||||
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
|
||||
- Selective disk monitoring with mountpoint selection
|
||||
- Docker monitoring
|
||||
- Ping monitoring
|
||||
- SSL monitoring
|
||||
- Port monitoring
|
||||
- Game server monitoring (3.0)
|
||||
- Incidents at a glance
|
||||
- Status pages
|
||||
- E-mail, Webhooks, Discord and Slack notifications
|
||||
- Status pages with 4 beautiful themes
|
||||
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
|
||||
- Scheduled maintenance
|
||||
- JSON query monitoring
|
||||
- Multi-language support for English, German, Japanese, Portuguese (Brazil), Russian, Turkish, Ukrainian, Vietnamese, Chinese (Traditional, Taiwan)
|
||||
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
|
||||
|
||||
**Short term roadmap:**
|
||||
|
||||
- Plugins that will help Checkmate get any information from a remote service (e.g database, etc)
|
||||
- Better notifications
|
||||
- Network monitoring
|
||||
- ..and a few more features
|
||||
|
||||
If you would like to sponsor an additional feature, [see this page](https://checkmate.so/sponsored-features).
|
||||
|
||||
## Monitor Lifecycle
|
||||
|
||||
@@ -180,8 +167,3 @@ Here's how you can contribute:
|
||||
|
||||
[](https://star-history.com/#bluewave-labs/Checkmate&Date)
|
||||
|
||||
## Our sponsors
|
||||
|
||||
Thanks to [Gitbook](https://gitbook.io/) for giving us a free tier for their documentation platform, and [Poeditor](https://poeditor.com/) providing us a free account to use their i18n services. If you would like to sponsor Checkmate, please send an email to hello@bluewavelabs.ca
|
||||
|
||||
If you would like to sponsor a feature, [see this page](https://checkmate.so/sponsored-features).
|
||||
@@ -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: <Typography color="text.secondary" /> — 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
Generated
+446
-105
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "client",
|
||||
"version": "3.6.1",
|
||||
"version": "3.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "client",
|
||||
"version": "3.6.1",
|
||||
"version": "3.7.1",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
@@ -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",
|
||||
@@ -1633,9 +1635,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
|
||||
"integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1646,9 +1648,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1659,9 +1661,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1672,9 +1674,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
|
||||
"integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1685,9 +1687,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1698,9 +1700,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
|
||||
"integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1711,9 +1713,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
|
||||
"integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1724,9 +1726,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
|
||||
"integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1737,9 +1739,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1750,9 +1752,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1763,9 +1765,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1776,9 +1778,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1789,9 +1791,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1802,9 +1804,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1815,9 +1817,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1828,9 +1830,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1841,9 +1843,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1854,9 +1856,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1867,9 +1869,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1880,9 +1882,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
|
||||
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1893,9 +1895,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1906,9 +1908,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
|
||||
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1919,9 +1921,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
|
||||
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1932,9 +1934,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1945,9 +1947,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
|
||||
"integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -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",
|
||||
@@ -6101,9 +6419,9 @@
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
|
||||
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
@@ -6116,31 +6434,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.2",
|
||||
"@rollup/rollup-android-arm64": "4.60.2",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.2",
|
||||
"@rollup/rollup-darwin-x64": "4.60.2",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.2",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.2",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.2",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.2",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.2",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.2",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.2",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.2",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.2",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -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"
|
||||
|
||||
+5
-2
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "3.6.1",
|
||||
"version": "3.7.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
|
||||
@@ -9,13 +9,14 @@ import {
|
||||
EmptyMonitorFallback,
|
||||
} from "@/Components/design-elements/Fallback";
|
||||
import { EmptyState } from "@/Components/design-elements/EmptyState";
|
||||
import { NoticeBanner } from "@/Components/design-elements/NoticeBanner";
|
||||
import { Breadcrumb } from "@/Components/design-elements/Breadcrumb";
|
||||
import { PageHeader } from "@/Components/design-elements/PageHeader";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { LanguageSelector, SwitchTheme } from "@/Components/inputs";
|
||||
|
||||
import type { StackProps } from "@mui/material/Stack";
|
||||
import { useTheme, alpha } from "@mui/material/styles";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { Typography } from "@mui/material";
|
||||
@@ -23,60 +24,30 @@ import { Typography } from "@mui/material";
|
||||
export const PageSpeedKeyPriorityFallback = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const alertBg = alpha(theme.palette.warning.main, 0.08);
|
||||
const alertBorder = alpha(theme.palette.warning.main, 0.45);
|
||||
const alertIcon = theme.palette.warning.main;
|
||||
return (
|
||||
<EmptyState
|
||||
fullscreen
|
||||
title={t("pages.pageSpeed.fallback.title")}
|
||||
description={t("pages.pageSpeed.fallback.description")}
|
||||
alert={
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
sx={{
|
||||
width: "100%",
|
||||
p: theme.spacing(LAYOUT.MD),
|
||||
borderRadius: 1,
|
||||
border: `1px solid ${alertBorder}`,
|
||||
backgroundColor: alertBg,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
color: alertIcon,
|
||||
fontSize: 18,
|
||||
lineHeight: 1,
|
||||
mt: "2px",
|
||||
<NoticeBanner severity="warning">
|
||||
<Trans
|
||||
i18nKey="common.alerts.pageSpeedApiKey.content"
|
||||
components={{
|
||||
settingsLink: (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/settings"
|
||||
color={theme.palette.primary.main}
|
||||
fontWeight={600}
|
||||
sx={{
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
⚠
|
||||
</Box>
|
||||
<Typography
|
||||
sx={{ color: theme.palette.text.primary, lineHeight: 1.55, fontSize: 13 }}
|
||||
>
|
||||
<Trans
|
||||
i18nKey="common.alerts.pageSpeedApiKey.content"
|
||||
components={{
|
||||
settingsLink: (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/settings"
|
||||
sx={{
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 600,
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</Stack>
|
||||
/>
|
||||
</NoticeBanner>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
||||
return (
|
||||
<Stack>
|
||||
<Typography
|
||||
fontSize={typographyLevels.xl}
|
||||
fontSize={typographyLevels.xxl}
|
||||
fontWeight={500}
|
||||
overflow={"hidden"}
|
||||
textOverflow={"ellipsis"}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme, alpha } from "@mui/material/styles";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
|
||||
export const Severities = ["info", "warning", "error"] as const;
|
||||
export type Severity = (typeof Severities)[number];
|
||||
|
||||
const SEVERITY_GLYPH: Record<Severity, string> = {
|
||||
info: "ⓘ",
|
||||
warning: "⚠",
|
||||
error: "✕",
|
||||
};
|
||||
|
||||
interface NoticeBannerProps {
|
||||
severity?: Severity;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const NoticeBanner = ({ severity = "info", children }: NoticeBannerProps) => {
|
||||
const theme = useTheme();
|
||||
const tone = theme.palette[severity].main;
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
width={"100%"}
|
||||
p={theme.spacing(LAYOUT.MD)}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
border={`1px solid ${alpha(tone, 0.45)}`}
|
||||
bgcolor={alpha(tone, 0.08)}
|
||||
textAlign={"left"}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
color={tone}
|
||||
fontSize={typographyLevels.xl}
|
||||
lineHeight={1}
|
||||
mt={LAYOUT.XXS}
|
||||
aria-hidden
|
||||
>
|
||||
{SEVERITY_GLYPH[severity]}
|
||||
</Box>
|
||||
<Typography
|
||||
color={theme.palette.text.primary}
|
||||
lineHeight={1.55}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -53,7 +53,7 @@ export const ConfigBox = ({
|
||||
rightContent,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
subtitle: React.ReactNode;
|
||||
leftContent?: React.ReactNode;
|
||||
rightContent: React.ReactNode;
|
||||
}) => {
|
||||
|
||||
@@ -22,5 +22,6 @@ export * from "./Gauge";
|
||||
export * from "./Tabs";
|
||||
export * from "./SplitBox";
|
||||
export * from "./TextLink";
|
||||
export * from "./NoticeBanner";
|
||||
export * from "./OfflineBanner";
|
||||
export * from "./Avatar";
|
||||
@@ -1,19 +1,43 @@
|
||||
import Button from "@mui/material/Button";
|
||||
import type { ButtonProps } from "@mui/material/Button";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTheme, darken } from "@mui/material/styles";
|
||||
import { HOVER } from "@/Utils/Theme/constants";
|
||||
|
||||
const PALETTE_COLORS = [
|
||||
"primary",
|
||||
"secondary",
|
||||
"error",
|
||||
"warning",
|
||||
"info",
|
||||
"success",
|
||||
] as const;
|
||||
type PaletteColor = (typeof PALETTE_COLORS)[number];
|
||||
|
||||
export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
|
||||
const theme = useTheme();
|
||||
const outlinedSx =
|
||||
|
||||
const hoverOverrides: Record<string, unknown> = { boxShadow: "none" };
|
||||
|
||||
if (props.variant === "outlined") {
|
||||
hoverOverrides.borderColor = theme.palette.text.secondary;
|
||||
}
|
||||
|
||||
if (props.variant === "contained") {
|
||||
const colorKey = (props.color ?? "primary") as PaletteColor;
|
||||
const palette = theme.palette[colorKey];
|
||||
if (palette && typeof palette === "object" && "main" in palette) {
|
||||
hoverOverrides.backgroundColor = darken(palette.main, HOVER.DARKEN);
|
||||
}
|
||||
}
|
||||
|
||||
const variantSx =
|
||||
props.variant === "outlined"
|
||||
? {
|
||||
color: theme.palette.text.primary,
|
||||
borderColor: theme.palette.divider,
|
||||
"&:hover": {
|
||||
borderColor: theme.palette.text.secondary,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
@@ -26,10 +50,8 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
boxShadow: "none",
|
||||
"&:hover": {
|
||||
boxShadow: "none",
|
||||
},
|
||||
...outlinedSx,
|
||||
"&:hover": hoverOverrides,
|
||||
...variantSx,
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2,10 +2,12 @@ import Dialog from "@mui/material/Dialog";
|
||||
import type { DialogProps } from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
@@ -16,7 +18,6 @@ export const DialogInput = ({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmColor = "primary",
|
||||
cancelColor = "error",
|
||||
loading = false,
|
||||
cancelText,
|
||||
confirmText,
|
||||
@@ -31,7 +32,6 @@ export const DialogInput = ({
|
||||
onConfirm?(item: any): any;
|
||||
onCancel?(item: any): any;
|
||||
confirmColor?: "error" | "primary";
|
||||
cancelColor?: "error" | "primary";
|
||||
loading?: boolean;
|
||||
cancelText?: string;
|
||||
confirmText?: string;
|
||||
@@ -41,6 +41,7 @@ export const DialogInput = ({
|
||||
additionalButtons?: ReactNode;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -50,16 +51,48 @@ export const DialogInput = ({
|
||||
if (reason !== "backdropClick" && onCancel) onCancel(undefined);
|
||||
}}
|
||||
>
|
||||
{title && <DialogTitle sx={{ fontSize: typographyLevels.l }}>{title}</DialogTitle>}
|
||||
<DialogContent>
|
||||
{content && <DialogContentText>{content}</DialogContentText>}
|
||||
{children}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{title && (
|
||||
<DialogTitle
|
||||
pb={theme.spacing(LAYOUT.XS)}
|
||||
bgcolor={theme.palette.action.hover}
|
||||
borderBottom={`1px solid ${theme.palette.divider}`}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="h2"
|
||||
fontWeight={600}
|
||||
display={"block"}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{content && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
color={theme.palette.text.secondary}
|
||||
mt={theme.spacing(LAYOUT.XS)}
|
||||
display={"block"}
|
||||
>
|
||||
{content}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogTitle>
|
||||
)}
|
||||
{children && (
|
||||
<DialogContent sx={{ pt: theme.spacing(LAYOUT.MD) }}>
|
||||
<Box pt={theme.spacing(LAYOUT.XS)}>{children}</Box>
|
||||
</DialogContent>
|
||||
)}
|
||||
<DialogActions
|
||||
sx={{
|
||||
p: theme.spacing(LAYOUT.MD),
|
||||
pt: theme.spacing(LAYOUT.SM),
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
loading={loading}
|
||||
variant="contained"
|
||||
color={cancelColor}
|
||||
variant="outlined"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelText ?? t("common.buttons.cancel")}
|
||||
|
||||
@@ -15,9 +15,9 @@ export const FieldLabel = ({ children, required = false, htmlFor }: FieldLabelPr
|
||||
return (
|
||||
<Typography
|
||||
component="label"
|
||||
variant="body1"
|
||||
htmlFor={htmlFor}
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
fontWeight: 500,
|
||||
color: theme.palette.text.secondary,
|
||||
marginBottom: theme.spacing(2),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface TextInputProps extends Omit<TextFieldProps, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -12,47 +13,53 @@ interface TextInputProps extends Omit<TextFieldProps, "label"> {
|
||||
}
|
||||
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
|
||||
{ fieldLabel, required, ...props },
|
||||
{ fieldLabel, required, sx, ...props },
|
||||
ref
|
||||
) {
|
||||
const theme = useTheme();
|
||||
|
||||
const innerSx = {
|
||||
width: "100%",
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: 34,
|
||||
fontSize: typographyLevels.base,
|
||||
overflow: "hidden",
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
|
||||
{
|
||||
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`,
|
||||
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
|
||||
caretColor: theme.palette.text.primary,
|
||||
transition: "background-color 5000s ease-in-out 0s",
|
||||
},
|
||||
};
|
||||
|
||||
const input = (
|
||||
<TextField
|
||||
{...props}
|
||||
inputRef={ref}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: 34,
|
||||
fontSize: typographyLevels.base,
|
||||
overflow: "hidden",
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
|
||||
{
|
||||
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`,
|
||||
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
|
||||
caretColor: theme.palette.text.primary,
|
||||
transition: "background-color 5000s ease-in-out 0s",
|
||||
},
|
||||
}}
|
||||
sx={fieldLabel ? innerSx : { ...innerSx, ...sx }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (fieldLabel) {
|
||||
return (
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
<Stack
|
||||
spacing={theme.spacing(LAYOUT.XXS)}
|
||||
sx={sx}
|
||||
>
|
||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||
{input}
|
||||
</Stack>
|
||||
|
||||
@@ -89,7 +89,9 @@ const uiSlice = createSlice({
|
||||
},
|
||||
|
||||
setTimezone: (state, action: PayloadAction<{ timezone: string }>) => {
|
||||
state.timezone = action.payload.timezone;
|
||||
if (action.payload.timezone) {
|
||||
state.timezone = action.payload.timezone;
|
||||
}
|
||||
},
|
||||
setLanguage: (state, action: PayloadAction<string>) => {
|
||||
state.language = action.payload;
|
||||
|
||||
@@ -18,6 +18,7 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
|
||||
systemEmailHost: data?.systemEmailHost || "",
|
||||
systemEmailUser: data?.systemEmailUser || "",
|
||||
systemEmailAddress: data?.systemEmailAddress || "",
|
||||
systemEmailDisplayName: data?.systemEmailDisplayName || "",
|
||||
systemEmailConnectionHost: data?.systemEmailConnectionHost || "localhost",
|
||||
systemEmailTLSServername: data?.systemEmailTLSServername || "",
|
||||
systemEmailPort: data?.systemEmailPort,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm";
|
||||
import type { AddTeamMemberFormData } from "@/Validation/addTeamMember";
|
||||
import type { UserRole, User } from "@/Types/User";
|
||||
import { usePost } from "@/Hooks/UseApi";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface AddTeamMemberDialogProps {
|
||||
open: boolean;
|
||||
@@ -82,38 +83,40 @@ export const AddTeamMemberDialog = ({
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
mt={theme.spacing(4)}
|
||||
>
|
||||
<Controller
|
||||
name="firstName"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fieldLabel={t("common.form.name.option.firstName.label")}
|
||||
placeholder={t("common.form.name.option.firstName.placeholder")}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="lastName"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fieldLabel={t("common.form.name.option.lastName.label")}
|
||||
placeholder={t("common.form.name.option.lastName.placeholder")}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
<Controller
|
||||
name="firstName"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fieldLabel={t("common.form.name.option.firstName.label")}
|
||||
placeholder={t("common.form.name.option.firstName.placeholder")}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="lastName"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fieldLabel={t("common.form.name.option.lastName.label")}
|
||||
placeholder={t("common.form.name.option.lastName.placeholder")}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
@@ -151,36 +154,41 @@ export const AddTeamMemberDialog = ({
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||
type="password"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="confirm"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
|
||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||
type="password"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||
type="password"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="confirm"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
|
||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||
type="password"
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { UserRole } from "@/Types/User";
|
||||
import { useInviteForm } from "@/Hooks/useInviteForm";
|
||||
import type { InviteFormData } from "@/Validation/invite";
|
||||
import { usePost } from "@/Hooks/UseApi";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST;
|
||||
|
||||
@@ -95,10 +96,7 @@ export const InviteTeamMemberDialog = ({
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
mt={theme.spacing(4)}
|
||||
>
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
|
||||
@@ -359,7 +359,20 @@ const CreateMonitorPage = () => {
|
||||
|
||||
<ConfigBox
|
||||
title={t("pages.createMonitor.form.general.title")}
|
||||
subtitle={t(`pages.createMonitor.form.general.description.${watchedType}`)}
|
||||
subtitle={
|
||||
<Trans
|
||||
i18nKey={`pages.createMonitor.form.general.description.${watchedType}`}
|
||||
components={{
|
||||
gamedigLink: (
|
||||
<Link
|
||||
href="https://github.com/gamedig/node-gamedig/blob/master/GAMES_LIST.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
{/* URL/Host/Container field - not shown for hardware */}
|
||||
|
||||
@@ -20,6 +20,24 @@ interface CardDetailsProps {
|
||||
sx?: object;
|
||||
}
|
||||
|
||||
const SectionHeading = ({ children }: { children: React.ReactNode }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Typography
|
||||
component="h2"
|
||||
variant="eyebrow"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
const Cell = ({ children }: { children: React.ReactNode }) => (
|
||||
<Typography variant="body1">{children}</Typography>
|
||||
);
|
||||
|
||||
export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
@@ -33,22 +51,18 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
sx={sx}
|
||||
>
|
||||
<Typography textTransform={"uppercase"}>
|
||||
{t("pages.incidents.dialog.details.title")}
|
||||
</Typography>
|
||||
<SectionHeading>{t("pages.incidents.dialog.details.title")}</SectionHeading>
|
||||
<BaseBox padding={LAYOUT.MD}>
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
<Typography textTransform={"uppercase"}>
|
||||
{t("pages.incidents.dialog.details.overview")}
|
||||
</Typography>
|
||||
<Divider />
|
||||
|
||||
<SectionHeading>{t("pages.incidents.dialog.details.overview")}</SectionHeading>
|
||||
<Grid
|
||||
container
|
||||
spacing={theme.spacing(LAYOUT.MD)}
|
||||
alignItems="center"
|
||||
>
|
||||
<Grid size={2}>{t("pages.incidents.dialog.details.status")}</Grid>
|
||||
<Grid size={2}>
|
||||
<Cell>{t("pages.incidents.dialog.details.status")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={10}>
|
||||
<ValueLabel
|
||||
value={incident.status ? "negative" : "positive"}
|
||||
@@ -61,15 +75,17 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
</Grid>
|
||||
{monitor && (
|
||||
<>
|
||||
<Grid size={2}>{t("pages.incidents.dialog.details.monitor")}</Grid>
|
||||
<Grid size={2}>
|
||||
<Cell>{t("pages.incidents.dialog.details.monitor")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={10}>
|
||||
<Typography>{monitor.name ?? "N/A"}</Typography>
|
||||
<Cell>{monitor.name ?? "N/A"}</Cell>
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Typography>{t("pages.incidents.dialog.details.url")}</Typography>
|
||||
<Cell>{t("pages.incidents.dialog.details.url")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={10}>
|
||||
<Typography>{monitor.url ?? "N/A"}</Typography>
|
||||
<Cell>{monitor.url ?? "N/A"}</Cell>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
@@ -78,51 +94,48 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
</BaseBox>
|
||||
<BaseBox padding={LAYOUT.MD}>
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
<Typography textTransform={"uppercase"}>
|
||||
{t("pages.incidents.dialog.details.analysis")}
|
||||
</Typography>
|
||||
<Divider />
|
||||
<SectionHeading>{t("pages.incidents.dialog.details.analysis")}</SectionHeading>
|
||||
<Grid
|
||||
container
|
||||
spacing={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
<Grid size={6}>
|
||||
<Typography>{t("pages.incidents.dialog.details.timeline")}</Typography>
|
||||
<Cell>{t("pages.incidents.dialog.details.timeline")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<Typography>{t("pages.incidents.dialog.details.detailsLabel")}</Typography>
|
||||
<Cell>{t("pages.incidents.dialog.details.detailsLabel")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<Divider></Divider>
|
||||
<Divider />
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<Divider></Divider>
|
||||
<Divider />
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Typography>{t("pages.incidents.dialog.details.startedAt")}</Typography>
|
||||
<Cell>{t("pages.incidents.dialog.details.startedAt")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<Typography>
|
||||
<Cell>
|
||||
{formatDateWithTz(incident.startTime, "D MMM YYYY, h:mm A", uiTimezone)}
|
||||
</Typography>
|
||||
</Cell>
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Typography>{t("pages.incidents.dialog.details.statusCode")}</Typography>
|
||||
<Cell>{t("pages.incidents.dialog.details.statusCode")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<Typography>{incident.statusCode ?? "N/A"}</Typography>
|
||||
<Cell>{incident.statusCode ?? "N/A"}</Cell>
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Typography>{t("pages.incidents.dialog.details.downtime")}</Typography>
|
||||
<Cell>{t("pages.incidents.dialog.details.downtime")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<Typography>{getIncidentsDuration(incident)}</Typography>
|
||||
<Cell>{getIncidentsDuration(incident)}</Cell>
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Typography>{t("pages.incidents.dialog.details.message")}</Typography>
|
||||
<Cell>{t("pages.incidents.dialog.details.message")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<Typography>{incident.message ?? "N/A"}</Typography>
|
||||
<Cell>{incident.message ?? "N/A"}</Cell>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Stack>
|
||||
@@ -130,60 +143,53 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
{!incident.status && (
|
||||
<BaseBox padding={LAYOUT.MD}>
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
<Typography textTransform={"uppercase"}>
|
||||
<SectionHeading>
|
||||
{t("pages.incidents.dialog.details.resolutionDetails")}
|
||||
</Typography>
|
||||
<Divider />
|
||||
</SectionHeading>
|
||||
<Grid
|
||||
container
|
||||
spacing={theme.spacing(LAYOUT.MD)}
|
||||
alignItems="center"
|
||||
>
|
||||
<Grid size={2}>
|
||||
<Typography>{t("pages.incidents.dialog.details.resolvedAt")}</Typography>
|
||||
<Cell>{t("pages.incidents.dialog.details.resolvedAt")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={10}>
|
||||
<Typography>
|
||||
<Cell>
|
||||
{incident.endTime
|
||||
? formatDateWithTz(incident.endTime, "D MMM YYYY, h:mm A", uiTimezone)
|
||||
: "N/A"}
|
||||
</Typography>
|
||||
</Cell>
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Typography>
|
||||
{t("pages.incidents.dialog.details.resolutionType")}
|
||||
</Typography>
|
||||
<Cell>{t("pages.incidents.dialog.details.resolutionType")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={10}>
|
||||
<Typography>
|
||||
<Cell>
|
||||
{incident.resolutionType
|
||||
? t(
|
||||
`pages.incidents.dialog.details.resolutionTypes.${incident.resolutionType}`
|
||||
)
|
||||
: "N/A"}
|
||||
</Typography>
|
||||
</Cell>
|
||||
</Grid>
|
||||
{incident.resolvedBy && (
|
||||
<>
|
||||
<Grid size={2}>
|
||||
<Typography>
|
||||
{t("pages.incidents.dialog.details.resolvedBy")}
|
||||
</Typography>
|
||||
<Cell>{t("pages.incidents.dialog.details.resolvedBy")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={10}>
|
||||
<Typography>
|
||||
{incident.resolvedByEmail ?? incident.resolvedBy}
|
||||
</Typography>
|
||||
<Cell>{incident.resolvedByEmail ?? incident.resolvedBy}</Cell>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
{incident.comment && (
|
||||
<>
|
||||
<Grid size={2}>
|
||||
<Typography>{t("pages.incidents.dialog.details.comment")}</Typography>
|
||||
<Cell>{t("pages.incidents.dialog.details.comment")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={10}>
|
||||
<Typography>{incident.comment}</Typography>
|
||||
<Cell>{incident.comment}</Cell>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { BaseBox, ValueLabel } from "@/Components/design-elements";
|
||||
import { BaseBox, Icon, ValueLabel } from "@/Components/design-elements";
|
||||
import { CircleCheck, TriangleAlert, Bell, Wrench, Globe } from "lucide-react";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
@@ -25,6 +24,7 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(2)}
|
||||
minHeight={32}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -32,10 +32,10 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
{icon}
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
<Typography variant="body1">{label}</Typography>
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
variant="body1"
|
||||
fontWeight={600}
|
||||
>
|
||||
{value}
|
||||
@@ -67,15 +67,11 @@ export const SummaryCard = ({
|
||||
>
|
||||
<Typography
|
||||
component="h2"
|
||||
sx={{
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 500,
|
||||
fontSize: 13,
|
||||
}}
|
||||
variant="eyebrow"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Divider />
|
||||
{children}
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
@@ -97,10 +93,13 @@ export const SummaryCardActiveIncidents = ({
|
||||
const activeCount = summary.totalActive;
|
||||
const hasActive = activeCount > 0;
|
||||
const color = hasActive ? theme.palette.error.main : theme.palette.success.main;
|
||||
const icon = hasActive ? (
|
||||
<TriangleAlert color={color} />
|
||||
) : (
|
||||
<CircleCheck color={color} />
|
||||
const icon = (
|
||||
<Box sx={{ color, display: "inline-flex" }}>
|
||||
<Icon
|
||||
icon={hasActive ? TriangleAlert : CircleCheck}
|
||||
size={32}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
const msg = t("pages.incidents.summaryCard.activeIncidents.active", {
|
||||
count: activeCount,
|
||||
@@ -133,10 +132,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
|
||||
container
|
||||
alignItems="center"
|
||||
spacing={2}
|
||||
sx={{
|
||||
width: "100%",
|
||||
py: theme.spacing(0.5),
|
||||
}}
|
||||
sx={{ width: "100%", minHeight: 32 }}
|
||||
>
|
||||
<Grid
|
||||
size={{ xs: 12, lg: 5 }}
|
||||
@@ -147,7 +143,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
|
||||
gap: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
<Globe />
|
||||
<Icon icon={Globe} />
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={500}
|
||||
@@ -159,9 +155,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
|
||||
|
||||
<Grid
|
||||
size={{ xs: 12, md: 6, lg: 3 }}
|
||||
sx={{
|
||||
display: "flex",
|
||||
}}
|
||||
sx={{ display: "flex" }}
|
||||
>
|
||||
<ValueLabel
|
||||
value={incident.status ? "negative" : "positive"}
|
||||
@@ -173,7 +167,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
|
||||
size={{ xs: 12, md: 6, lg: 4 }}
|
||||
sx={{
|
||||
textAlign: { xs: "left", md: "right" },
|
||||
fontWeight: 500,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">{duration}</Typography>
|
||||
@@ -190,22 +184,17 @@ export const SummaryCardLatestIncidents = ({
|
||||
summary,
|
||||
}: SummaryCardLatestIncidentsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const latestIncidents = summary?.latestIncidents ?? [];
|
||||
|
||||
return (
|
||||
<SummaryCard title={t("pages.incidents.summaryCard.latestIncidents.title")}>
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
{latestIncidents.slice(0, 3).map((incident, index) => (
|
||||
<Box key={incident.id}>
|
||||
<SummaryIncidentItem incident={incident} />
|
||||
{index < latestIncidents.length - 1 && (
|
||||
<Divider sx={{ mt: theme.spacing(2) }} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
{latestIncidents.slice(0, 3).map((incident) => (
|
||||
<SummaryIncidentItem
|
||||
key={incident.id}
|
||||
incident={incident}
|
||||
/>
|
||||
))}
|
||||
</SummaryCard>
|
||||
);
|
||||
};
|
||||
@@ -224,17 +213,17 @@ export const SummaryCardStats = ({ summary }: SummaryCardStatsProps) => {
|
||||
return (
|
||||
<SummaryCard title={t("pages.incidents.summaryCard.incidentStats.title")}>
|
||||
<SummaryItem
|
||||
icon={<Bell size={18} />}
|
||||
icon={<Icon icon={Bell} />}
|
||||
label={t("pages.incidents.summaryCard.incidentStats.totalIncidents")}
|
||||
value={summary?.total || 0}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<TriangleAlert size={18} />}
|
||||
icon={<Icon icon={TriangleAlert} />}
|
||||
label={t("pages.incidents.summaryCard.incidentStats.mostAffectedMonitor")}
|
||||
value={mostAffected}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Wrench size={18} />}
|
||||
icon={<Icon icon={Wrench} />}
|
||||
label={t("pages.incidents.summaryCard.incidentStats.avgResolutionTime")}
|
||||
value={
|
||||
summary.total > 0
|
||||
|
||||
@@ -75,7 +75,7 @@ export const ControlsIncidentFilter = ({
|
||||
variant="contained"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
{t("pages.incidents.filters.clearFilters")}
|
||||
{t("common.buttons.clearFilters")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import { Dialog, TextField } from "@/Components/inputs";
|
||||
import { usePut } from "@/Hooks/UseApi";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material";
|
||||
|
||||
interface DialogResolutionProps {
|
||||
open: boolean;
|
||||
@@ -19,7 +17,6 @@ export const DialogResolution = ({
|
||||
onResolved,
|
||||
}: DialogResolutionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [comment, setComment] = useState("");
|
||||
const { put: resolveIncident, loading: isResolving } = usePut();
|
||||
|
||||
@@ -50,22 +47,19 @@ export const DialogResolution = ({
|
||||
onCancel={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
confirmColor="error"
|
||||
cancelColor="primary"
|
||||
loading={isResolving}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<Box sx={{ mt: theme.spacing(4) }}>
|
||||
<TextField
|
||||
fieldLabel={t("pages.incidents.dialog.resolveIncident.option.comment.label")}
|
||||
placeholder={t(
|
||||
"pages.incidents.dialog.resolveIncident.option.comment.placeholder"
|
||||
)}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
fieldLabel={t("pages.incidents.dialog.resolveIncident.option.comment.label")}
|
||||
placeholder={t(
|
||||
"pages.incidents.dialog.resolveIncident.option.comment.placeholder"
|
||||
)}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import { HeaderTimeRange } from "@/Components/common";
|
||||
import { ControlsIncidentFilter } from "@/Pages/Incidents/Components/ControlsIncidentFilter";
|
||||
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident";
|
||||
@@ -186,8 +187,9 @@ const IncidentsPage = () => {
|
||||
{activeIncidentsCount > 0 && (
|
||||
<>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ mb: theme.spacing(4), textTransform: "uppercase" }}
|
||||
variant="eyebrow"
|
||||
color={theme.palette.text.secondary}
|
||||
mb={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
{t("pages.incidents.table.activeIncidents")}
|
||||
</Typography>
|
||||
@@ -208,8 +210,9 @@ const IncidentsPage = () => {
|
||||
)}
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ mb: theme.spacing(4), textTransform: "uppercase" }}
|
||||
variant="eyebrow"
|
||||
color={theme.palette.text.secondary}
|
||||
mb={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
{t("pages.incidents.table.resolvedIncidents")}
|
||||
</Typography>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { NoticeBanner } from "@/Components/design-elements";
|
||||
import { HistogramInfrastructure } from "@/Components/monitors";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -6,6 +8,7 @@ import type { HardwareCheckStats } from "@/Types/Monitor";
|
||||
import { useMemo } from "react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
const formatBytesToMB = (value: number) => `${(value / (1024 * 1024)).toFixed(2)} MB`;
|
||||
|
||||
@@ -60,6 +63,11 @@ const getChartConfigs = (
|
||||
return configs;
|
||||
};
|
||||
|
||||
const hasAnyNetworkTraffic = (checks: HardwareCheckStats[]): boolean =>
|
||||
checks.some((c) =>
|
||||
c.net?.some((iface) => iface.bytesSentPerSecond > 0 || iface.deltaBytesRecv > 0)
|
||||
);
|
||||
|
||||
export const InfraNetworkCharts = ({
|
||||
checks,
|
||||
dateRange,
|
||||
@@ -74,35 +82,43 @@ export const InfraNetworkCharts = ({
|
||||
() => getChartConfigs(theme, checks, t),
|
||||
[theme, checks, t]
|
||||
);
|
||||
const showNoTrafficNotice = chartConfigs.length > 0 && !hasAnyNetworkTraffic(checks);
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={theme.spacing(8)}
|
||||
>
|
||||
{chartConfigs.map((config) => {
|
||||
return (
|
||||
<Grid
|
||||
size={isSmall ? 12 : 6}
|
||||
key={`${config.type}-${config.interfaceName ?? config.idx ?? ""}`}
|
||||
>
|
||||
<HistogramInfrastructure
|
||||
dateRange={dateRange}
|
||||
title={config.title}
|
||||
type={config.type}
|
||||
idx={config.idx}
|
||||
checks={checks}
|
||||
xKey="bucketDate"
|
||||
dataKeys={config.dataKeys}
|
||||
gradient={true}
|
||||
gradientStartColor={config.gradientStartColor}
|
||||
gradientEndColor="#ffffff"
|
||||
strokeColor={config.strokeColor}
|
||||
yAxisFormatter={formatBytesToMB}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
{showNoTrafficNotice && (
|
||||
<NoticeBanner severity="warning">
|
||||
{t("pages.infrastructure.charts.labels.netNoTraffic")}
|
||||
</NoticeBanner>
|
||||
)}
|
||||
<Grid
|
||||
container
|
||||
spacing={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
{chartConfigs.map((config) => {
|
||||
return (
|
||||
<Grid
|
||||
size={isSmall ? 12 : 6}
|
||||
key={`${config.type}-${config.interfaceName ?? config.idx ?? ""}`}
|
||||
>
|
||||
<HistogramInfrastructure
|
||||
dateRange={dateRange}
|
||||
title={config.title}
|
||||
type={config.type}
|
||||
idx={config.idx}
|
||||
checks={checks}
|
||||
xKey="bucketDate"
|
||||
dataKeys={config.dataKeys}
|
||||
gradient={true}
|
||||
gradientStartColor={config.gradientStartColor}
|
||||
gradientEndColor="#ffffff"
|
||||
strokeColor={config.strokeColor}
|
||||
yAxisFormatter={formatBytesToMB}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -128,8 +128,8 @@ export const SettingsPage = () => {
|
||||
timezoneOptions.find((tz) => tz.id === selectedTimezoneId) ?? null;
|
||||
|
||||
const handleTimezoneChange = (newValue: Timezone | null) => {
|
||||
const newId = newValue?.id ?? "";
|
||||
dispatch(setTimezone({ timezone: newId }));
|
||||
if (!newValue?.id) return;
|
||||
dispatch(setTimezone({ timezone: newValue.id }));
|
||||
};
|
||||
|
||||
const handleModeChange = (e: SelectChangeEvent<ThemeMode>) => {
|
||||
@@ -182,6 +182,9 @@ export const SettingsPage = () => {
|
||||
systemEmailRequireTLS: formValues.systemEmailRequireTLS,
|
||||
systemEmailRejectUnauthorized: formValues.systemEmailRejectUnauthorized,
|
||||
...(formValues.systemEmailUser && { systemEmailUser: formValues.systemEmailUser }),
|
||||
...(formValues.systemEmailDisplayName && {
|
||||
systemEmailDisplayName: formValues.systemEmailDisplayName,
|
||||
}),
|
||||
...(formValues.systemEmailTLSServername && {
|
||||
systemEmailTLSServername: formValues.systemEmailTLSServername,
|
||||
}),
|
||||
@@ -577,44 +580,65 @@ export const SettingsPage = () => {
|
||||
href="https://nodemailer.com/smtp/"
|
||||
target="_blank"
|
||||
/>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
fontFamily: theme.typography.fontFamilyMonospace,
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
overflow: "auto",
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
}}
|
||||
>
|
||||
<code>
|
||||
{JSON.stringify(
|
||||
{
|
||||
host: form.watch("systemEmailHost") || "",
|
||||
port: form.watch("systemEmailPort") || "",
|
||||
secure: form.watch("systemEmailSecure") ?? false,
|
||||
auth: {
|
||||
user:
|
||||
form.watch("systemEmailUser") ||
|
||||
form.watch("systemEmailAddress") ||
|
||||
"",
|
||||
pass: "<your_password>",
|
||||
},
|
||||
name: form.watch("systemEmailConnectionHost") || "localhost",
|
||||
pool: form.watch("systemEmailPool") ?? false,
|
||||
tls: {
|
||||
rejectUnauthorized:
|
||||
form.watch("systemEmailRejectUnauthorized") ?? true,
|
||||
ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
|
||||
requireTLS: form.watch("systemEmailRequireTLS") ?? false,
|
||||
servername: form.watch("systemEmailTLSServername") || "",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</code>
|
||||
</Box>
|
||||
{(() => {
|
||||
const address = form.watch("systemEmailAddress") || "";
|
||||
const displayName = form.watch("systemEmailDisplayName")?.trim();
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
component="pre"
|
||||
p={2}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
bgcolor={theme.palette.action.hover}
|
||||
sx={{
|
||||
fontFamily: theme.typography.fontFamilyMonospace,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<code>
|
||||
{JSON.stringify(
|
||||
{
|
||||
host: form.watch("systemEmailHost") || "",
|
||||
port: form.watch("systemEmailPort") || "",
|
||||
secure: form.watch("systemEmailSecure") ?? false,
|
||||
auth: {
|
||||
user: form.watch("systemEmailUser") || address,
|
||||
pass: "<your_password>",
|
||||
},
|
||||
name: form.watch("systemEmailConnectionHost") || "localhost",
|
||||
pool: form.watch("systemEmailPool") ?? false,
|
||||
tls: {
|
||||
rejectUnauthorized:
|
||||
form.watch("systemEmailRejectUnauthorized") ?? true,
|
||||
ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
|
||||
requireTLS: form.watch("systemEmailRequireTLS") ?? false,
|
||||
servername: form.watch("systemEmailTLSServername") || "",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</code>
|
||||
</Box>
|
||||
{address && (
|
||||
<Box
|
||||
component="pre"
|
||||
p={2}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
bgcolor={theme.palette.action.hover}
|
||||
sx={{
|
||||
fontFamily: theme.typography.fontFamilyMonospace,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<code>
|
||||
{`From: ${displayName ? `"${displayName}" <${address}>` : address}`}
|
||||
</code>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Stack>
|
||||
}
|
||||
rightContent={
|
||||
@@ -680,6 +704,24 @@ export const SettingsPage = () => {
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Email Display Name (Optional) */}
|
||||
<Controller
|
||||
name="systemEmailDisplayName"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
fieldLabel={t("pages.settings.form.email.option.displayName.label")}
|
||||
placeholder={t(
|
||||
"pages.settings.form.email.option.displayName.placeholder"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Email User (Optional) */}
|
||||
<Controller
|
||||
name="systemEmailUser"
|
||||
@@ -974,7 +1016,13 @@ export const SettingsPage = () => {
|
||||
setIsDemoMonitorsDialogOpen(false);
|
||||
}}
|
||||
loading={isDeletingAllMonitors}
|
||||
/>
|
||||
confirmColor="error"
|
||||
confirmText={t("common.buttons.removeMonitors")}
|
||||
>
|
||||
<Typography variant="body1">
|
||||
{t("pages.settings.form.removeMonitors.dialog.paragraph")}
|
||||
</Typography>
|
||||
</Dialog>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import RadioGroup from "@mui/material/RadioGroup";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { RadioWithDescription, Select } from "@/Components/inputs";
|
||||
import {
|
||||
STATUS_PAGE_THEMES,
|
||||
STATUS_PAGE_THEME_MODES,
|
||||
type StatusPageTheme,
|
||||
type StatusPageThemeMode,
|
||||
} from "@/Types/StatusPage";
|
||||
|
||||
interface Props {
|
||||
theme: StatusPageTheme;
|
||||
themeMode: StatusPageThemeMode;
|
||||
onThemeChange: (value: StatusPageTheme) => void;
|
||||
onThemeModeChange: (value: StatusPageThemeMode) => void;
|
||||
}
|
||||
|
||||
export const ThemePicker = ({
|
||||
theme,
|
||||
themeMode,
|
||||
onThemeChange,
|
||||
onThemeModeChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const muiTheme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack spacing={muiTheme.spacing(6)}>
|
||||
<RadioGroup
|
||||
value={theme}
|
||||
onChange={(_, value) => onThemeChange(value as StatusPageTheme)}
|
||||
>
|
||||
<Stack spacing={muiTheme.spacing(4)}>
|
||||
{STATUS_PAGE_THEMES.map((option) => (
|
||||
<RadioWithDescription
|
||||
key={option}
|
||||
value={option}
|
||||
label={t(`pages.statusPages.form.theme.options.${option}.name`)}
|
||||
description={t(
|
||||
`pages.statusPages.form.theme.options.${option}.description`
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
|
||||
<Select
|
||||
fieldLabel={t("pages.statusPages.form.themeMode.label")}
|
||||
value={themeMode}
|
||||
onChange={(e) => onThemeModeChange(e.target.value as StatusPageThemeMode)}
|
||||
>
|
||||
{STATUS_PAGE_THEME_MODES.map((mode) => (
|
||||
<MenuItem
|
||||
key={mode}
|
||||
value={mode}
|
||||
>
|
||||
{t(`pages.statusPages.form.themeMode.${mode}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -34,6 +34,7 @@ import timezones from "@/Utils/timezones.json";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import axios from "axios";
|
||||
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
|
||||
import { ThemePicker } from "./Components/ThemePicker";
|
||||
|
||||
const monitorsUrl = (() => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -139,6 +140,8 @@ const CreateStatusPage = () => {
|
||||
fd.append("showUptimePercentage", String(data.showUptimePercentage));
|
||||
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
|
||||
fd.append("showInfrastructure", String(data.showInfrastructure));
|
||||
if (data.theme) fd.append("theme", data.theme);
|
||||
if (data.themeMode) fd.append("themeMode", data.themeMode);
|
||||
|
||||
data.monitors.forEach((monitorId) => {
|
||||
fd.append("monitors[]", monitorId);
|
||||
@@ -438,6 +441,30 @@ const CreateStatusPage = () => {
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.theme.title")}
|
||||
subtitle={t("pages.statusPages.form.theme.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="theme"
|
||||
control={control}
|
||||
render={({ field: themeField }) => (
|
||||
<Controller
|
||||
name="themeMode"
|
||||
control={control}
|
||||
render={({ field: modeField }) => (
|
||||
<ThemePicker
|
||||
theme={themeField.value ?? "refined"}
|
||||
themeMode={modeField.value ?? "auto"}
|
||||
onThemeChange={themeField.onChange}
|
||||
onThemeModeChange={modeField.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.features.title")}
|
||||
subtitle={t("pages.statusPages.form.features.description")}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Settings, ExternalLink } from "lucide-react";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { StatusPage } from "@/Types/StatusPage";
|
||||
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
|
||||
|
||||
interface HeaderStatusPageControlsProps {
|
||||
isAdmin: boolean;
|
||||
@@ -50,7 +50,7 @@ export const HeaderStatusPageControls = ({
|
||||
<Typography
|
||||
onClick={() => {
|
||||
window.open(
|
||||
`/status/public/${statusPage.url}`,
|
||||
`${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">{value}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Grid
|
||||
size={isSmall ? 12 : 4}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
gap: theme.spacing(SPACING.LG),
|
||||
padding: theme.spacing(LAYOUT.XS),
|
||||
borderRight: isSmall ? "none" : `1px solid ${theme.palette.divider}`,
|
||||
borderBottom: isSmall ? `1px solid ${theme.palette.divider}` : "none",
|
||||
"&:last-child": {
|
||||
borderRight: "none",
|
||||
borderBottom: "none",
|
||||
paddingBottom: theme.spacing(SPACING.LG),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
>
|
||||
<Gauge
|
||||
progress={progress}
|
||||
radius={GAUGE_RADIUS}
|
||||
strokeWidth={GAUGE_STROKE_WIDTH}
|
||||
/>
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
</Box>
|
||||
{details && details.length > 0 && (
|
||||
<Box
|
||||
width="100%"
|
||||
paddingX={theme.spacing(LAYOUT.LG)}
|
||||
>
|
||||
{details.map((detail) => (
|
||||
<MetricDetailRow
|
||||
key={detail.label}
|
||||
label={detail.label}
|
||||
value={detail.value}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
type LatestCheck = NonNullable<Monitor["recentChecks"]>[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 (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.noData")}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const metrics: MetricConfig[] = [
|
||||
buildCpuMetric(latestCheck, t),
|
||||
buildMemoryMetric(latestCheck, t),
|
||||
buildDiskMetric(latestCheck, t),
|
||||
].filter((m): m is MetricConfig => m !== null);
|
||||
|
||||
if (metrics.length === 0) {
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.noData")}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
alignItems="center"
|
||||
padding={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
{metrics.map(({ key, label, progress, details }) => (
|
||||
<MetricItem
|
||||
key={key}
|
||||
label={label}
|
||||
progress={progress}
|
||||
details={details}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
mb={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(SPACING.LG)}
|
||||
mb={theme.spacing(SPACING.SM)}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={getMonitorBadgeStyles(monitor.type ?? "", theme)}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{showURL && monitor.url && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.text.secondary}
|
||||
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{monitor.url}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<StatusLabel status={monitor.status} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
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 <InfrastructureMetrics monitor={monitor} />;
|
||||
}
|
||||
|
||||
if (statusPage.showCharts === false) return null;
|
||||
|
||||
const checks = monitor.checks?.slice().reverse() ?? [];
|
||||
return (
|
||||
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1, mb: theme.spacing(SPACING.LG) }}>
|
||||
{chartType === "histogram" ? (
|
||||
<HistogramResponseTime
|
||||
height={{ xs: 50, md: 100 }}
|
||||
gap={{ xs: theme.spacing(SPACING.SM), md: theme.spacing(LAYOUT.SM) }}
|
||||
checks={checks}
|
||||
/>
|
||||
) : (
|
||||
<HeatmapResponseTime checks={checks} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
{statusPage.showCharts && (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<Typography>{t("pages.statusPages.monitorsList.chartTypeHeatmap")}</Typography>
|
||||
<SwitchComponent
|
||||
dualOption
|
||||
value={chartType}
|
||||
checked={chartType === "histogram"}
|
||||
onChange={(e) => setChartType(e.target.checked ? "histogram" : "heatmap")}
|
||||
/>
|
||||
<Typography>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{monitors.map((monitor) => (
|
||||
<BaseBox
|
||||
key={monitor.id}
|
||||
padding={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
<MonitorHeader
|
||||
monitor={monitor}
|
||||
showURL={showURL}
|
||||
/>
|
||||
<MonitorContent
|
||||
monitor={monitor}
|
||||
statusPage={statusPage}
|
||||
chartType={chartType}
|
||||
/>
|
||||
</BaseBox>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -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: <AlertTriangle size={24} />,
|
||||
};
|
||||
|
||||
// Handle empty monitors array
|
||||
if (monitors.length === 0) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.noMonitors");
|
||||
monitorsStatus.color = theme.palette.warning.main;
|
||||
monitorsStatus.icon = <CircleX size={24} />;
|
||||
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 = <CircleCheck size={24} />;
|
||||
} else if (allOf("breached")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.allBreached");
|
||||
monitorsStatus.color = theme.palette.error.main;
|
||||
monitorsStatus.icon = <ShieldAlert size={24} />;
|
||||
} else if (allOf("maintenance")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.allMaintenance");
|
||||
monitorsStatus.color = theme.palette.warning.main;
|
||||
monitorsStatus.icon = <Wrench size={24} />;
|
||||
} else if (allOf("down")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.allDown");
|
||||
monitorsStatus.color = theme.palette.error.main;
|
||||
monitorsStatus.icon = <CircleX size={24} />;
|
||||
} else if (allOf("paused")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.allPaused");
|
||||
monitorsStatus.color = theme.palette.warning.main;
|
||||
monitorsStatus.icon = <PauseCircle size={24} />;
|
||||
} else if (allOf("initializing")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.allInitializing");
|
||||
monitorsStatus.color = theme.palette.warning.main;
|
||||
monitorsStatus.icon = <Loader size={24} />;
|
||||
|
||||
// Breached takes highest priority in mixed states
|
||||
} else if (someOf("breached") && someOf("down")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.breachedAndDown");
|
||||
monitorsStatus.color = theme.palette.error.main;
|
||||
monitorsStatus.icon = <ShieldAlert size={24} />;
|
||||
} else if (someOf("breached")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.breached");
|
||||
monitorsStatus.color = theme.palette.error.main;
|
||||
monitorsStatus.icon = <ShieldAlert size={24} />;
|
||||
|
||||
// Maintenance combinations
|
||||
} else if (someOf("maintenance") && someOf("down")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.maintenanceAndDown");
|
||||
monitorsStatus.color = theme.palette.error.main;
|
||||
monitorsStatus.icon = <Wrench size={24} />;
|
||||
} else if (someOf("maintenance") && noneOf("down")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.maintenance");
|
||||
monitorsStatus.color = theme.palette.warning.main;
|
||||
monitorsStatus.icon = <Wrench size={24} />;
|
||||
|
||||
// Degraded (some down, no maintenance/breached)
|
||||
} else if (someOf("down")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.degraded");
|
||||
monitorsStatus.color = theme.palette.warning.main;
|
||||
monitorsStatus.icon = <AlertTriangle size={24} />;
|
||||
|
||||
// Some Paused
|
||||
} else if (someOf("paused")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.partiallyPaused");
|
||||
monitorsStatus.color = theme.palette.warning.main;
|
||||
monitorsStatus.icon = <PauseCircle size={24} />;
|
||||
|
||||
// Initializing
|
||||
} else if (someOf("initializing")) {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.initializing");
|
||||
monitorsStatus.color = theme.palette.info.main;
|
||||
monitorsStatus.icon = <Loader size={24} />;
|
||||
} else {
|
||||
monitorsStatus.msg = t("pages.statusPages.statusBar.unknown");
|
||||
monitorsStatus.color = theme.palette.warning.main;
|
||||
}
|
||||
|
||||
return monitorsStatus;
|
||||
};
|
||||
|
||||
interface StatusBarProps {
|
||||
monitors: Monitor[];
|
||||
}
|
||||
|
||||
export const StatusBar = ({ monitors }: StatusBarProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const monitorsStatus = getMonitorStatus(monitors, theme, t);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
gap={theme.spacing(2)}
|
||||
height={theme.spacing(30)}
|
||||
bgcolor={monitorsStatus.color}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
>
|
||||
{monitorsStatus.icon}
|
||||
<Typography>{monitorsStatus.msg}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -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<ThemedRendererProps>
|
||||
> = {
|
||||
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 = (
|
||||
<ThemedRenderer
|
||||
statusPage={statusPage}
|
||||
monitors={monitors}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<StatusPageThemeProvider
|
||||
theme={statusPage.theme}
|
||||
themeMode={statusPage.themeMode}
|
||||
paintBody
|
||||
>
|
||||
{themedRenderer}
|
||||
</StatusPageThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<BasePage
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
sx={sx}
|
||||
breadcrumbOverride={isPublic ? [] : undefined}
|
||||
breadcrumbOverride={undefined}
|
||||
sx={{ flex: 1, minHeight: 0 }}
|
||||
>
|
||||
<HeaderStatusPageControls
|
||||
isAdmin={isAdmin}
|
||||
statusPage={statusPage}
|
||||
isPublic={isPublic}
|
||||
/>
|
||||
{logoSrc && (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alignSelf={"flex-start"}
|
||||
alt={statusPage.companyName}
|
||||
sx={{
|
||||
maxHeight: 120,
|
||||
maxWidth: "100%",
|
||||
objectFit: "contain",
|
||||
mb: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<StatusBar monitors={monitors} />
|
||||
<MonitorsList
|
||||
statusPage={statusPage}
|
||||
monitors={monitors}
|
||||
isPublic={false}
|
||||
/>
|
||||
<StatusPageThemeProvider
|
||||
theme={statusPage.theme}
|
||||
themeMode={statusPage.themeMode}
|
||||
transparent
|
||||
>
|
||||
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
|
||||
</StatusPageThemeProvider>
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
minHeight: "calc(100vh - 180px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: "12px",
|
||||
overflow: "hidden",
|
||||
border: `1px solid ${tokens.border}`,
|
||||
background: tokens.surface,
|
||||
color: tokens.text,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.25,
|
||||
padding: "10px 14px",
|
||||
background: tokens.bg,
|
||||
borderBottom: `1px solid ${tokens.border}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", gap: 0.75 }}>
|
||||
<Box
|
||||
sx={{ width: 12, height: 12, borderRadius: "50%", background: "#ff5f57" }}
|
||||
/>
|
||||
<Box
|
||||
sx={{ width: 12, height: 12, borderRadius: "50%", background: "#febc2e" }}
|
||||
/>
|
||||
<Box
|
||||
sx={{ width: 12, height: 12, borderRadius: "50%", background: "#28c840" }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
padding: "4px 12px",
|
||||
marginLeft: 2,
|
||||
borderRadius: "6px",
|
||||
background: tokens.surface,
|
||||
border: `1px solid ${tokens.border}`,
|
||||
fontFamily: "ui-monospace, Menlo, monospace",
|
||||
fontSize: 12,
|
||||
color: tokens.textMuted,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{url}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minHeight: 0 }}>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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<StatusPageThemeContextValue | null>(null);
|
||||
|
||||
const resolveSystemMode = (): ResolvedMode => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return "light";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
};
|
||||
|
||||
interface Props {
|
||||
theme?: StatusPageTheme;
|
||||
themeMode?: StatusPageThemeMode;
|
||||
/**
|
||||
* When true, paint the document body with the theme background so it
|
||||
* covers beyond the provider's wrapper (useful on the public route
|
||||
* where no admin shell sits behind). Defaults to false for admin
|
||||
* previews that already have an app background.
|
||||
*/
|
||||
paintBody?: boolean;
|
||||
/**
|
||||
* When true, the provider's wrapper div does NOT paint its own
|
||||
* background or set a min-height. Use this when the wrapper is
|
||||
* inside a clipped container (e.g. BrowserFrame) that already sets
|
||||
* the visible background, so the wrapper's bg doesn't bleed past the
|
||||
* container's rounded corners.
|
||||
*/
|
||||
transparent?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const StatusPageThemeProvider = ({
|
||||
theme,
|
||||
themeMode,
|
||||
paintBody = false,
|
||||
transparent = false,
|
||||
children,
|
||||
}: Props) => {
|
||||
const resolvedTheme = resolveStatusPageTheme(theme);
|
||||
const resolvedThemeMode = resolveStatusPageThemeMode(themeMode);
|
||||
|
||||
const [systemMode, setSystemMode] = useState<ResolvedMode>(resolveSystemMode);
|
||||
|
||||
useEffect(() => {
|
||||
if (resolvedThemeMode !== "auto") return;
|
||||
if (typeof window === "undefined" || !window.matchMedia) return;
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
// Reconcile on first paint whenever we (re-)enter auto mode — only
|
||||
// when it disagrees with the stored value, so we don't trigger an
|
||||
// extra render on the common hot path.
|
||||
const next: ResolvedMode = mq.matches ? "dark" : "light";
|
||||
setSystemMode((prev) => (prev === next ? prev : next));
|
||||
const handler = (e: MediaQueryListEvent) =>
|
||||
setSystemMode(e.matches ? "dark" : "light");
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, [resolvedThemeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paintBody || typeof document === "undefined") return;
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
const prev = {
|
||||
htmlBg: html.style.background,
|
||||
bodyBg: body.style.background,
|
||||
bodyMargin: body.style.margin,
|
||||
htmlColorScheme: html.style.colorScheme,
|
||||
};
|
||||
const resolvedMode = resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
|
||||
const bg = themeTokens[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 (
|
||||
<StatusPageThemeContext.Provider value={value}>
|
||||
<div
|
||||
style={
|
||||
transparent
|
||||
? undefined
|
||||
: {
|
||||
background: tokens.bg,
|
||||
minHeight: "100vh",
|
||||
}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StatusPageThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useStatusPageTheme = (): StatusPageThemeContextValue => {
|
||||
const ctx = useContext(StatusPageThemeContext);
|
||||
if (!ctx) {
|
||||
return {
|
||||
theme: DEFAULT_STATUS_PAGE_THEME,
|
||||
mode: resolveSystemMode(),
|
||||
tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()],
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
@@ -0,0 +1,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 (
|
||||
<Box sx={styles.page}>
|
||||
<Box
|
||||
component="header"
|
||||
sx={styles.top}
|
||||
>
|
||||
<Box sx={styles.brand}>
|
||||
{logoSrc ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
sx={styles.logoImg}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={styles.logoConic}>{monoFirstChar(statusPage.companyName)}</Box>
|
||||
)}
|
||||
{statusPage.companyName}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={styles.hero}>
|
||||
<Box
|
||||
component="h1"
|
||||
sx={styles.heroTitle}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.heroCheck(overall.tone)}
|
||||
>
|
||||
{overall.icon}
|
||||
</Box>
|
||||
{overall.message}
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.heroSub}
|
||||
>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitors.length,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{statusPage.showCharts && (
|
||||
<Box sx={styles.chartSwitchWrap}>
|
||||
<Box
|
||||
sx={styles.chartSwitch}
|
||||
role="radiogroup"
|
||||
>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "heatmap"}
|
||||
onClick={() => setChartMode("heatmap")}
|
||||
sx={styles.chartSwitchButton(chartMode === "heatmap")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
|
||||
</Box>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "histogram"}
|
||||
onClick={() => setChartMode("histogram")}
|
||||
sx={styles.chartSwitchButton(chartMode === "histogram")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
component="ul"
|
||||
sx={styles.monitorList}
|
||||
>
|
||||
{monitors.map((monitor) => {
|
||||
const isHardware = monitor.type === "hardware";
|
||||
const showInfra = isHardware && statusPage.showInfrastructure !== false;
|
||||
const showChart = !isHardware && statusPage.showCharts !== false;
|
||||
const badgeTone = monitorBadgeTone(monitor.status);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="li"
|
||||
key={monitor.id}
|
||||
sx={styles.card}
|
||||
>
|
||||
<Box sx={styles.cardRow}>
|
||||
<Box sx={styles.cardLeft}>
|
||||
<Box sx={styles.monitorName}>{monitor.name}</Box>
|
||||
<Box sx={styles.monitorMeta}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={isHardware ? styles.pillHardware : styles.pill}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
</Box>
|
||||
{monitor.url && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.monitorUrl}
|
||||
title={monitor.url}
|
||||
>
|
||||
{monitor.url}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.badge(badgeTone)}
|
||||
>
|
||||
{t(statusBadgeKey[monitor.status])}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{showInfra && (
|
||||
<ThemedInfrastructure
|
||||
monitor={monitor}
|
||||
sxApi={{
|
||||
containerSx: styles.infra,
|
||||
emptySx: styles.infraEmpty,
|
||||
gaugeSx: styles.gauge,
|
||||
gaugeLabelSx: styles.gaugeLabel,
|
||||
gaugeValueSx: styles.gaugeValue,
|
||||
gaugeBarSx: styles.gaugeBar,
|
||||
gaugeFillSx: styles.gaugeFill,
|
||||
gaugeSubSx: styles.gaugeSub,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showChart &&
|
||||
(chartMode === "heatmap" ? (
|
||||
<ThemedHeatmap
|
||||
checks={monitor.recentChecks ?? []}
|
||||
containerSx={styles.heatmap}
|
||||
cellSx={styles.heatmapCell}
|
||||
/>
|
||||
) : (
|
||||
<ThemedHistogram
|
||||
checks={monitor.recentChecks ?? []}
|
||||
containerSx={styles.histogram}
|
||||
barSx={styles.bar}
|
||||
statsSx={styles.chartStats}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
sx={styles.footer}
|
||||
>
|
||||
{t("pages.statusPages.footer.poweredBy")}{" "}
|
||||
<a
|
||||
href="https://checkmate.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Checkmate
|
||||
</a>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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<Theme>;
|
||||
top: SxProps<Theme>;
|
||||
brand: SxProps<Theme>;
|
||||
logoConic: SxProps<Theme>;
|
||||
logoImg: SxProps<Theme>;
|
||||
hero: SxProps<Theme>;
|
||||
heroTitle: SxProps<Theme>;
|
||||
heroCheck: (tone: OverallTone) => SxProps<Theme>;
|
||||
heroSub: SxProps<Theme>;
|
||||
chartSwitchWrap: SxProps<Theme>;
|
||||
chartSwitch: SxProps<Theme>;
|
||||
chartSwitchButton: (active: boolean) => SxProps<Theme>;
|
||||
monitorList: SxProps<Theme>;
|
||||
card: SxProps<Theme>;
|
||||
cardRow: SxProps<Theme>;
|
||||
cardLeft: SxProps<Theme>;
|
||||
monitorName: SxProps<Theme>;
|
||||
monitorMeta: SxProps<Theme>;
|
||||
pill: SxProps<Theme>;
|
||||
pillHardware: SxProps<Theme>;
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: BoldHeatCell) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: BoldBarKind, heightPct: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
gauge: SxProps<Theme>;
|
||||
gaugeLabel: SxProps<Theme>;
|
||||
gaugeValue: SxProps<Theme>;
|
||||
gaugeBar: SxProps<Theme>;
|
||||
gaugeFill: (level: BoldGaugeFill, widthPct: number) => SxProps<Theme>;
|
||||
gaugeSub: SxProps<Theme>;
|
||||
footer: SxProps<Theme>;
|
||||
}
|
||||
|
||||
export const boldStyles = (
|
||||
tokens: StatusPageThemeTokens,
|
||||
isDark: boolean
|
||||
): BoldStyles => {
|
||||
const heatCellBg: Record<BoldHeatCell, string> = {
|
||||
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<BoldHeatCell, string> = {
|
||||
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<BoldBarKind, string> = {
|
||||
up: tokens.up,
|
||||
down: tokens.down,
|
||||
empty: tokens.border,
|
||||
};
|
||||
|
||||
const gaugeFillBg: Record<BoldGaugeFill, string> = {
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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 (
|
||||
<Box sx={styles.page}>
|
||||
<Box
|
||||
component="header"
|
||||
sx={styles.top}
|
||||
>
|
||||
<Box sx={styles.brandWrap}>
|
||||
{logoSrc && (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
sx={styles.logoImg}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
component="h2"
|
||||
sx={styles.brandEyebrow}
|
||||
>
|
||||
{statusPage.companyName}
|
||||
</Box>
|
||||
<Box
|
||||
component="h1"
|
||||
sx={styles.brandTitle}
|
||||
>
|
||||
{t("pages.statusPages.editorial.reportTitle")}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.statusLine}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.statusDot(overall.tone)}
|
||||
/>
|
||||
{overall.message}
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.dateline}
|
||||
>
|
||||
{todayLabel} ·{" "}
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitors.length,
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{statusPage.showCharts && (
|
||||
<Box sx={styles.chartSwitchWrap}>
|
||||
<Box
|
||||
sx={styles.chartSwitch}
|
||||
role="radiogroup"
|
||||
>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "heatmap"}
|
||||
onClick={() => setChartMode("heatmap")}
|
||||
sx={styles.chartSwitchButton(chartMode === "heatmap")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
|
||||
</Box>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "histogram"}
|
||||
onClick={() => setChartMode("histogram")}
|
||||
sx={styles.chartSwitchButton(chartMode === "histogram")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
component="ul"
|
||||
sx={styles.monitorList}
|
||||
>
|
||||
{monitors.map((monitor) => {
|
||||
const isHardware = monitor.type === "hardware";
|
||||
const showInfra = isHardware && statusPage.showInfrastructure !== false;
|
||||
const showChart = !isHardware && statusPage.showCharts !== false;
|
||||
const badgeTone = monitorBadgeTone(monitor.status);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="li"
|
||||
key={monitor.id}
|
||||
sx={styles.card}
|
||||
>
|
||||
<Box sx={styles.cardRow}>
|
||||
<Box sx={styles.cardLeft}>
|
||||
<Box sx={styles.monitorName}>{monitor.name}</Box>
|
||||
<Box sx={styles.monitorMeta}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={isHardware ? styles.pillHardware : styles.pill}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
</Box>
|
||||
{monitor.url && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.monitorUrl}
|
||||
title={monitor.url}
|
||||
>
|
||||
{monitor.url}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.badge(badgeTone)}
|
||||
>
|
||||
{t(statusBadgeKey[monitor.status])}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{showInfra && (
|
||||
<ThemedInfrastructure
|
||||
monitor={monitor}
|
||||
sxApi={{
|
||||
containerSx: styles.infra,
|
||||
emptySx: styles.infraEmpty,
|
||||
gaugeSx: styles.gauge,
|
||||
gaugeLabelSx: styles.gaugeLabel,
|
||||
gaugeValueSx: styles.gaugeValue,
|
||||
gaugeBarSx: styles.gaugeBar,
|
||||
gaugeFillSx: styles.gaugeFill,
|
||||
gaugeSubSx: styles.gaugeSub,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showChart &&
|
||||
(chartMode === "heatmap" ? (
|
||||
<ThemedHeatmap
|
||||
checks={monitor.recentChecks ?? []}
|
||||
containerSx={styles.heatmap}
|
||||
cellSx={styles.heatmapCell}
|
||||
/>
|
||||
) : (
|
||||
<ThemedHistogram
|
||||
checks={monitor.recentChecks ?? []}
|
||||
containerSx={styles.histogram}
|
||||
barSx={styles.bar}
|
||||
statsSx={styles.chartStats}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
sx={styles.footer}
|
||||
>
|
||||
{t("pages.statusPages.footer.poweredBy")}{" "}
|
||||
<a
|
||||
href="https://checkmate.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Checkmate
|
||||
</a>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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<Theme>;
|
||||
top: SxProps<Theme>;
|
||||
brandWrap: SxProps<Theme>;
|
||||
logoImg: SxProps<Theme>;
|
||||
brandEyebrow: SxProps<Theme>;
|
||||
brandTitle: SxProps<Theme>;
|
||||
statusLine: SxProps<Theme>;
|
||||
statusDot: (tone: OverallTone) => SxProps<Theme>;
|
||||
dateline: SxProps<Theme>;
|
||||
chartSwitchWrap: SxProps<Theme>;
|
||||
chartSwitch: SxProps<Theme>;
|
||||
chartSwitchButton: (active: boolean) => SxProps<Theme>;
|
||||
monitorList: SxProps<Theme>;
|
||||
card: SxProps<Theme>;
|
||||
cardRow: SxProps<Theme>;
|
||||
cardLeft: SxProps<Theme>;
|
||||
monitorName: SxProps<Theme>;
|
||||
monitorMeta: SxProps<Theme>;
|
||||
pill: SxProps<Theme>;
|
||||
pillHardware: SxProps<Theme>;
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: EditorialHeatCell) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: EditorialBarKind, heightPct: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
gauge: SxProps<Theme>;
|
||||
gaugeLabel: SxProps<Theme>;
|
||||
gaugeValue: SxProps<Theme>;
|
||||
gaugeBar: SxProps<Theme>;
|
||||
gaugeFill: (level: EditorialGaugeFill, widthPct: number) => SxProps<Theme>;
|
||||
gaugeSub: SxProps<Theme>;
|
||||
footer: SxProps<Theme>;
|
||||
}
|
||||
|
||||
export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles => {
|
||||
const heatCellBg: Record<EditorialHeatCell, string> = {
|
||||
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<EditorialBarKind, string> = {
|
||||
up: tokens.up,
|
||||
down: tokens.down,
|
||||
empty: tokens.border,
|
||||
};
|
||||
|
||||
const gaugeFillBg: Record<EditorialGaugeFill, string> = {
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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 (
|
||||
<Box sx={styles.page}>
|
||||
<Box
|
||||
component="header"
|
||||
sx={styles.top}
|
||||
>
|
||||
<Box sx={styles.brand}>
|
||||
{logoSrc ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
sx={styles.logoImg}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={styles.logoGrad}>{monoFirstChar(statusPage.companyName)}</Box>
|
||||
)}
|
||||
{statusPage.companyName}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={styles.hero}>
|
||||
<Box sx={styles.pulse(overall.tone)} />
|
||||
<Box sx={styles.statusCopy}>
|
||||
<Box
|
||||
component="h1"
|
||||
sx={styles.heroTitle}
|
||||
>
|
||||
{overall.message}
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.heroSub}
|
||||
>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitors.length,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
|
||||
</Box>
|
||||
|
||||
{statusPage.showCharts && (
|
||||
<Box sx={styles.chartSwitchWrap}>
|
||||
<Box
|
||||
sx={styles.chartSwitch}
|
||||
role="radiogroup"
|
||||
>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "heatmap"}
|
||||
onClick={() => setChartMode("heatmap")}
|
||||
sx={styles.chartSwitchButton(chartMode === "heatmap")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
|
||||
</Box>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "histogram"}
|
||||
onClick={() => setChartMode("histogram")}
|
||||
sx={styles.chartSwitchButton(chartMode === "histogram")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
component="ul"
|
||||
sx={styles.monitorList}
|
||||
>
|
||||
{monitors.map((monitor) => {
|
||||
const isHardware = monitor.type === "hardware";
|
||||
const showInfra = isHardware && statusPage.showInfrastructure !== false;
|
||||
const showChart = !isHardware && statusPage.showCharts !== false;
|
||||
const badgeTone = monitorBadgeTone(monitor.status);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="li"
|
||||
key={monitor.id}
|
||||
sx={styles.card}
|
||||
>
|
||||
<Box sx={styles.cardRow}>
|
||||
<Box sx={styles.cardLeft}>
|
||||
<Box sx={styles.monitorName}>{monitor.name}</Box>
|
||||
<Box sx={styles.monitorMeta}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={isHardware ? styles.pillHardware : styles.pill}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
</Box>
|
||||
{monitor.url && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.monitorUrl}
|
||||
title={monitor.url}
|
||||
>
|
||||
{monitor.url}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.badge(badgeTone)}
|
||||
>
|
||||
{t(statusBadgeKey[monitor.status])}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{showInfra && (
|
||||
<ThemedInfrastructure
|
||||
monitor={monitor}
|
||||
sxApi={{
|
||||
containerSx: styles.infra,
|
||||
emptySx: styles.infraEmpty,
|
||||
gaugeSx: styles.gauge,
|
||||
gaugeLabelSx: styles.gaugeLabel,
|
||||
gaugeValueSx: styles.gaugeValue,
|
||||
gaugeBarSx: styles.gaugeBar,
|
||||
gaugeFillSx: styles.gaugeFill,
|
||||
gaugeSubSx: styles.gaugeSub,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showChart &&
|
||||
(chartMode === "heatmap" ? (
|
||||
<ThemedHeatmap
|
||||
checks={monitor.recentChecks ?? []}
|
||||
containerSx={styles.heatmap}
|
||||
cellSx={styles.heatmapCell}
|
||||
/>
|
||||
) : (
|
||||
<ThemedHistogram
|
||||
checks={monitor.recentChecks ?? []}
|
||||
containerSx={styles.histogram}
|
||||
barSx={styles.bar}
|
||||
statsSx={styles.chartStats}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
sx={styles.footer}
|
||||
>
|
||||
{t("pages.statusPages.footer.poweredBy")}{" "}
|
||||
<a
|
||||
href="https://checkmate.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Checkmate
|
||||
</a>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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<Theme>;
|
||||
top: SxProps<Theme>;
|
||||
brand: SxProps<Theme>;
|
||||
logoGrad: SxProps<Theme>;
|
||||
logoImg: SxProps<Theme>;
|
||||
hero: SxProps<Theme>;
|
||||
pulse: (tone: OverallTone) => SxProps<Theme>;
|
||||
statusCopy: SxProps<Theme>;
|
||||
heroTitle: SxProps<Theme>;
|
||||
heroSub: SxProps<Theme>;
|
||||
heroIcon: (tone: OverallTone) => SxProps<Theme>;
|
||||
chartSwitchWrap: SxProps<Theme>;
|
||||
chartSwitch: SxProps<Theme>;
|
||||
chartSwitchButton: (active: boolean) => SxProps<Theme>;
|
||||
monitorList: SxProps<Theme>;
|
||||
card: SxProps<Theme>;
|
||||
cardRow: SxProps<Theme>;
|
||||
cardLeft: SxProps<Theme>;
|
||||
monitorName: SxProps<Theme>;
|
||||
monitorMeta: SxProps<Theme>;
|
||||
pill: SxProps<Theme>;
|
||||
pillHardware: SxProps<Theme>;
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: ModernHeatCell) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: ModernBarKind, heightPct: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
gauge: SxProps<Theme>;
|
||||
gaugeLabel: SxProps<Theme>;
|
||||
gaugeValue: SxProps<Theme>;
|
||||
gaugeBar: SxProps<Theme>;
|
||||
gaugeFill: (level: ModernGaugeFill, widthPct: number) => SxProps<Theme>;
|
||||
gaugeSub: SxProps<Theme>;
|
||||
footer: SxProps<Theme>;
|
||||
}
|
||||
|
||||
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<ModernHeatCell, string> = {
|
||||
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<ModernBarKind, string> = {
|
||||
up: tokens.up,
|
||||
down: tokens.down,
|
||||
empty: tokens.border,
|
||||
};
|
||||
|
||||
const gaugeFillBg: Record<ModernGaugeFill, string> = {
|
||||
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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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 (
|
||||
<Box sx={styles.page}>
|
||||
<Box
|
||||
component="header"
|
||||
sx={styles.top}
|
||||
>
|
||||
<Box sx={styles.brand}>
|
||||
{logoSrc ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
sx={styles.logoImg}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={styles.logoMono}>{monoFirstChar(statusPage.companyName)}</Box>
|
||||
)}
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.company}
|
||||
>
|
||||
{statusPage.companyName}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={styles.hero}>
|
||||
<Box sx={styles.statusDot(overall.tone)} />
|
||||
<Box sx={styles.statusCopy}>
|
||||
<Box
|
||||
component="h1"
|
||||
sx={styles.heroTitle}
|
||||
>
|
||||
{overall.message}
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.heroSub}
|
||||
>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitors.length,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
|
||||
</Box>
|
||||
|
||||
{statusPage.showCharts && (
|
||||
<Box sx={styles.chartSwitchWrap}>
|
||||
<Box
|
||||
sx={styles.chartSwitch}
|
||||
role="radiogroup"
|
||||
>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "heatmap"}
|
||||
onClick={() => setChartMode("heatmap")}
|
||||
sx={styles.chartSwitchButton(chartMode === "heatmap")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
|
||||
</Box>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "histogram"}
|
||||
onClick={() => setChartMode("histogram")}
|
||||
sx={styles.chartSwitchButton(chartMode === "histogram")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
component="ul"
|
||||
sx={styles.monitorList}
|
||||
>
|
||||
{monitors.map((monitor) => {
|
||||
const isHardware = monitor.type === "hardware";
|
||||
const showInfra = isHardware && statusPage.showInfrastructure !== false;
|
||||
const showChart = !isHardware && statusPage.showCharts !== false;
|
||||
const badgeTone = monitorBadgeTone(monitor.status);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="li"
|
||||
key={monitor.id}
|
||||
sx={styles.card}
|
||||
>
|
||||
<Box sx={styles.cardRow}>
|
||||
<Box sx={styles.cardLeft}>
|
||||
<Box sx={styles.monitorName}>{monitor.name}</Box>
|
||||
<Box sx={styles.monitorMeta}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={isHardware ? styles.pillHardware : styles.pill}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
</Box>
|
||||
{monitor.url && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.monitorUrl}
|
||||
title={monitor.url}
|
||||
>
|
||||
{monitor.url}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.badge(badgeTone)}
|
||||
>
|
||||
{t(statusBadgeKey[monitor.status])}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{showInfra && (
|
||||
<ThemedInfrastructure
|
||||
monitor={monitor}
|
||||
sxApi={{
|
||||
containerSx: styles.infra,
|
||||
emptySx: styles.infraEmpty,
|
||||
gaugeSx: styles.gauge,
|
||||
gaugeLabelSx: styles.gaugeLabel,
|
||||
gaugeValueSx: styles.gaugeValue,
|
||||
gaugeBarSx: styles.gaugeBar,
|
||||
gaugeFillSx: styles.gaugeFill,
|
||||
gaugeSubSx: styles.gaugeSub,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showChart &&
|
||||
(chartMode === "heatmap" ? (
|
||||
<ThemedHeatmap
|
||||
checks={monitor.recentChecks ?? []}
|
||||
containerSx={styles.heatmap}
|
||||
cellSx={styles.heatmapCell}
|
||||
/>
|
||||
) : (
|
||||
<ThemedHistogram
|
||||
checks={monitor.recentChecks ?? []}
|
||||
containerSx={styles.histogram}
|
||||
barSx={styles.bar}
|
||||
statsSx={styles.chartStats}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
sx={styles.footer}
|
||||
>
|
||||
{t("pages.statusPages.footer.poweredBy")}{" "}
|
||||
<a
|
||||
href="https://checkmate.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Checkmate
|
||||
</a>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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<Theme>;
|
||||
top: SxProps<Theme>;
|
||||
brand: SxProps<Theme>;
|
||||
logoMono: SxProps<Theme>;
|
||||
logoImg: SxProps<Theme>;
|
||||
company: SxProps<Theme>;
|
||||
hero: SxProps<Theme>;
|
||||
statusDot: (tone: OverallTone) => SxProps<Theme>;
|
||||
statusCopy: SxProps<Theme>;
|
||||
heroTitle: SxProps<Theme>;
|
||||
heroSub: SxProps<Theme>;
|
||||
heroIcon: (tone: OverallTone) => SxProps<Theme>;
|
||||
chartSwitchWrap: SxProps<Theme>;
|
||||
chartSwitch: SxProps<Theme>;
|
||||
chartSwitchButton: (active: boolean) => SxProps<Theme>;
|
||||
monitorList: SxProps<Theme>;
|
||||
card: SxProps<Theme>;
|
||||
cardRow: SxProps<Theme>;
|
||||
cardLeft: SxProps<Theme>;
|
||||
monitorName: SxProps<Theme>;
|
||||
monitorMeta: SxProps<Theme>;
|
||||
pill: SxProps<Theme>;
|
||||
pillHardware: SxProps<Theme>;
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: RefinedHeatCell) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: RefinedBarKind, heightPct: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
gauge: SxProps<Theme>;
|
||||
gaugeLabel: SxProps<Theme>;
|
||||
gaugeValue: SxProps<Theme>;
|
||||
gaugeBar: SxProps<Theme>;
|
||||
gaugeFill: (level: RefinedGaugeFill, widthPct: number) => SxProps<Theme>;
|
||||
gaugeSub: SxProps<Theme>;
|
||||
footer: SxProps<Theme>;
|
||||
}
|
||||
|
||||
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<RefinedHeatCell, string> = {
|
||||
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<RefinedBarKind, string> = {
|
||||
up: tokens.up,
|
||||
down: tokens.down,
|
||||
empty: tokens.border,
|
||||
};
|
||||
|
||||
const gaugeFillBg: Record<RefinedGaugeFill, string> = {
|
||||
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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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<Theme>;
|
||||
cellSx: (kind: HeatCellKind) => SxProps<Theme>;
|
||||
}
|
||||
|
||||
const classify = (check: CheckSnapshot): Exclude<HeatCellKind, "empty"> => {
|
||||
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(-CELLS);
|
||||
const padded: (CheckSnapshot | null)[] = [
|
||||
...source,
|
||||
...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={containerSx}
|
||||
role="img"
|
||||
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
|
||||
>
|
||||
{padded.map((check, i) => {
|
||||
if (!check) {
|
||||
return (
|
||||
<Box
|
||||
key={`empty-${i}`}
|
||||
sx={cellSx("empty")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const kind = classify(check);
|
||||
const tooltipContent = (
|
||||
<Stack gap={0.25}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
>
|
||||
{check.status
|
||||
? `${check.responseTime} ms`
|
||||
: t("pages.statusPages.monitorsList.chart.downTooltip")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{formatDateWithTz(
|
||||
check.createdAt,
|
||||
"ddd, MMMM D, YYYY, HH:mm A",
|
||||
uiTimezone
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
key={check.id ?? i}
|
||||
title={tooltipContent}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
sx={cellSx(kind)}
|
||||
aria-label={`${check.responseTime} ms, ${check.status ? "up" : "down"}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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<Theme>;
|
||||
barSx: (kind: BarKind, heightPct: number) => SxProps<Theme>;
|
||||
statsSx: SxProps<Theme>;
|
||||
statsGap?: number;
|
||||
}
|
||||
|
||||
const tone = (check: CheckSnapshot): Exclude<BarKind, "empty"> =>
|
||||
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(-CELLS);
|
||||
const out: (CheckSnapshot | null)[] = [
|
||||
...source,
|
||||
...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
|
||||
];
|
||||
const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0);
|
||||
const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1;
|
||||
const avgRt = valid.length
|
||||
? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length)
|
||||
: 0;
|
||||
return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 };
|
||||
}, [checks]);
|
||||
|
||||
return (
|
||||
<Stack gap={statsGap}>
|
||||
<Box sx={containerSx}>
|
||||
{padded.map((check, i) => {
|
||||
if (!check) {
|
||||
return (
|
||||
<Box
|
||||
key={`empty-${i}`}
|
||||
sx={barSx("empty", MIN_HEIGHT_PCT)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const height = Math.max(
|
||||
MIN_HEIGHT_PCT,
|
||||
Math.round((check.responseTime / max) * 100)
|
||||
);
|
||||
const tooltipContent = (
|
||||
<Stack gap={0.25}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
>
|
||||
{check.status
|
||||
? `${check.responseTime} ms`
|
||||
: t("pages.statusPages.monitorsList.chart.downTooltip")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{formatDateWithTz(
|
||||
check.createdAt,
|
||||
"ddd, MMMM D, YYYY, HH:mm A",
|
||||
uiTimezone
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
key={check.id ?? i}
|
||||
title={tooltipContent}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
sx={barSx(tone(check), height)}
|
||||
aria-label={`${check.responseTime} ms`}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
sx={statsSx}
|
||||
>
|
||||
<span>{t("pages.statusPages.monitorsList.chart.avg", { value: avg })}</span>
|
||||
<span>{t("pages.statusPages.monitorsList.chart.max", { value: peak })}</span>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,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<Theme>;
|
||||
emptySx: SxProps<Theme>;
|
||||
gaugeSx: SxProps<Theme>;
|
||||
gaugeLabelSx: SxProps<Theme>;
|
||||
gaugeValueSx: SxProps<Theme>;
|
||||
gaugeBarSx: SxProps<Theme>;
|
||||
gaugeFillSx: (level: GaugeFillLevel, widthPct: number) => SxProps<Theme>;
|
||||
gaugeSubSx: SxProps<Theme>;
|
||||
}
|
||||
|
||||
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 = () => (
|
||||
<Box sx={sxApi.emptySx}>{t("pages.statusPages.monitorsList.noData")}</Box>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Box sx={sxApi.containerSx}>
|
||||
{gauges.map((g) => (
|
||||
<Box
|
||||
key={g.key}
|
||||
sx={sxApi.gaugeSx}
|
||||
>
|
||||
<Box sx={sxApi.gaugeLabelSx}>{g.label}</Box>
|
||||
<Box sx={sxApi.gaugeValueSx}>{g.value.toFixed(0)}%</Box>
|
||||
<Box sx={sxApi.gaugeBarSx}>
|
||||
<Box sx={sxApi.gaugeFillSx(heatLevel(g.value), Math.min(100, g.value))} />
|
||||
</Box>
|
||||
<Box sx={sxApi.gaugeSubSx}>{g.sub}</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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<Monitor, "status">;
|
||||
|
||||
interface Options {
|
||||
iconSize?: number;
|
||||
// Optional override for the `allUp` message key (editorial uses a more
|
||||
// formal sentence).
|
||||
allUpKey?: string;
|
||||
}
|
||||
|
||||
export const resolveOverallStatus = (
|
||||
monitors: StatusPageMonitor[],
|
||||
t: (key: string) => string,
|
||||
options: Options = {}
|
||||
): OverallStatus => {
|
||||
const size = options.iconSize ?? 18;
|
||||
const allUpMessage = t(options.allUpKey ?? "pages.statusPages.statusBar.allUp");
|
||||
|
||||
if (monitors.length === 0) {
|
||||
return {
|
||||
tone: "warn",
|
||||
message: t("pages.statusPages.statusBar.noMonitors"),
|
||||
icon: <CircleX size={size} />,
|
||||
};
|
||||
}
|
||||
|
||||
const allOf = (...statuses: MonitorStatus[]) =>
|
||||
monitors.every((m) => statuses.includes(m.status));
|
||||
const someOf = (...statuses: MonitorStatus[]) =>
|
||||
monitors.some((m) => statuses.includes(m.status));
|
||||
const noneOf = (...statuses: MonitorStatus[]) =>
|
||||
monitors.every((m) => !statuses.includes(m.status));
|
||||
|
||||
if (allOf("up")) {
|
||||
return { tone: "up", message: allUpMessage, icon: <CircleCheck size={size} /> };
|
||||
}
|
||||
if (allOf("breached")) {
|
||||
return {
|
||||
tone: "down",
|
||||
message: t("pages.statusPages.statusBar.allBreached"),
|
||||
icon: <ShieldAlert size={size} />,
|
||||
};
|
||||
}
|
||||
if (allOf("maintenance")) {
|
||||
return {
|
||||
tone: "warn",
|
||||
message: t("pages.statusPages.statusBar.allMaintenance"),
|
||||
icon: <Wrench size={size} />,
|
||||
};
|
||||
}
|
||||
if (allOf("down")) {
|
||||
return {
|
||||
tone: "down",
|
||||
message: t("pages.statusPages.statusBar.allDown"),
|
||||
icon: <CircleX size={size} />,
|
||||
};
|
||||
}
|
||||
if (allOf("paused")) {
|
||||
return {
|
||||
tone: "warn",
|
||||
message: t("pages.statusPages.statusBar.allPaused"),
|
||||
icon: <PauseCircle size={size} />,
|
||||
};
|
||||
}
|
||||
if (allOf("initializing")) {
|
||||
return {
|
||||
tone: "warn",
|
||||
message: t("pages.statusPages.statusBar.allInitializing"),
|
||||
icon: <Loader size={size} />,
|
||||
};
|
||||
}
|
||||
if (someOf("breached") && someOf("down")) {
|
||||
return {
|
||||
tone: "down",
|
||||
message: t("pages.statusPages.statusBar.breachedAndDown"),
|
||||
icon: <ShieldAlert size={size} />,
|
||||
};
|
||||
}
|
||||
if (someOf("breached")) {
|
||||
return {
|
||||
tone: "down",
|
||||
message: t("pages.statusPages.statusBar.breached"),
|
||||
icon: <ShieldAlert size={size} />,
|
||||
};
|
||||
}
|
||||
if (someOf("maintenance") && someOf("down")) {
|
||||
return {
|
||||
tone: "down",
|
||||
message: t("pages.statusPages.statusBar.maintenanceAndDown"),
|
||||
icon: <Wrench size={size} />,
|
||||
};
|
||||
}
|
||||
if (someOf("maintenance") && noneOf("down")) {
|
||||
return {
|
||||
tone: "warn",
|
||||
message: t("pages.statusPages.statusBar.maintenance"),
|
||||
icon: <Wrench size={size} />,
|
||||
};
|
||||
}
|
||||
if (someOf("down")) {
|
||||
return {
|
||||
tone: "warn",
|
||||
message: t("pages.statusPages.statusBar.degraded"),
|
||||
icon: <AlertTriangle size={size} />,
|
||||
};
|
||||
}
|
||||
if (someOf("paused")) {
|
||||
return {
|
||||
tone: "warn",
|
||||
message: t("pages.statusPages.statusBar.partiallyPaused"),
|
||||
icon: <PauseCircle size={size} />,
|
||||
};
|
||||
}
|
||||
if (someOf("initializing")) {
|
||||
return {
|
||||
tone: "up",
|
||||
message: t("pages.statusPages.statusBar.initializing"),
|
||||
icon: <Loader size={size} />,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tone: "warn",
|
||||
message: t("pages.statusPages.statusBar.unknown"),
|
||||
icon: <AlertTriangle size={size} />,
|
||||
};
|
||||
};
|
||||
|
||||
export const statusBadgeKey: Record<MonitorStatus, string> = {
|
||||
up: "pages.statusPages.monitorsList.status.up",
|
||||
down: "pages.statusPages.monitorsList.status.down",
|
||||
breached: "pages.statusPages.monitorsList.status.breached",
|
||||
maintenance: "pages.statusPages.monitorsList.status.maintenance",
|
||||
paused: "pages.statusPages.monitorsList.status.paused",
|
||||
initializing: "pages.statusPages.monitorsList.status.initializing",
|
||||
};
|
||||
|
||||
export const monoFirstChar = (s?: string): string =>
|
||||
(s?.trim().charAt(0) || "?").toUpperCase();
|
||||
|
||||
export const monitorBadgeTone = (status: MonitorStatus): OverallTone =>
|
||||
status === "up" ? "up" : status === "down" || status === "breached" ? "down" : "warn";
|
||||
@@ -0,0 +1,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<StatusPageTheme, ThemeVariants> = {
|
||||
refined,
|
||||
modern,
|
||||
bold,
|
||||
editorial,
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Settings {
|
||||
systemEmailHost?: string;
|
||||
systemEmailPort?: number;
|
||||
systemEmailAddress?: string;
|
||||
systemEmailDisplayName?: string;
|
||||
systemEmailUser?: string;
|
||||
systemEmailConnectionHost?: string;
|
||||
systemEmailTLSServername?: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
const typographyBase = 13;
|
||||
|
||||
export const typographyLevels = {
|
||||
base: typographyBase,
|
||||
xs: `${(typographyBase - 4) / 16}rem`,
|
||||
s: `${(typographyBase - 2) / 16}rem`,
|
||||
m: `${typographyBase / 16}rem`,
|
||||
l: `${(typographyBase + 2) / 16}rem`,
|
||||
xl: `${(typographyBase + 10) / 16}rem`,
|
||||
base: typographyBase, // 13px
|
||||
xs: `${(typographyBase - 4) / 16}rem`, // 9px
|
||||
s: `${(typographyBase - 2) / 16}rem`, // 11px
|
||||
m: `${typographyBase / 16}rem`, // 13px
|
||||
l: `${(typographyBase + 2) / 16}rem`, // 15px
|
||||
xl: `${(typographyBase + 5) / 16}rem`, // 18px
|
||||
xxl: `${(typographyBase + 10) / 16}rem`, // 23px
|
||||
};
|
||||
|
||||
export const colors = {
|
||||
|
||||
@@ -58,7 +58,7 @@ export const theme = (mode: string, palette: any) =>
|
||||
fontFamilyMonospace,
|
||||
fontSize: typographyLevels.base,
|
||||
h1: {
|
||||
fontSize: typographyLevels.xl,
|
||||
fontSize: typographyLevels.xxl,
|
||||
fontWeight: 500,
|
||||
},
|
||||
h2: {
|
||||
|
||||
@@ -8,6 +8,7 @@ export const SPACING = {
|
||||
} as const;
|
||||
|
||||
export const LAYOUT = {
|
||||
XXS: 2,
|
||||
XS: 4,
|
||||
SM: 6,
|
||||
MD: 8,
|
||||
@@ -15,3 +16,7 @@ export const LAYOUT = {
|
||||
XL: 12,
|
||||
XXL: 16,
|
||||
} as const;
|
||||
|
||||
export const HOVER = {
|
||||
DARKEN: 0.06, // This is a coefficient for darkening function
|
||||
} as const;
|
||||
@@ -19,8 +19,11 @@ export const formatDateWithTz = (timestamp: string, format: string, timezone: st
|
||||
if (!timestamp) {
|
||||
return "Unknown time";
|
||||
}
|
||||
const formattedDate = dayjs(timestamp).tz(timezone).format(format);
|
||||
return formattedDate;
|
||||
try {
|
||||
return dayjs(timestamp).tz(timezone).format(format);
|
||||
} catch {
|
||||
return dayjs(timestamp).utc().format(format);
|
||||
}
|
||||
};
|
||||
|
||||
export const tickDateFormatLookup = (range: string) => {
|
||||
|
||||
@@ -32,6 +32,11 @@ export const settingsSchema = z.object({
|
||||
.or(z.literal(""))
|
||||
.transform((val) => (val === "" ? null : val.toLowerCase().trim()))
|
||||
.optional(),
|
||||
systemEmailDisplayName: z
|
||||
.string()
|
||||
.max(100, "Display name must be 100 characters or fewer")
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailUser: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+82
-47
@@ -13,7 +13,7 @@
|
||||
"content": "تحذير: لم تقم بإضافة مفتاح Google PageSpeed API بعد. قم بزيارة <settingsLink>الإعدادات</settingsLink> لإضافته. بدونه، لن تعمل مراقبة PageSpeed."
|
||||
}
|
||||
},
|
||||
"appName": "",
|
||||
"appName": "Checkmate",
|
||||
"breadcrumbs": {
|
||||
"details": "التفاصيل",
|
||||
"home": "الرئيسية"
|
||||
@@ -35,11 +35,11 @@
|
||||
"sendInvite": "إرسال دعوة",
|
||||
"test": "اختبار",
|
||||
"testNotifications": "اختبار الإشعارات",
|
||||
"toggleTheme": "",
|
||||
"toggleTheme": "تبديل الوضع الفاتح والداكن",
|
||||
"flushQueue": "تفريغ قائمة الانتظار",
|
||||
"notFound": "الانتقال إلى لوحة التحكم الرئيسية",
|
||||
"resetPassword": "",
|
||||
"reset": "",
|
||||
"resetPassword": "إعادة تعيين كلمة المرور",
|
||||
"reset": "إعادة تعيين",
|
||||
"clear": "مسح",
|
||||
"addDemo": "إضافة مراقبات تجريبية",
|
||||
"removeMonitors": "إزالة المراقبات",
|
||||
@@ -53,8 +53,8 @@
|
||||
"labels": {
|
||||
"averageResponseTime": "متوسط وقت الاستجابة",
|
||||
"downtime": "وقت التوقف",
|
||||
"high": "",
|
||||
"low": "",
|
||||
"high": "مرتفع",
|
||||
"low": "منخفض",
|
||||
"uptime": "وقت التشغيل"
|
||||
},
|
||||
"histogram": {
|
||||
@@ -86,12 +86,12 @@
|
||||
"name": {
|
||||
"option": {
|
||||
"firstName": {
|
||||
"label": "",
|
||||
"placeholder": ""
|
||||
"label": "الاسم الأول",
|
||||
"placeholder": "أدخل اسمك الأول"
|
||||
},
|
||||
"lastName": {
|
||||
"label": "",
|
||||
"placeholder": ""
|
||||
"label": "اسم العائلة",
|
||||
"placeholder": "أدخل اسم عائلتك"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -107,14 +107,14 @@
|
||||
"table": {
|
||||
"empty": "لا يوجد شيء هنا",
|
||||
"headers": {
|
||||
"actions": "",
|
||||
"actions": "إجراءات",
|
||||
"dateTime": "التاريخ والوقت",
|
||||
"message": "الرسالة",
|
||||
"monitor": "المراقب",
|
||||
"monitorId": "معرّف المراقب",
|
||||
"name": "الاسم",
|
||||
"status": "الحالة",
|
||||
"type": "",
|
||||
"type": "النوع",
|
||||
"url": "الرابط",
|
||||
"interval": "الفاصل الزمني",
|
||||
"active": "نشط",
|
||||
@@ -125,17 +125,17 @@
|
||||
"components": {
|
||||
"imageUpload": {
|
||||
"clickToUpload": "انقر للرفع",
|
||||
"dragAndDrop": "",
|
||||
"supportedFormats": "",
|
||||
"maxSize": "",
|
||||
"dragAndDrop": "أو اسحب وأسقط",
|
||||
"supportedFormats": "الصيغ المدعومة",
|
||||
"maxSize": "الحجم الأقصى",
|
||||
"orDragAndDrop": "أو اسحب وأفلت",
|
||||
"errors": {
|
||||
"invalidFileSize": "",
|
||||
"invalidFileFormat": ""
|
||||
"invalidFileSize": "حجم الملف كبير جدًا!",
|
||||
"invalidFileFormat": "تنسيق الملف غير مدعوم!"
|
||||
}
|
||||
},
|
||||
"headerStatusPageControls": {
|
||||
"publicLink": ""
|
||||
"publicLink": "رابط عام"
|
||||
},
|
||||
"headerTimeRange": {
|
||||
"labels": {
|
||||
@@ -155,26 +155,26 @@
|
||||
"serverUnreachable": "تعذر الوصول إلى الخادم",
|
||||
"retry": "إعادة المحاولة",
|
||||
"retrying": "جارٍ إعادة المحاولة...",
|
||||
"reconnected": ""
|
||||
"reconnected": "تمت استعادة الاتصال"
|
||||
},
|
||||
"sidebar": {
|
||||
"menu": {
|
||||
"uptime": "",
|
||||
"pagespeed": "",
|
||||
"infrastructure": "",
|
||||
"notifications": "",
|
||||
"uptime": "وقت التشغيل",
|
||||
"pagespeed": "Pagespeed",
|
||||
"infrastructure": "البنية التحتية",
|
||||
"notifications": "الإشعارات",
|
||||
"checks": "الفحوصات",
|
||||
"incidents": "",
|
||||
"statusPages": "",
|
||||
"maintenance": "",
|
||||
"logs": "",
|
||||
"settings": ""
|
||||
"incidents": "الحوادث",
|
||||
"statusPages": "صفحات الحالة",
|
||||
"maintenance": "الصيانة",
|
||||
"logs": "السجلات",
|
||||
"settings": "الإعدادات"
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "",
|
||||
"discussions": "",
|
||||
"docs": "",
|
||||
"changelog": ""
|
||||
"support": "الدعم",
|
||||
"discussions": "المناقشات",
|
||||
"docs": "الوثائق",
|
||||
"changelog": "سجل التغييرات"
|
||||
},
|
||||
"accountMenu": {
|
||||
"profile": "الملف الشخصي",
|
||||
@@ -186,13 +186,13 @@
|
||||
"description": "اطلع على أحدث الإصدارات وساعد في تنمية المجتمع على GitHub"
|
||||
},
|
||||
"authFooter": {
|
||||
"navControls": "",
|
||||
"logOut": "",
|
||||
"navControls": "عناصر التحكم",
|
||||
"logOut": "تسجيل الخروج",
|
||||
"roles": {
|
||||
"superAdmin": "مسؤول أعلى",
|
||||
"admin": "مسؤول",
|
||||
"user": "مستخدم",
|
||||
"demoUser": ""
|
||||
"demoUser": "مستخدم تجريبي"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -259,15 +259,15 @@
|
||||
"team": {
|
||||
"filter": {
|
||||
"placeholder": "تصفية حسب الدور",
|
||||
"all": "",
|
||||
"all": "كل الأدوار",
|
||||
"admin": "مسؤول",
|
||||
"member": ""
|
||||
"member": "عضو"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"email": "البريد الإلكتروني",
|
||||
"role": "الدور",
|
||||
"created": ""
|
||||
"created": "تم الإنشاء في"
|
||||
}
|
||||
},
|
||||
"addMember": {
|
||||
@@ -364,7 +364,8 @@
|
||||
"register": {
|
||||
"title": "مرحباً بعودتك إلى Checkmate!",
|
||||
"subtitle": "سجّل للبدء",
|
||||
"submit": "تسجيل"
|
||||
"submit": "تسجيل",
|
||||
"setupNotice": "هذه الصفحة متاحة فقط أثناء الإعداد الأولي. بمجرد تسجيل المسؤول الأعلى، يمكن دعوة مستخدمين إضافيين من إعدادات الفريق."
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "نسيت كلمة المرور؟",
|
||||
@@ -426,7 +427,8 @@
|
||||
"activeFor": "نشط منذ",
|
||||
"certificateExpiry": "انتهاء الشهادة",
|
||||
"lastCheck": "آخر فحص",
|
||||
"lastResponseTime": "آخر وقت استجابة"
|
||||
"lastResponseTime": "آخر وقت استجابة",
|
||||
"serviceIsDown": "الخدمة معطلة"
|
||||
},
|
||||
"status": {
|
||||
"down": "متوقف",
|
||||
@@ -540,7 +542,7 @@
|
||||
"ping": "",
|
||||
"port": "",
|
||||
"docker": "",
|
||||
"game": "",
|
||||
"game": "أدخل عنوان IP أو اسم المضيف ورقم المنفذ للاختبار (مثل 192.168.1.100 أو example.com) واختر نوع اللعبة. تتطلب بعض الألعاب تفعيل استعلامات الخادم في ملف الإعدادات الخاص بها — يُرجى مراجعة وثائق خادم اللعبة. اطّلع على <gamedigLink>قائمة الألعاب المدعومة</gamedigLink> لمعرفة تفاصيل البروتوكول.",
|
||||
"pagespeed": "تتبع أداء تحميل الصفحة ومؤشرات Core Web Vitals ودرجات التحسين لموقعك.",
|
||||
"grpc": "أدخل اسم المضيف ومنفذ خادم gRPC، وحدد اختيارياً اسم خدمة لفحص الصحة، وأضف اسم عرض.",
|
||||
"websocket": "أدخل رابط WebSocket للمراقبة (مثال: wss://example.com/socket) وأضف اسم عرض.",
|
||||
@@ -767,6 +769,7 @@
|
||||
"memory": "استخدام الذاكرة",
|
||||
"netBytesRecv": "{{name}} - البايتات المستلمة",
|
||||
"netBytesSent": "{{name}} - البايتات المرسلة",
|
||||
"netNoTraffic": "Capture يبلغ عن عدم وجود حركة بيانات على أي واجهة. هذا أمر طبيعي عندما يعمل Capture داخل حاوية لا ترى سوى واجهات الاسترجاع — قم بتشغيل Capture مباشرة على المضيف لرؤية نشاط الشبكة الحقيقي.",
|
||||
"temp": "الحرارة"
|
||||
}
|
||||
},
|
||||
@@ -825,9 +828,9 @@
|
||||
"description": "ستظهر هنا نشاط الخادم والتحذيرات والأخطاء أثناء تشغيل Checkmate."
|
||||
},
|
||||
"tabs": {
|
||||
"diagnostics": "",
|
||||
"logs": "",
|
||||
"queue": ""
|
||||
"diagnostics": "التشخيص",
|
||||
"logs": "السجلات",
|
||||
"queue": "قائمة المهام"
|
||||
},
|
||||
"logLevelSelect": {
|
||||
"label": "مستوى السجل"
|
||||
@@ -1016,6 +1019,26 @@
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "معرّف المحادثة",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
},
|
||||
"pushover": {
|
||||
"title": "إعدادات Pushover",
|
||||
"description": "أدخل رمز التطبيق ومفتاح المستخدم في Pushover.",
|
||||
"optionAppToken": "رمز التطبيق",
|
||||
"placeholderAppToken": "أدخل رمز التطبيق",
|
||||
"optionUserKey": "مفتاح المستخدم",
|
||||
"placeholderUserKey": "أدخل مفتاح المستخدم"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "إعدادات Twilio",
|
||||
"description": "أدخل بيانات اعتماد حساب Twilio لإرسال الرسائل النصية.",
|
||||
"optionAccountSid": "معرف الحساب (SID)",
|
||||
"placeholderAccountSid": "أدخل معرف الحساب",
|
||||
"optionAuthToken": "رمز المصادقة",
|
||||
"placeholderAuthToken": "أدخل رمز المصادقة",
|
||||
"optionFromNumber": "من الرقم",
|
||||
"placeholderFromNumber": "+1234567890",
|
||||
"optionToNumber": "إلى الرقم",
|
||||
"placeholderToNumber": "+1234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1214,11 +1237,12 @@
|
||||
"description": "إضافة مراقبات نموذجية لأغراض العرض التوضيحي."
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "إعادة تعيين النظام",
|
||||
"title": "إعادة ضبط النظام",
|
||||
"description": "إزالة جميع المراقبات من نظامك.",
|
||||
"dialog": {
|
||||
"title": "إزالة جميع المراقبات؟",
|
||||
"description": "لا يمكن التراجع عن هذا."
|
||||
"description": "لا يمكن التراجع عن هذا الإجراء.",
|
||||
"paragraph": "سيتم أيضًا إزالة جميع سجلات الفحص والحوادث ونوافذ الصيانة المجدولة وأي صفحات حالة كانت تعتمد على هذه المراقبات. صدّر المراقبات أولًا للاحتفاظ بنسخة احتياطية من بياناتها."
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
@@ -1271,7 +1295,18 @@
|
||||
"allDown": "جميع الأنظمة متوقفة",
|
||||
"allUp": "جميع الأنظمة تعمل بشكل طبيعي",
|
||||
"degraded": "بعض الأنظمة تواجه مشكلات",
|
||||
"unknown": "تعذر تحديد حالة النظام"
|
||||
"unknown": "تعذر تحديد حالة النظام",
|
||||
"allBreached": "تجاوزت جميع الأنظمة الحدود",
|
||||
"allMaintenance": "جميع الأنظمة قيد الصيانة",
|
||||
"allPaused": "جميع الأنظمة متوقفة مؤقتًا",
|
||||
"allInitializing": "جميع الأنظمة قيد التهيئة",
|
||||
"breachedAndDown": "تجاوزت بعض الأنظمة الحدود وهي معطلة",
|
||||
"breached": "تجاوزت بعض الأنظمة الحدود",
|
||||
"maintenanceAndDown": "بعض الأنظمة معطلة أثناء الصيانة",
|
||||
"maintenance": "بعض الأنظمة قيد الصيانة",
|
||||
"partiallyPaused": "بعض الأنظمة متوقفة مؤقتًا",
|
||||
"initializing": "بعض الأنظمة قيد التهيئة",
|
||||
"noMonitors": "لم يتم تكوين أي شاشة"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
|
||||
+75
-40
@@ -39,7 +39,7 @@
|
||||
"flushQueue": "Vyprázdnit frontu",
|
||||
"notFound": "Přejít na hlavní přehled",
|
||||
"resetPassword": "Obnovit heslo",
|
||||
"reset": "",
|
||||
"reset": "Resetovat",
|
||||
"clear": "Vymazat",
|
||||
"addDemo": "Přidat ukázkové monitory",
|
||||
"removeMonitors": "Odebrat monitory",
|
||||
@@ -53,8 +53,8 @@
|
||||
"labels": {
|
||||
"averageResponseTime": "Průměrná doba odezvy",
|
||||
"downtime": "Výpadek",
|
||||
"high": "",
|
||||
"low": "",
|
||||
"high": "Vysoké",
|
||||
"low": "Nízké",
|
||||
"uptime": "Dostupnost"
|
||||
},
|
||||
"histogram": {
|
||||
@@ -107,14 +107,14 @@
|
||||
"table": {
|
||||
"empty": "Zde nic není",
|
||||
"headers": {
|
||||
"actions": "",
|
||||
"actions": "Akce",
|
||||
"dateTime": "Datum a čas",
|
||||
"message": "Zpráva",
|
||||
"monitor": "Monitor",
|
||||
"monitorId": "ID monitoru",
|
||||
"name": "Název",
|
||||
"status": "Stav",
|
||||
"type": "",
|
||||
"type": "Typ",
|
||||
"url": "URL",
|
||||
"interval": "Interval",
|
||||
"active": "Aktivní",
|
||||
@@ -125,17 +125,17 @@
|
||||
"components": {
|
||||
"imageUpload": {
|
||||
"clickToUpload": "Klikněte pro nahrání",
|
||||
"dragAndDrop": "",
|
||||
"supportedFormats": "",
|
||||
"maxSize": "",
|
||||
"dragAndDrop": "nebo přetáhněte",
|
||||
"supportedFormats": "Podporované formáty",
|
||||
"maxSize": "Max. velikost",
|
||||
"orDragAndDrop": "nebo přetáhněte soubor",
|
||||
"errors": {
|
||||
"invalidFileSize": "",
|
||||
"invalidFileFormat": ""
|
||||
"invalidFileSize": "Soubor je příliš velký!",
|
||||
"invalidFileFormat": "Nepodporovaný formát souboru!"
|
||||
}
|
||||
},
|
||||
"headerStatusPageControls": {
|
||||
"publicLink": ""
|
||||
"publicLink": "Veřejný odkaz"
|
||||
},
|
||||
"headerTimeRange": {
|
||||
"labels": {
|
||||
@@ -159,22 +159,22 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"menu": {
|
||||
"uptime": "",
|
||||
"pagespeed": "",
|
||||
"infrastructure": "",
|
||||
"notifications": "",
|
||||
"uptime": "Dostupnost",
|
||||
"pagespeed": "Pagespeed",
|
||||
"infrastructure": "Infrastruktura",
|
||||
"notifications": "Oznámení",
|
||||
"checks": "Kontroly",
|
||||
"incidents": "",
|
||||
"statusPages": "",
|
||||
"maintenance": "",
|
||||
"logs": "",
|
||||
"settings": ""
|
||||
"incidents": "Incidenty",
|
||||
"statusPages": "Stavové stránky",
|
||||
"maintenance": "Údržba",
|
||||
"logs": "Záznamy",
|
||||
"settings": "Nastavení"
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "",
|
||||
"discussions": "",
|
||||
"docs": "",
|
||||
"changelog": ""
|
||||
"support": "Podpora",
|
||||
"discussions": "Diskuze",
|
||||
"docs": "Dokumentace",
|
||||
"changelog": "Záznam změn"
|
||||
},
|
||||
"accountMenu": {
|
||||
"profile": "Profil",
|
||||
@@ -186,13 +186,13 @@
|
||||
"description": "Sledujte nejnovější vydání a pomozte rozšířit komunitu na GitHubu"
|
||||
},
|
||||
"authFooter": {
|
||||
"navControls": "",
|
||||
"logOut": "",
|
||||
"navControls": "Ovládací prvky",
|
||||
"logOut": "Odhlásit se",
|
||||
"roles": {
|
||||
"superAdmin": "Superadministrátor",
|
||||
"admin": "Administrátor",
|
||||
"user": "Uživatel",
|
||||
"demoUser": ""
|
||||
"demoUser": "Demo uživatel"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -259,15 +259,15 @@
|
||||
"team": {
|
||||
"filter": {
|
||||
"placeholder": "Filtrovat podle role",
|
||||
"all": "",
|
||||
"all": "Všechny role",
|
||||
"admin": "Administrátor",
|
||||
"member": ""
|
||||
"member": "Člen"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"email": "Email",
|
||||
"role": "Role",
|
||||
"created": ""
|
||||
"created": "Vytvořeno"
|
||||
}
|
||||
},
|
||||
"addMember": {
|
||||
@@ -364,7 +364,8 @@
|
||||
"register": {
|
||||
"title": "Vítejte v Checkmate!",
|
||||
"subtitle": "Zaregistrujte se a začněte",
|
||||
"submit": "Registrovat"
|
||||
"submit": "Registrovat",
|
||||
"setupNotice": "Tato stránka je k dispozici pouze během počátečního nastavení. Po zaregistrování super administrátora lze pozvat další uživatele z nastavení týmu."
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Zapomněli jste heslo?",
|
||||
@@ -426,7 +427,8 @@
|
||||
"activeFor": "Aktivní po dobu",
|
||||
"certificateExpiry": "Expirace certifikátu",
|
||||
"lastCheck": "Poslední kontrola",
|
||||
"lastResponseTime": "Poslední doba odezvy"
|
||||
"lastResponseTime": "Poslední doba odezvy",
|
||||
"serviceIsDown": "Služba je nedostupná"
|
||||
},
|
||||
"status": {
|
||||
"down": "nedostupný",
|
||||
@@ -540,7 +542,7 @@
|
||||
"ping": "",
|
||||
"port": "",
|
||||
"docker": "",
|
||||
"game": "",
|
||||
"game": "Zadejte IP adresu nebo název hostitele a číslo portu pro ping (např. 192.168.1.100 nebo example.com) a vyberte typ hry. Některé hry vyžadují povolení dotazů na server v jejich konfiguračním souboru — viz dokumentace vašeho herního serveru. Podrobnosti o protokolu najdete v <gamedigLink>seznamu podporovaných her</gamedigLink>.",
|
||||
"pagespeed": "Sledujte výkon načítání stránky, metriky Core Web Vitals a hodnocení optimalizace vašeho webu.",
|
||||
"grpc": "Zadejte název hostitele a port gRPC serveru, volitelně zadejte název služby pro kontrolu stavu a přidejte zobrazovaný název.",
|
||||
"websocket": "Zadejte WebSocket URL ke sledování (např. wss://example.com/socket) a přidejte zobrazovaný název.",
|
||||
@@ -767,6 +769,7 @@
|
||||
"memory": "Využití paměti",
|
||||
"netBytesRecv": "{{name}} - přijaté bajty",
|
||||
"netBytesSent": "{{name}} - odeslané bajty",
|
||||
"netNoTraffic": "Capture hlásí nulový provoz na všech rozhraních. To je normální, když Capture běží uvnitř kontejneru, který vidí pouze loopback rozhraní — spusťte Capture přímo na hostiteli, abyste viděli skutečnou síťovou aktivitu.",
|
||||
"temp": "Teplota"
|
||||
}
|
||||
},
|
||||
@@ -825,9 +828,9 @@
|
||||
"description": "Aktivita serveru, varování a chyby se zobrazí zde, jak Checkmate běží."
|
||||
},
|
||||
"tabs": {
|
||||
"diagnostics": "",
|
||||
"logs": "",
|
||||
"queue": ""
|
||||
"diagnostics": "Diagnostika",
|
||||
"logs": "Záznamy",
|
||||
"queue": "Fronta úloh"
|
||||
},
|
||||
"logLevelSelect": {
|
||||
"label": "Úroveň logování"
|
||||
@@ -1016,6 +1019,26 @@
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "ID chatu",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
},
|
||||
"pushover": {
|
||||
"title": "Konfigurace Pushover",
|
||||
"description": "Zadejte token aplikace a uživatelský klíč Pushover.",
|
||||
"optionAppToken": "Token aplikace",
|
||||
"placeholderAppToken": "Zadejte token aplikace",
|
||||
"optionUserKey": "Uživatelský klíč",
|
||||
"placeholderUserKey": "Zadejte uživatelský klíč"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "Konfigurace Twilio",
|
||||
"description": "Zadejte přihlašovací údaje účtu Twilio pro odesílání SMS.",
|
||||
"optionAccountSid": "SID účtu",
|
||||
"placeholderAccountSid": "Zadejte SID účtu",
|
||||
"optionAuthToken": "Autentizační token",
|
||||
"placeholderAuthToken": "Zadejte autentizační token",
|
||||
"optionFromNumber": "Z čísla",
|
||||
"placeholderFromNumber": "+1234567890",
|
||||
"optionToNumber": "Na číslo",
|
||||
"placeholderToNumber": "+1234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1214,11 +1237,12 @@
|
||||
"description": "Přidejte ukázkové monitory pro demonstrační účely."
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "Obnovení systému",
|
||||
"description": "Odeberte všechny monitory z vašeho systému.",
|
||||
"title": "Reset systému",
|
||||
"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": {
|
||||
@@ -1271,7 +1295,18 @@
|
||||
"allDown": "Všechny systémy jsou nedostupné",
|
||||
"allUp": "Všechny systémy fungují",
|
||||
"degraded": "Některé systémy mají problémy",
|
||||
"unknown": "Nelze určit stav systému"
|
||||
"unknown": "Nelze určit stav systému",
|
||||
"allBreached": "Všechny systémy překročily prahové hodnoty",
|
||||
"allMaintenance": "Všechny systémy jsou v údržbě",
|
||||
"allPaused": "Všechny systémy jsou pozastavené",
|
||||
"allInitializing": "Všechny systémy se inicializují",
|
||||
"breachedAndDown": "Některé systémy překročily prahové hodnoty a jsou nedostupné",
|
||||
"breached": "Některé systémy překročily prahové hodnoty",
|
||||
"maintenanceAndDown": "Některé systémy jsou nedostupné během údržby",
|
||||
"maintenance": "Některé systémy jsou v údržbě",
|
||||
"partiallyPaused": "Některé systémy jsou pozastavené",
|
||||
"initializing": "Některé systémy se inicializují",
|
||||
"noMonitors": "Nebyly nakonfigurovány žádné monitory"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
|
||||
@@ -364,7 +364,8 @@
|
||||
"register": {
|
||||
"title": "Willkommen bei Checkmate!",
|
||||
"subtitle": "Registrieren Sie sich, um loszulegen",
|
||||
"submit": "Registrieren"
|
||||
"submit": "Registrieren",
|
||||
"setupNotice": "Diese Seite ist nur während der Ersteinrichtung verfügbar. Sobald ein Super-Admin registriert ist, können weitere Benutzer in den Team-Einstellungen eingeladen werden."
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Passwort vergessen?",
|
||||
@@ -426,7 +427,8 @@
|
||||
"activeFor": "Aktiv seit",
|
||||
"certificateExpiry": "Zertifikatablauf",
|
||||
"lastCheck": "Letzter Check",
|
||||
"lastResponseTime": "Letzte Antwortzeit"
|
||||
"lastResponseTime": "Letzte Antwortzeit",
|
||||
"serviceIsDown": "Dienst ist nicht erreichbar"
|
||||
},
|
||||
"status": {
|
||||
"down": "ausgefallen",
|
||||
@@ -540,7 +542,7 @@
|
||||
"ping": "Geben Sie die IP-Adresse oder den Hostnamen ein, der angepingt werden soll (z. B. 192.168.1.100 oder example.com), und fügen Sie einen eindeutigen Anzeigenamen hinzu, der auf dem Dashboard angezeigt wird.",
|
||||
"port": "Geben Sie die URL oder IP des Servers, die Portnummer und einen eindeutigen Anzeigenamen ein, der auf dem Dashboard angezeigt wird.",
|
||||
"docker": "Geben Sie die Docker-ID Ihres Containers ein. Docker-IDs müssen die vollständige 64-Zeichen-ID sein. Sie können docker inspect <short_id> ausführen, um die vollständige Container-ID abzurufen.",
|
||||
"game": "IP Adresse oder Hostnamen und Port für Ping eingeben (z.B. 192.168.1.100 oder beispiel.de) und Spieletyp auswählen.",
|
||||
"game": "IP Adresse oder Hostnamen und Port für Ping eingeben (z.B. 192.168.1.100 oder beispiel.de) und Spieletyp auswählen. Einige Spiele erfordern das Aktivieren von Server-Abfragen in ihrer Konfigurationsdatei — sieh in der Dokumentation deines Spielservers nach. In der <gamedigLink>Liste unterstützter Spiele</gamedigLink> findest du Protokolldetails.",
|
||||
"pagespeed": "Verfolgen Sie die Ladezeit, Core Web Vitals und Optimierungswerte Ihrer Website.",
|
||||
"grpc": "Geben Sie den Hostnamen und Port des gRPC-Servers ein, optional einen Dienstnamen für den Health-Check, und fügen Sie einen Anzeigenamen hinzu.",
|
||||
"websocket": "Geben Sie die zu überwachende WebSocket-URL ein (z. B. wss://example.com/socket) und fügen Sie einen Anzeigenamen hinzu.",
|
||||
@@ -767,6 +769,7 @@
|
||||
"memory": "Arbeitsspeicherauslastung",
|
||||
"netBytesRecv": "{{name}} - Empfangene Bytes",
|
||||
"netBytesSent": "{{name}} - Gesendete Bytes",
|
||||
"netNoTraffic": "Capture meldet null Datenverkehr auf allen Schnittstellen. Das ist normal, wenn Capture in einem Container läuft, der nur Loopback-Schnittstellen sieht — führe Capture direkt auf dem Host aus, um echten Netzwerkverkehr zu sehen.",
|
||||
"temp": "Temp."
|
||||
}
|
||||
},
|
||||
@@ -1016,6 +1019,26 @@
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "Chat-ID",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
},
|
||||
"pushover": {
|
||||
"title": "Pushover-Konfiguration",
|
||||
"description": "Geben Sie Ihr Anwendungstoken und Ihren Benutzerschlüssel von Pushover ein.",
|
||||
"optionAppToken": "Anwendungstoken",
|
||||
"placeholderAppToken": "Anwendungstoken eingeben",
|
||||
"optionUserKey": "Benutzerschlüssel",
|
||||
"placeholderUserKey": "Benutzerschlüssel eingeben"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "Twilio-Konfiguration",
|
||||
"description": "Geben Sie Ihre Twilio-Kontoanmeldeinformationen ein, um SMS zu senden.",
|
||||
"optionAccountSid": "Account-SID",
|
||||
"placeholderAccountSid": "Account-SID eingeben",
|
||||
"optionAuthToken": "Auth-Token",
|
||||
"placeholderAuthToken": "Auth-Token eingeben",
|
||||
"optionFromNumber": "Absendernummer",
|
||||
"placeholderFromNumber": "+1234567890",
|
||||
"optionToNumber": "Empfängernummer",
|
||||
"placeholderToNumber": "+1234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1215,10 +1238,11 @@
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "System zurücksetzen",
|
||||
"description": "Alle Monitore aus Ihrem System entfernen.",
|
||||
"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": {
|
||||
@@ -1271,7 +1295,18 @@
|
||||
"allDown": "Alle Systeme sind ausgefallen",
|
||||
"allUp": "Alle Systeme funktionsfähig",
|
||||
"degraded": "Einige Systeme haben Probleme",
|
||||
"unknown": "Systemstatus kann nicht ermittelt werden"
|
||||
"unknown": "Systemstatus kann nicht ermittelt werden",
|
||||
"allBreached": "Alle Systeme haben Schwellenwerte überschritten",
|
||||
"allMaintenance": "Alle Systeme befinden sich in Wartung",
|
||||
"allPaused": "Alle Systeme sind pausiert",
|
||||
"allInitializing": "Alle Systeme werden initialisiert",
|
||||
"breachedAndDown": "Einige Systeme haben Schwellenwerte überschritten und sind ausgefallen",
|
||||
"breached": "Einige Systeme haben Schwellenwerte überschritten",
|
||||
"maintenanceAndDown": "Einige Systeme sind während der Wartung ausgefallen",
|
||||
"maintenance": "Einige Systeme befinden sich in Wartung",
|
||||
"partiallyPaused": "Einige Systeme sind pausiert",
|
||||
"initializing": "Einige Systeme werden initialisiert",
|
||||
"noMonitors": "Es wurden keine Monitore konfiguriert"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
|
||||
@@ -542,7 +542,7 @@
|
||||
"ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.",
|
||||
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.",
|
||||
"docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",
|
||||
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.",
|
||||
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type. Some games require enabling server queries in their config file — check your game server's documentation. See the <gamedigLink>supported games list</gamedigLink> for protocol details.",
|
||||
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
|
||||
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
|
||||
"websocket": "Enter the WebSocket URL to monitor (e.g., wss://example.com/socket) and add a display name.",
|
||||
@@ -769,6 +769,7 @@
|
||||
"memory": "Memory usage",
|
||||
"netBytesRecv": "{{name}} - Bytes Received",
|
||||
"netBytesSent": "{{name}} - Bytes Sent",
|
||||
"netNoTraffic": "Capture is reporting zero traffic on every interface. This is normal when Capture runs inside a container that can only see loopback interfaces — run Capture directly on the host to see real network activity.",
|
||||
"temp": "Temp"
|
||||
}
|
||||
},
|
||||
@@ -1197,6 +1198,10 @@
|
||||
"label": "Email address - Used for authentication",
|
||||
"placeholder": "you@example.com"
|
||||
},
|
||||
"displayName": {
|
||||
"label": "Sender name (optional) - Friendly name shown in the From field",
|
||||
"placeholder": "Checkmate notifications"
|
||||
},
|
||||
"user": {
|
||||
"label": "Email user - Username for authentication, overrides email address if specified",
|
||||
"placeholder": "Leave empty if not required"
|
||||
@@ -1240,7 +1245,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": {
|
||||
@@ -1274,6 +1280,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",
|
||||
@@ -1290,6 +1310,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",
|
||||
@@ -1321,6 +1343,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",
|
||||
@@ -1378,6 +1407,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.",
|
||||
|
||||
+134
-85
@@ -13,7 +13,7 @@
|
||||
"content": "Advertencia: Aún no ha agregado una clave de API de Google PageSpeed. Visite <settingsLink>Configuración</settingsLink> para agregar una. Sin ella, el monitor de PageSpeed no funcionará."
|
||||
}
|
||||
},
|
||||
"appName": "",
|
||||
"appName": "Checkmate",
|
||||
"breadcrumbs": {
|
||||
"details": "Detalles",
|
||||
"home": "Inicio"
|
||||
@@ -35,11 +35,11 @@
|
||||
"sendInvite": "Enviar invitación",
|
||||
"test": "Probar",
|
||||
"testNotifications": "Probar notificaciones",
|
||||
"toggleTheme": "",
|
||||
"toggleTheme": "Cambiar tema claro / oscuro",
|
||||
"flushQueue": "Vaciar cola",
|
||||
"notFound": "Ir al panel principal",
|
||||
"resetPassword": "",
|
||||
"reset": "",
|
||||
"resetPassword": "Restablecer contraseña",
|
||||
"reset": "Restablecer",
|
||||
"clear": "Limpiar",
|
||||
"addDemo": "Agregar monitores de demostración",
|
||||
"removeMonitors": "Eliminar monitores",
|
||||
@@ -47,7 +47,14 @@
|
||||
"exportToJSON": "Exportar a JSON",
|
||||
"importFromJSON": "Importar desde JSON",
|
||||
"clearFilters": "Limpiar filtros",
|
||||
"removeUser": "Eliminar usuario"
|
||||
"removeUser": "Eliminar usuario",
|
||||
"testNotification": "Probar notificación",
|
||||
"invite": "Invitar",
|
||||
"copyLink": "Copiar enlace",
|
||||
"regenerateToken": "Regenerar token",
|
||||
"copyToken": "Copiar token",
|
||||
"exportLogs": "Exportar registros",
|
||||
"refresh": "Actualizar"
|
||||
},
|
||||
"charts": {
|
||||
"labels": {
|
||||
@@ -86,12 +93,12 @@
|
||||
"name": {
|
||||
"option": {
|
||||
"firstName": {
|
||||
"label": "",
|
||||
"placeholder": ""
|
||||
"label": "Nombre",
|
||||
"placeholder": "Introduce tu nombre"
|
||||
},
|
||||
"lastName": {
|
||||
"label": "",
|
||||
"placeholder": ""
|
||||
"label": "Apellidos",
|
||||
"placeholder": "Introduce tus apellidos"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -99,7 +106,7 @@
|
||||
"option": {
|
||||
"email": {
|
||||
"label": "Correo electrónico",
|
||||
"placeholder": "you@example.com"
|
||||
"placeholder": "tu@ejemplo.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,14 +114,14 @@
|
||||
"table": {
|
||||
"empty": "No hay nada aquí",
|
||||
"headers": {
|
||||
"actions": "",
|
||||
"actions": "Acciones",
|
||||
"dateTime": "Fecha y hora",
|
||||
"message": "Mensaje",
|
||||
"monitor": "Monitor",
|
||||
"monitorId": "ID de monitor",
|
||||
"name": "Nombre",
|
||||
"status": "Estado",
|
||||
"type": "",
|
||||
"type": "Tipo",
|
||||
"url": "URL",
|
||||
"interval": "Intervalo",
|
||||
"active": "Activo",
|
||||
@@ -125,17 +132,17 @@
|
||||
"components": {
|
||||
"imageUpload": {
|
||||
"clickToUpload": "Haga clic para subir",
|
||||
"dragAndDrop": "",
|
||||
"supportedFormats": "",
|
||||
"maxSize": "",
|
||||
"dragAndDrop": "o arrastra y suelta",
|
||||
"supportedFormats": "Formatos admitidos",
|
||||
"maxSize": "Tamaño máximo",
|
||||
"orDragAndDrop": "o arrastre y suelte",
|
||||
"errors": {
|
||||
"invalidFileSize": "",
|
||||
"invalidFileFormat": ""
|
||||
"invalidFileSize": "¡El archivo es demasiado grande!",
|
||||
"invalidFileFormat": "¡Formato de archivo no admitido!"
|
||||
}
|
||||
},
|
||||
"headerStatusPageControls": {
|
||||
"publicLink": ""
|
||||
"publicLink": "Enlace público"
|
||||
},
|
||||
"headerTimeRange": {
|
||||
"labels": {
|
||||
@@ -155,26 +162,26 @@
|
||||
"serverUnreachable": "No se puede conectar al servidor",
|
||||
"retry": "Reintentar",
|
||||
"retrying": "Reintentando...",
|
||||
"reconnected": ""
|
||||
"reconnected": "Conexión restablecida"
|
||||
},
|
||||
"sidebar": {
|
||||
"menu": {
|
||||
"uptime": "",
|
||||
"pagespeed": "",
|
||||
"infrastructure": "",
|
||||
"notifications": "",
|
||||
"uptime": "Disponibilidad",
|
||||
"pagespeed": "Pagespeed",
|
||||
"infrastructure": "Infraestructura",
|
||||
"notifications": "Notificaciones",
|
||||
"checks": "Verificaciones",
|
||||
"incidents": "",
|
||||
"statusPages": "",
|
||||
"maintenance": "",
|
||||
"logs": "",
|
||||
"settings": ""
|
||||
"incidents": "Incidentes",
|
||||
"statusPages": "Páginas de estado",
|
||||
"maintenance": "Mantenimiento",
|
||||
"logs": "Registros",
|
||||
"settings": "Configuración"
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "",
|
||||
"discussions": "",
|
||||
"docs": "",
|
||||
"changelog": ""
|
||||
"support": "Soporte",
|
||||
"discussions": "Discusiones",
|
||||
"docs": "Documentación",
|
||||
"changelog": "Registro de cambios"
|
||||
},
|
||||
"accountMenu": {
|
||||
"profile": "Perfil",
|
||||
@@ -186,13 +193,13 @@
|
||||
"description": "Vea las últimas versiones y ayude a crecer la comunidad en GitHub"
|
||||
},
|
||||
"authFooter": {
|
||||
"navControls": "",
|
||||
"logOut": "",
|
||||
"navControls": "Controles",
|
||||
"logOut": "Cerrar sesión",
|
||||
"roles": {
|
||||
"superAdmin": "Superadministrador",
|
||||
"admin": "Administrador",
|
||||
"user": "Usuario",
|
||||
"demoUser": ""
|
||||
"demoUser": "Usuario demo"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -259,15 +266,15 @@
|
||||
"team": {
|
||||
"filter": {
|
||||
"placeholder": "Filtrar por rol",
|
||||
"all": "",
|
||||
"all": "Todos los roles",
|
||||
"admin": "Administrador",
|
||||
"member": ""
|
||||
"member": "Miembro"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"email": "Correo electrónico",
|
||||
"role": "Rol",
|
||||
"created": ""
|
||||
"created": "Creado el"
|
||||
}
|
||||
},
|
||||
"addMember": {
|
||||
@@ -306,35 +313,35 @@
|
||||
"common": {
|
||||
"passwordRules": {
|
||||
"length": {
|
||||
"beginning": "",
|
||||
"highlighted": ""
|
||||
"beginning": "Debe tener al menos",
|
||||
"highlighted": "8 caracteres"
|
||||
},
|
||||
"lowercase": {
|
||||
"beginning": "",
|
||||
"highlighted": ""
|
||||
"beginning": "Debe contener al menos",
|
||||
"highlighted": "una letra minúscula"
|
||||
},
|
||||
"match": {
|
||||
"beginning": "",
|
||||
"highlighted": ""
|
||||
"beginning": "Las contraseñas",
|
||||
"highlighted": "deben coincidir"
|
||||
},
|
||||
"number": {
|
||||
"beginning": "",
|
||||
"highlighted": ""
|
||||
"beginning": "Debe contener al menos",
|
||||
"highlighted": "un número"
|
||||
},
|
||||
"special": {
|
||||
"beginning": "",
|
||||
"highlighted": ""
|
||||
"beginning": "Debe contener al menos",
|
||||
"highlighted": "un carácter especial (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)"
|
||||
},
|
||||
"uppercase": {
|
||||
"beginning": "",
|
||||
"highlighted": ""
|
||||
"beginning": "Debe contener al menos",
|
||||
"highlighted": "una letra mayúscula"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"option": {
|
||||
"email": {
|
||||
"label": "Correo electrónico",
|
||||
"placeholder": "me@example.com"
|
||||
"placeholder": "yo@ejemplo.com"
|
||||
},
|
||||
"password": {
|
||||
"label": "Contraseña",
|
||||
@@ -364,7 +371,8 @@
|
||||
"register": {
|
||||
"title": "¡Bienvenido de nuevo a Checkmate!",
|
||||
"subtitle": "Regístrese para comenzar",
|
||||
"submit": "Registrarse"
|
||||
"submit": "Registrarse",
|
||||
"setupNotice": "Esta página solo está disponible durante la configuración inicial. Una vez que se haya registrado un super administrador, se podrá invitar a usuarios adicionales desde la configuración del equipo."
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "¿Olvidó su contraseña?",
|
||||
@@ -426,7 +434,8 @@
|
||||
"activeFor": "Activo desde hace",
|
||||
"certificateExpiry": "Vencimiento del certificado",
|
||||
"lastCheck": "Última verificación",
|
||||
"lastResponseTime": "Último tiempo de respuesta"
|
||||
"lastResponseTime": "Último tiempo de respuesta",
|
||||
"serviceIsDown": "El servicio está caído"
|
||||
},
|
||||
"status": {
|
||||
"down": "caído",
|
||||
@@ -467,9 +476,9 @@
|
||||
},
|
||||
"matchMethod": {
|
||||
"label": "Método de coincidencia",
|
||||
"equal": "",
|
||||
"include": "",
|
||||
"regex": ""
|
||||
"equal": "Igual",
|
||||
"include": "Contiene",
|
||||
"regex": "Regex"
|
||||
}
|
||||
},
|
||||
"title": "Configuración avanzada"
|
||||
@@ -481,15 +490,15 @@
|
||||
"label": "Frecuencia de verificación",
|
||||
"value": {
|
||||
"fifteenMinutes": "15 minutos",
|
||||
"fifteenSeconds": "",
|
||||
"fifteenSeconds": "15 segundos",
|
||||
"fiveMinutes": "5 minutos",
|
||||
"fourMinutes": "",
|
||||
"oneMinute": "",
|
||||
"fourMinutes": "4 minutos",
|
||||
"oneMinute": "1 minuto",
|
||||
"tenMinutes": "10 minutos",
|
||||
"thirtyMinutes": "30 minutos",
|
||||
"thirtySeconds": "",
|
||||
"threeMinutes": "",
|
||||
"twoMinutes": ""
|
||||
"thirtySeconds": "30 segundos",
|
||||
"threeMinutes": "3 minutos",
|
||||
"twoMinutes": "2 minutos"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -518,8 +527,8 @@
|
||||
"placeholder": "https://www.google.com"
|
||||
},
|
||||
"game": {
|
||||
"label": "",
|
||||
"placeholder": ""
|
||||
"label": "Elige el juego",
|
||||
"placeholder": "Selecciona un juego"
|
||||
},
|
||||
"port": {
|
||||
"label": "Puerto a monitorear",
|
||||
@@ -537,10 +546,10 @@
|
||||
"title": "Configuración general",
|
||||
"description": {
|
||||
"http": "Ingrese la URL o IP a monitorear (p. ej., https://example.com/ o 192.168.1.100) y agregue un nombre para mostrar que aparecerá en el panel.",
|
||||
"ping": "",
|
||||
"port": "",
|
||||
"docker": "",
|
||||
"game": "",
|
||||
"ping": "Introduce la dirección IP o el nombre de host para hacer ping (p. ej., 192.168.1.100 o ejemplo.com) y un nombre descriptivo que aparecerá en el panel.",
|
||||
"port": "Introduce la URL o IP del servidor, el número de puerto y un nombre descriptivo que aparecerá en el panel.",
|
||||
"docker": "Introduce el nombre o ID del contenedor de Docker. Puedes usar el nombre del contenedor (p. ej., my-app) o su ID.",
|
||||
"game": "Introduce la dirección IP o el nombre de host y el número de puerto para hacer ping (p. ej., 192.168.1.100 o ejemplo.com) y un nombre descriptivo que aparecerá en el panel. Algunos juegos requieren habilitar consultas del servidor en su archivo de configuración — consulta la documentación de tu servidor. Consulta la <gamedigLink>lista de juegos compatibles</gamedigLink> para ver los detalles del protocolo.",
|
||||
"pagespeed": "Rastree el rendimiento de carga de página, Core Web Vitals y puntuaciones de optimización de su sitio web.",
|
||||
"grpc": "Ingrese el nombre de host y puerto del servidor gRPC, opcionalmente especifique un nombre de servicio para la verificación de salud, y agregue un nombre para mostrar.",
|
||||
"websocket": "Ingrese la URL de WebSocket a monitorear (p. ej., wss://example.com/socket) y agregue un nombre para mostrar.",
|
||||
@@ -745,7 +754,7 @@
|
||||
"headers": {
|
||||
"endTime": "Hora de fin",
|
||||
"resolutionType": "Tipo de resolución",
|
||||
"startTime": "",
|
||||
"startTime": "Hora de inicio",
|
||||
"statusCode": "Código de estado"
|
||||
},
|
||||
"resolvedIncidents": "Incidentes resueltos",
|
||||
@@ -767,6 +776,7 @@
|
||||
"memory": "Uso de memoria",
|
||||
"netBytesRecv": "{{name}} - Bytes recibidos",
|
||||
"netBytesSent": "{{name}} - Bytes enviados",
|
||||
"netNoTraffic": "Capture no informa tráfico en ninguna interfaz. Esto es normal cuando Capture se ejecuta dentro de un contenedor que solo ve interfaces de loopback — ejecuta Capture directamente en el host para ver actividad de red real.",
|
||||
"temp": "Temp"
|
||||
}
|
||||
},
|
||||
@@ -788,10 +798,10 @@
|
||||
}
|
||||
},
|
||||
"statBoxes": {
|
||||
"avgCpuTemperature": "",
|
||||
"cpuFrequency": "",
|
||||
"cpuLogical": "",
|
||||
"cpuPhysical": "",
|
||||
"avgCpuTemperature": "Temperatura media de la CPU",
|
||||
"cpuFrequency": "Frecuencia de la CPU",
|
||||
"cpuLogical": "CPU (lógicas)",
|
||||
"cpuPhysical": "CPU (físicas)",
|
||||
"disk": "Disco",
|
||||
"memory": "Memoria",
|
||||
"os": "SO"
|
||||
@@ -805,7 +815,7 @@
|
||||
},
|
||||
"tabs": {
|
||||
"labels": {
|
||||
"network": "",
|
||||
"network": "Red",
|
||||
"overview": "Resumen"
|
||||
}
|
||||
},
|
||||
@@ -825,9 +835,9 @@
|
||||
"description": "La actividad del servidor, advertencias y errores aparecerán aquí mientras Checkmate se ejecuta."
|
||||
},
|
||||
"tabs": {
|
||||
"diagnostics": "",
|
||||
"logs": "",
|
||||
"queue": ""
|
||||
"diagnostics": "Diagnóstico",
|
||||
"logs": "Registros",
|
||||
"queue": "Cola de trabajos"
|
||||
},
|
||||
"logLevelSelect": {
|
||||
"label": "Nivel de registro"
|
||||
@@ -865,7 +875,7 @@
|
||||
},
|
||||
"sections": {
|
||||
"runtimeStats": {
|
||||
"title": "Estadísticas de ejecución",
|
||||
"title": "Estadísticas de tiempo de ejecución",
|
||||
"description": "Instantánea del proceso Node.js y la máquina anfitriona. Se actualiza cada 5 segundos."
|
||||
},
|
||||
"memoryCpu": {
|
||||
@@ -908,8 +918,8 @@
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"nextWindow": "",
|
||||
"repeat": ""
|
||||
"nextWindow": "Próxima ventana",
|
||||
"repeat": "Repetición"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -1016,6 +1026,26 @@
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "ID de chat",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
},
|
||||
"pushover": {
|
||||
"title": "Configuración de Pushover",
|
||||
"description": "Introduce tu token de aplicación y la clave de usuario de Pushover.",
|
||||
"optionAppToken": "Token de aplicación",
|
||||
"placeholderAppToken": "Introduce el token de aplicación",
|
||||
"optionUserKey": "Clave de usuario",
|
||||
"placeholderUserKey": "Introduce la clave de usuario"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "Configuración de Twilio",
|
||||
"description": "Introduce las credenciales de tu cuenta de Twilio para enviar SMS.",
|
||||
"optionAccountSid": "SID de la cuenta",
|
||||
"placeholderAccountSid": "Introduce el SID de la cuenta",
|
||||
"optionAuthToken": "Token de autenticación",
|
||||
"placeholderAuthToken": "Introduce el token de autenticación",
|
||||
"optionFromNumber": "Número de origen",
|
||||
"placeholderFromNumber": "+1234567890",
|
||||
"optionToNumber": "Número de destino",
|
||||
"placeholderToNumber": "+1234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1160,7 +1190,7 @@
|
||||
"email": {
|
||||
"title": "Configuración de correo electrónico",
|
||||
"description": "Configure los ajustes de correo electrónico de su sistema. Se usa para enviar notificaciones y alertas.",
|
||||
"descriptionTransport": "",
|
||||
"descriptionTransport": "Esto crea un transporte SMTP para NodeMailer",
|
||||
"descriptionTransportLink": "Ver especificaciones aquí",
|
||||
"option": {
|
||||
"host": {
|
||||
@@ -1214,11 +1244,12 @@
|
||||
"description": "Agregue monitores de ejemplo con fines demostrativos."
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "Reinicio del sistema",
|
||||
"description": "Elimine todos los monitores de su sistema.",
|
||||
"title": "Reiniciar sistema",
|
||||
"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": {
|
||||
@@ -1271,7 +1302,18 @@
|
||||
"allDown": "Todos los sistemas están caídos",
|
||||
"allUp": "Todos los sistemas operativos",
|
||||
"degraded": "Algunos sistemas están experimentando problemas",
|
||||
"unknown": "No se puede determinar el estado del sistema"
|
||||
"unknown": "No se puede determinar el estado del sistema",
|
||||
"allBreached": "Todos los sistemas han superado los umbrales",
|
||||
"allMaintenance": "Todos los sistemas están en mantenimiento",
|
||||
"allPaused": "Todos los sistemas están en pausa",
|
||||
"allInitializing": "Todos los sistemas se están inicializando",
|
||||
"breachedAndDown": "Algunos sistemas han superado los umbrales y están caídos",
|
||||
"breached": "Algunos sistemas han superado los umbrales",
|
||||
"maintenanceAndDown": "Algunos sistemas están caídos durante el mantenimiento",
|
||||
"maintenance": "Algunos sistemas están en mantenimiento",
|
||||
"partiallyPaused": "Algunos sistemas están en pausa",
|
||||
"initializing": "Algunos sistemas se están inicializando",
|
||||
"noMonitors": "No se ha configurado ningún monitor"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
@@ -1367,6 +1409,13 @@
|
||||
"header": {
|
||||
"title": "Páginas de estado",
|
||||
"description": "Publica páginas públicas que muestren a tus clientes y partes interesadas la disponibilidad en tiempo real y el historial de incidentes."
|
||||
},
|
||||
"publicPage": {
|
||||
"maintenance": {
|
||||
"upcoming": "Mantenimiento programado",
|
||||
"inProgress": "Mantenimiento en curso",
|
||||
"completed": "Mantenimiento completado"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uptime": {
|
||||
|
||||
+62
-27
@@ -13,7 +13,7 @@
|
||||
"content": "Varoitus: Et ole vielä lisännyt Google PageSpeed API -avainta. Siirry <settingsLink>Asetuksiin</settingsLink> lisätäksesi sellainen. Ilman sitä PageSpeed-monitorointi ei toimi."
|
||||
}
|
||||
},
|
||||
"appName": "",
|
||||
"appName": "Checkmate",
|
||||
"breadcrumbs": {
|
||||
"details": "Tiedot",
|
||||
"home": "Etusivu"
|
||||
@@ -35,10 +35,10 @@
|
||||
"sendInvite": "Lähetä kutsu",
|
||||
"test": "Testaa",
|
||||
"testNotifications": "Testaa ilmoitukset",
|
||||
"toggleTheme": "",
|
||||
"toggleTheme": "Vaihda vaalea / tumma",
|
||||
"flushQueue": "Tyhjennä jono",
|
||||
"notFound": "Siirry pääsivulle",
|
||||
"resetPassword": "",
|
||||
"resetPassword": "Nollaa salasana",
|
||||
"reset": "Palauta",
|
||||
"clear": "Tyhjennä",
|
||||
"addDemo": "Lisää demomonitorit",
|
||||
@@ -53,8 +53,8 @@
|
||||
"labels": {
|
||||
"averageResponseTime": "Keskimääräinen vasteaika",
|
||||
"downtime": "Käyttökatko",
|
||||
"high": "",
|
||||
"low": "",
|
||||
"high": "Korkea",
|
||||
"low": "Matala",
|
||||
"uptime": "Käytettävyys"
|
||||
},
|
||||
"histogram": {
|
||||
@@ -86,12 +86,12 @@
|
||||
"name": {
|
||||
"option": {
|
||||
"firstName": {
|
||||
"label": "",
|
||||
"placeholder": ""
|
||||
"label": "Etunimi",
|
||||
"placeholder": "Anna etunimesi"
|
||||
},
|
||||
"lastName": {
|
||||
"label": "",
|
||||
"placeholder": ""
|
||||
"label": "Sukunimi",
|
||||
"placeholder": "Anna sukunimesi"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -107,14 +107,14 @@
|
||||
"table": {
|
||||
"empty": "Täällä ei ole mitään",
|
||||
"headers": {
|
||||
"actions": "",
|
||||
"actions": "Toiminnot",
|
||||
"dateTime": "Päivämäärä ja aika",
|
||||
"message": "Viesti",
|
||||
"monitor": "Monitori",
|
||||
"monitorId": "Monitorin tunnus",
|
||||
"name": "Nimi",
|
||||
"status": "Tila",
|
||||
"type": "",
|
||||
"type": "Tyyppi",
|
||||
"url": "Osoite",
|
||||
"interval": "Aikaväli",
|
||||
"active": "Aktiivinen",
|
||||
@@ -131,7 +131,7 @@
|
||||
"orDragAndDrop": "tai vedä ja pudota",
|
||||
"errors": {
|
||||
"invalidFileSize": "Tiedoston koko on liian suuri!",
|
||||
"invalidFileFormat": ""
|
||||
"invalidFileFormat": "Tiedostomuotoa ei tueta!"
|
||||
}
|
||||
},
|
||||
"headerStatusPageControls": {
|
||||
@@ -155,19 +155,19 @@
|
||||
"serverUnreachable": "Palvelimeen ei saada yhteyttä",
|
||||
"retry": "Yritä uudelleen",
|
||||
"retrying": "Yritetään uudelleen...",
|
||||
"reconnected": ""
|
||||
"reconnected": "Yhteys palautettu"
|
||||
},
|
||||
"sidebar": {
|
||||
"menu": {
|
||||
"uptime": "",
|
||||
"pagespeed": "",
|
||||
"uptime": "Käytettävyys",
|
||||
"pagespeed": "Pagespeed",
|
||||
"infrastructure": "Infrastruktuuri",
|
||||
"notifications": "",
|
||||
"notifications": "Ilmoitukset",
|
||||
"checks": "Tarkistukset",
|
||||
"incidents": "",
|
||||
"incidents": "Häiriöt",
|
||||
"statusPages": "Tilasivut",
|
||||
"maintenance": "Huolto",
|
||||
"logs": "",
|
||||
"logs": "Lokit",
|
||||
"settings": "Asetukset"
|
||||
},
|
||||
"bottomMenu": {
|
||||
@@ -364,7 +364,8 @@
|
||||
"register": {
|
||||
"title": "Tervetuloa takaisin Checkmateen!",
|
||||
"subtitle": "Rekisteröidy aloittaaksesi",
|
||||
"submit": "Rekisteröidy"
|
||||
"submit": "Rekisteröidy",
|
||||
"setupNotice": "Tämä sivu on käytettävissä vain alkuasennuksen aikana. Kun super-admin on rekisteröity, lisäkäyttäjiä voi kutsua tiimiasetuksista."
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Unohditko salasanasi?",
|
||||
@@ -426,7 +427,8 @@
|
||||
"activeFor": "Aktiivinen",
|
||||
"certificateExpiry": "Varmenteen vanheneminen",
|
||||
"lastCheck": "Viimeisin tarkistus",
|
||||
"lastResponseTime": "Viimeisin vasteaika"
|
||||
"lastResponseTime": "Viimeisin vasteaika",
|
||||
"serviceIsDown": "Palvelu ei ole käytettävissä"
|
||||
},
|
||||
"status": {
|
||||
"down": "alhaalla",
|
||||
@@ -540,7 +542,7 @@
|
||||
"ping": "",
|
||||
"port": "",
|
||||
"docker": "",
|
||||
"game": "",
|
||||
"game": "Anna IP-osoite tai isäntänimi ja portin numero pingausta varten (esim. 192.168.1.100 tai example.com) ja valitse pelityyppi. Jotkin pelit vaativat palvelinkyselyiden käyttöönoton asetustiedostossaan — tarkista pelipalvelimesi dokumentaatio. Katso protokollatiedot <gamedigLink>tuettujen pelien luettelosta</gamedigLink>.",
|
||||
"pagespeed": "Seuraa sivun latausnopeutta, Core Web Vitals -arvoja ja optimointipisteitä verkkosivustollasi.",
|
||||
"grpc": "Syötä gRPC-palvelimen isäntänimi ja portti, määritä halutessasi palvelun nimi terveystarkistusta varten ja lisää näyttönimi.",
|
||||
"websocket": "Syötä monitoroitava WebSocket URL (esim. wss://example.com/socket) ja lisää näyttönimi.",
|
||||
@@ -767,6 +769,7 @@
|
||||
"memory": "Muistin käyttö",
|
||||
"netBytesRecv": "{{name}} - tavua vastaanotettu",
|
||||
"netBytesSent": "{{name}} - tavua lähetetty",
|
||||
"netNoTraffic": "Capture ilmoittaa nollaliikennettä kaikilla rajapinnoilla. Tämä on normaalia, kun Capture käynnistetään säiliössä, joka näkee vain loopback-rajapinnat — suorita Capture suoraan isännällä nähdäksesi todellisen verkkoaktiviteetin.",
|
||||
"temp": "Lämpö"
|
||||
}
|
||||
},
|
||||
@@ -825,9 +828,9 @@
|
||||
"description": "Palvelimen toiminta, varoitukset ja virheet näkyvät täällä Checkmaten toimiessa."
|
||||
},
|
||||
"tabs": {
|
||||
"diagnostics": "",
|
||||
"logs": "",
|
||||
"queue": ""
|
||||
"diagnostics": "Diagnostiikka",
|
||||
"logs": "Lokit",
|
||||
"queue": "Työjono"
|
||||
},
|
||||
"logLevelSelect": {
|
||||
"label": "Lokitaso"
|
||||
@@ -1016,6 +1019,26 @@
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "Chat-tunnus",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
},
|
||||
"pushover": {
|
||||
"title": "Pushover-määritys",
|
||||
"description": "Anna Pushover-sovellustunnus ja käyttäjäavain.",
|
||||
"optionAppToken": "Sovellustunnus",
|
||||
"placeholderAppToken": "Anna sovellustunnus",
|
||||
"optionUserKey": "Käyttäjäavain",
|
||||
"placeholderUserKey": "Anna käyttäjäavain"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "Twilio-määritys",
|
||||
"description": "Anna Twilio-tilin tunnistetiedot tekstiviestien lähettämistä varten.",
|
||||
"optionAccountSid": "Tilin SID",
|
||||
"placeholderAccountSid": "Anna tilin SID",
|
||||
"optionAuthToken": "Todennustunnus",
|
||||
"placeholderAuthToken": "Anna todennustunnus",
|
||||
"optionFromNumber": "Lähettäjän numero",
|
||||
"placeholderFromNumber": "+1234567890",
|
||||
"optionToNumber": "Vastaanottajan numero",
|
||||
"placeholderToNumber": "+1234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1214,11 +1237,12 @@
|
||||
"description": "Lisää esimerkkimonitoreja esittelytarkoituksiin."
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "Järjestelmän nollaus",
|
||||
"title": "Järjestelmän palautus",
|
||||
"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": {
|
||||
@@ -1271,7 +1295,18 @@
|
||||
"allDown": "Kaikki järjestelmät ovat alhaalla",
|
||||
"allUp": "Kaikki järjestelmät toiminnassa",
|
||||
"degraded": "Joissakin järjestelmissä on ongelmia",
|
||||
"unknown": "Järjestelmän tilaa ei voida määrittää"
|
||||
"unknown": "Järjestelmän tilaa ei voida määrittää",
|
||||
"allBreached": "Kaikki järjestelmät ovat ylittäneet kynnysarvot",
|
||||
"allMaintenance": "Kaikki järjestelmät ovat huollossa",
|
||||
"allPaused": "Kaikki järjestelmät on keskeytetty",
|
||||
"allInitializing": "Kaikki järjestelmät alustuvat",
|
||||
"breachedAndDown": "Jotkin järjestelmät ovat ylittäneet kynnysarvot ja ovat alhaalla",
|
||||
"breached": "Jotkin järjestelmät ovat ylittäneet kynnysarvot",
|
||||
"maintenanceAndDown": "Jotkin järjestelmät ovat alhaalla huollon aikana",
|
||||
"maintenance": "Jotkin järjestelmät ovat huollossa",
|
||||
"partiallyPaused": "Jotkin järjestelmät on keskeytetty",
|
||||
"initializing": "Jotkin järjestelmät alustuvat",
|
||||
"noMonitors": "Yhtään monitoria ei ole määritetty"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
|
||||
@@ -364,7 +364,8 @@
|
||||
"register": {
|
||||
"title": "Bienvenue sur Checkmate !",
|
||||
"subtitle": "Inscrivez-vous pour commencer",
|
||||
"submit": "S'inscrire"
|
||||
"submit": "S'inscrire",
|
||||
"setupNotice": "Cette page n'est disponible que lors de la configuration initiale. Une fois qu'un super administrateur est enregistré, des utilisateurs supplémentaires peuvent être invités depuis les paramètres de l'équipe."
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Mot de passe oublié ?",
|
||||
@@ -426,7 +427,8 @@
|
||||
"activeFor": "Actif depuis",
|
||||
"certificateExpiry": "Expiration du certificat",
|
||||
"lastCheck": "Dernière vérification",
|
||||
"lastResponseTime": "Dernier temps de réponse"
|
||||
"lastResponseTime": "Dernier temps de réponse",
|
||||
"serviceIsDown": "Le service est indisponible"
|
||||
},
|
||||
"status": {
|
||||
"down": "hors ligne",
|
||||
@@ -540,7 +542,7 @@
|
||||
"ping": "Entrez l'adresse IP ou le nom d'hôte à tester (par exemple, 192.168.1.100 ou exemple.fr) et ajoutez un nom familier qui apparaîtra sur le tableau de bord.",
|
||||
"port": "Entrez l'URL ou l'adresse IP du serveur, le numéro de port et un nom d'affichage familier qui apparaîtra sur le tableau de bord.",
|
||||
"docker": "Entrer l'ID Docker du container. Les identifiants Docker doivent être les 64 caractères de l'ID Docker. Vous pouvez utiliser la commande docker inspect <short_id> pour avoir l'ID complet.",
|
||||
"game": "",
|
||||
"game": "Saisissez l'adresse IP ou le nom d'hôte et le numéro de port à interroger (par ex. 192.168.1.100 ou example.com) et choisissez le type de jeu. Certains jeux nécessitent l'activation des requêtes serveur dans leur fichier de configuration — consultez la documentation de votre serveur de jeu. Voir la <gamedigLink>liste des jeux pris en charge</gamedigLink> pour les détails du protocole.",
|
||||
"pagespeed": "Suivez les performances de chargement, les Core Web Vitals et les scores d'optimisation de votre site web.",
|
||||
"grpc": "Saisissez le nom d'hôte et le port du serveur gRPC, spécifiez éventuellement un nom de service pour le bilan de santé, et ajoutez un nom d'affichage.",
|
||||
"websocket": "Saisissez l'URL WebSocket à surveiller (ex. wss://example.com/socket) et ajoutez un nom d'affichage.",
|
||||
@@ -767,6 +769,7 @@
|
||||
"memory": "Utilisation mémoire",
|
||||
"netBytesRecv": "{{name}} - Octets reçus",
|
||||
"netBytesSent": "{{name}} - Octets envoyés",
|
||||
"netNoTraffic": "Capture ne signale aucun trafic sur toutes les interfaces. C'est normal lorsque Capture s'exécute dans un conteneur qui ne voit que les interfaces de loopback — exécutez Capture directement sur l'hôte pour voir l'activité réseau réelle.",
|
||||
"temp": "Temp."
|
||||
}
|
||||
},
|
||||
@@ -1016,6 +1019,26 @@
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "ID du chat",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
},
|
||||
"pushover": {
|
||||
"title": "Configuration Pushover",
|
||||
"description": "Entrez votre jeton d'application et votre clé utilisateur Pushover.",
|
||||
"optionAppToken": "Jeton d'application",
|
||||
"placeholderAppToken": "Entrez le jeton d'application",
|
||||
"optionUserKey": "Clé utilisateur",
|
||||
"placeholderUserKey": "Entrez la clé utilisateur"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "Configuration Twilio",
|
||||
"description": "Entrez les identifiants de votre compte Twilio pour envoyer des SMS.",
|
||||
"optionAccountSid": "SID du compte",
|
||||
"placeholderAccountSid": "Entrez le SID du compte",
|
||||
"optionAuthToken": "Jeton d'authentification",
|
||||
"placeholderAuthToken": "Entrez le jeton d'authentification",
|
||||
"optionFromNumber": "Numéro d'origine",
|
||||
"placeholderFromNumber": "+1234567890",
|
||||
"optionToNumber": "Numéro de destination",
|
||||
"placeholderToNumber": "+1234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1214,11 +1237,12 @@
|
||||
"description": "Ajoutez des moniteurs d'exemple à des fins de démonstration."
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "Réinitialisation du système",
|
||||
"description": "Supprimez tous les moniteurs de votre système.",
|
||||
"title": "Réinitialiser le système",
|
||||
"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": {
|
||||
@@ -1271,7 +1295,18 @@
|
||||
"allDown": "Tous les systèmes sont hors service",
|
||||
"allUp": "Tous les systèmes sont opérationnels",
|
||||
"degraded": "Certains systèmes rencontrent des problèmes",
|
||||
"unknown": "Impossible de déterminer le statut du système"
|
||||
"unknown": "Impossible de déterminer le statut du système",
|
||||
"allBreached": "Tous les systèmes ont dépassé les seuils",
|
||||
"allMaintenance": "Tous les systèmes sont en maintenance",
|
||||
"allPaused": "Tous les systèmes sont en pause",
|
||||
"allInitializing": "Tous les systèmes sont en cours d'initialisation",
|
||||
"breachedAndDown": "Certains systèmes ont dépassé les seuils et sont hors ligne",
|
||||
"breached": "Certains systèmes ont dépassé les seuils",
|
||||
"maintenanceAndDown": "Certains systèmes sont hors ligne pendant la maintenance",
|
||||
"maintenance": "Certains systèmes sont en maintenance",
|
||||
"partiallyPaused": "Certains systèmes sont en pause",
|
||||
"initializing": "Certains systèmes sont en cours d'initialisation",
|
||||
"noMonitors": "Aucun moniteur n'a été configuré"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
|
||||
@@ -364,7 +364,8 @@
|
||||
"register": {
|
||||
"title": "Checkmateへようこそ!",
|
||||
"subtitle": "サインアップして始めましょう",
|
||||
"submit": "登録"
|
||||
"submit": "登録",
|
||||
"setupNotice": "このページは初期セットアップ中のみ利用できます。スーパー管理者が登録されると、追加のユーザーはチーム設定から招待できます。"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "パスワードをお忘れですか?",
|
||||
@@ -426,7 +427,8 @@
|
||||
"activeFor": "稼働期間",
|
||||
"certificateExpiry": "証明書の有効期限",
|
||||
"lastCheck": "最終チェック",
|
||||
"lastResponseTime": "最終応答時間"
|
||||
"lastResponseTime": "最終応答時間",
|
||||
"serviceIsDown": "サービスがダウンしています"
|
||||
},
|
||||
"status": {
|
||||
"down": "ダウン",
|
||||
@@ -540,7 +542,7 @@
|
||||
"ping": "pingするIPアドレスまたはホスト名を入力(例: 192.168.1.100 または example.com)し、ダッシュボードに表示される明確な表示名を追加。",
|
||||
"port": "サーバーのURLまたはIP、ポート番号、ダッシュボードに表示される明確な表示名を入力。",
|
||||
"docker": "コンテナのDocker IDを入力。Docker IDは64文字のフルDocker IDである必要があります。docker inspect <short_id>を実行してフルコンテナIDを取得できます。",
|
||||
"game": "",
|
||||
"game": "Ping を実行する IP アドレスまたはホスト名とポート番号(例: 192.168.1.100 や example.com)を入力し、ゲームタイプを選択してください。一部のゲームでは設定ファイルでサーバークエリを有効にする必要があります — ゲームサーバーのドキュメントを確認してください。プロトコルの詳細は<gamedigLink>サポートされているゲームの一覧</gamedigLink>をご覧ください。",
|
||||
"pagespeed": "Webサイトのページ読み込みパフォーマンス、Core Web Vitals、最適化スコアを追跡します。",
|
||||
"grpc": "gRPCサーバーのホスト名とポートを入力し、必要に応じてヘルスチェック用のサービス名を指定し、表示名を追加してください。",
|
||||
"websocket": "監視するWebSocket URLを入力し(例: wss://example.com/socket)、表示名を追加してください。",
|
||||
@@ -767,6 +769,7 @@
|
||||
"memory": "メモリ使用率",
|
||||
"netBytesRecv": "{{name}} - 受信バイト数",
|
||||
"netBytesSent": "{{name}} - 送信バイト数",
|
||||
"netNoTraffic": "Capture はすべてのインターフェイスでゼロのトラフィックを報告しています。これは Capture がループバックインターフェイスのみを認識できるコンテナ内で実行されている場合に正常です — 実際のネットワークアクティビティを表示するには、ホスト上で Capture を直接実行してください。",
|
||||
"temp": "温度"
|
||||
}
|
||||
},
|
||||
@@ -1016,6 +1019,26 @@
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "チャットID",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
},
|
||||
"pushover": {
|
||||
"title": "Pushover の設定",
|
||||
"description": "Pushover のアプリケーショントークンとユーザーキーを入力してください。",
|
||||
"optionAppToken": "アプリトークン",
|
||||
"placeholderAppToken": "アプリトークンを入力",
|
||||
"optionUserKey": "ユーザーキー",
|
||||
"placeholderUserKey": "ユーザーキーを入力"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "Twilio の設定",
|
||||
"description": "SMS を送信するための Twilio アカウント認証情報を入力してください。",
|
||||
"optionAccountSid": "アカウント SID",
|
||||
"placeholderAccountSid": "アカウント SID を入力",
|
||||
"optionAuthToken": "認証トークン",
|
||||
"placeholderAuthToken": "認証トークンを入力",
|
||||
"optionFromNumber": "送信元番号",
|
||||
"placeholderFromNumber": "+1234567890",
|
||||
"optionToNumber": "送信先番号",
|
||||
"placeholderToNumber": "+1234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1218,7 +1241,8 @@
|
||||
"description": "システムからすべてのモニターを削除します。",
|
||||
"dialog": {
|
||||
"title": "すべてのモニターを削除しますか?",
|
||||
"description": "この操作は元に戻せません。"
|
||||
"description": "この操作は元に戻せません。",
|
||||
"paragraph": "これらのモニターに関連するチェック履歴、インシデント、予定されたメンテナンスウィンドウ、ステータスページもすべて削除されます。データのバックアップを残したい場合は、先にモニターをエクスポートしてください。"
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
@@ -1271,7 +1295,18 @@
|
||||
"allDown": "すべてのシステムがダウンしています",
|
||||
"allUp": "すべてのシステムが正常に稼働しています",
|
||||
"degraded": "一部のシステムに問題が発生しています",
|
||||
"unknown": "システムステータスを判定できません"
|
||||
"unknown": "システムステータスを判定できません",
|
||||
"allBreached": "すべてのシステムがしきい値を超えました",
|
||||
"allMaintenance": "すべてのシステムがメンテナンス中です",
|
||||
"allPaused": "すべてのシステムが一時停止中です",
|
||||
"allInitializing": "すべてのシステムが初期化中です",
|
||||
"breachedAndDown": "一部のシステムがしきい値を超えてダウンしています",
|
||||
"breached": "一部のシステムがしきい値を超えています",
|
||||
"maintenanceAndDown": "一部のシステムがメンテナンス中にダウンしています",
|
||||
"maintenance": "一部のシステムがメンテナンス中です",
|
||||
"partiallyPaused": "一部のシステムが一時停止中です",
|
||||
"initializing": "一部のシステムが初期化中です",
|
||||
"noMonitors": "モニターが設定されていません"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
|
||||
@@ -364,7 +364,8 @@
|
||||
"register": {
|
||||
"title": "Bem-vindo ao Checkmate!",
|
||||
"subtitle": "Cadastre-se para começar",
|
||||
"submit": "Cadastrar"
|
||||
"submit": "Cadastrar",
|
||||
"setupNotice": "Esta página está disponível apenas durante a configuração inicial. Após o registro de um super administrador, usuários adicionais podem ser convidados a partir das configurações da equipe."
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Esqueceu sua senha?",
|
||||
@@ -426,7 +427,8 @@
|
||||
"activeFor": "Ativo há",
|
||||
"certificateExpiry": "Validade do certificado",
|
||||
"lastCheck": "Última verificação",
|
||||
"lastResponseTime": "Último tempo de resposta"
|
||||
"lastResponseTime": "Último tempo de resposta",
|
||||
"serviceIsDown": "O serviço está fora do ar"
|
||||
},
|
||||
"status": {
|
||||
"down": "fora do ar",
|
||||
@@ -540,7 +542,7 @@
|
||||
"ping": "Insira o endereço IP ou nome de domínio para ping (ex. 192.168.1.100 ou exemplo.com.br) e adicione uma descrição que aparecerá na dashboard.",
|
||||
"port": "Insira a URL ou o IP do servidor, aporta e uma descrição que aparecerá na dashboard.",
|
||||
"docker": "Insira o Docker Id do seu container. Docker Ids devem ser todos os 64 caracteres. Você pode executar docker inspect <short_id> para descobrir o Id completo.",
|
||||
"game": "",
|
||||
"game": "Digite o endereço IP ou nome de host e o número da porta para pingar (por exemplo, 192.168.1.100 ou example.com) e escolha o tipo de jogo. Alguns jogos exigem habilitar consultas ao servidor no arquivo de configuração — consulte a documentação do seu servidor de jogo. Veja a <gamedigLink>lista de jogos suportados</gamedigLink> para detalhes do protocolo.",
|
||||
"pagespeed": "Acompanhe o desempenho de carregamento da página, Core Web Vitals e pontuações de otimização do seu site.",
|
||||
"grpc": "Digite o hostname e a porta do servidor gRPC, opcionalmente especifique um nome de serviço para a verificação de saúde e adicione um nome de exibição.",
|
||||
"websocket": "Digite a URL do WebSocket para monitorar (ex.: wss://exemplo.com/socket) e adicione um nome de exibição.",
|
||||
@@ -767,6 +769,7 @@
|
||||
"memory": "Uso de memória",
|
||||
"netBytesRecv": "{{name}} - Bytes recebidos",
|
||||
"netBytesSent": "{{name}} - Bytes enviados",
|
||||
"netNoTraffic": "Capture está reportando zero tráfego em todas as interfaces. Isso é normal quando Capture é executado dentro de um container que só consegue ver interfaces de loopback — execute Capture diretamente no host para ver a atividade real da rede.",
|
||||
"temp": "Temp"
|
||||
}
|
||||
},
|
||||
@@ -1016,6 +1019,26 @@
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "ID do chat",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
},
|
||||
"pushover": {
|
||||
"title": "Configuração do Pushover",
|
||||
"description": "Insira o token de aplicação e a chave de usuário do Pushover.",
|
||||
"optionAppToken": "Token de aplicação",
|
||||
"placeholderAppToken": "Insira o token de aplicação",
|
||||
"optionUserKey": "Chave de usuário",
|
||||
"placeholderUserKey": "Insira a chave de usuário"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "Configuração do Twilio",
|
||||
"description": "Insira as credenciais da conta Twilio para enviar SMS.",
|
||||
"optionAccountSid": "SID da conta",
|
||||
"placeholderAccountSid": "Insira o SID da conta",
|
||||
"optionAuthToken": "Token de autenticação",
|
||||
"placeholderAuthToken": "Insira o token de autenticação",
|
||||
"optionFromNumber": "Número de origem",
|
||||
"placeholderFromNumber": "+1234567890",
|
||||
"optionToNumber": "Número de destino",
|
||||
"placeholderToNumber": "+1234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1214,11 +1237,12 @@
|
||||
"description": "Adicione monitores de exemplo para fins de demonstração."
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "Redefinição do sistema",
|
||||
"description": "Remova todos os monitores do seu sistema.",
|
||||
"title": "Redefinir sistema",
|
||||
"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": {
|
||||
@@ -1271,7 +1295,18 @@
|
||||
"allDown": "Todos os sistemas estão fora do ar",
|
||||
"allUp": "Todos os sistemas operacionais",
|
||||
"degraded": "Alguns sistemas estão com problemas",
|
||||
"unknown": "Não foi possível determinar o status do sistema"
|
||||
"unknown": "Não foi possível determinar o status do sistema",
|
||||
"allBreached": "Todos os sistemas excederam os limites",
|
||||
"allMaintenance": "Todos os sistemas estão em manutenção",
|
||||
"allPaused": "Todos os sistemas estão pausados",
|
||||
"allInitializing": "Todos os sistemas estão inicializando",
|
||||
"breachedAndDown": "Alguns sistemas excederam os limites e estão fora do ar",
|
||||
"breached": "Alguns sistemas excederam os limites",
|
||||
"maintenanceAndDown": "Alguns sistemas estão fora do ar durante a manutenção",
|
||||
"maintenance": "Alguns sistemas estão em manutenção",
|
||||
"partiallyPaused": "Alguns sistemas estão pausados",
|
||||
"initializing": "Alguns sistemas estão inicializando",
|
||||
"noMonitors": "Nenhum monitor foi configurado"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
|
||||
@@ -364,7 +364,8 @@
|
||||
"register": {
|
||||
"title": "С возвращением в Checkmate!",
|
||||
"subtitle": "Зарегистрируйтесь, чтобы начать",
|
||||
"submit": "Регистрация"
|
||||
"submit": "Регистрация",
|
||||
"setupNotice": "Эта страница доступна только во время первоначальной настройки. После регистрации суперадминистратора дополнительных пользователей можно пригласить из настроек команды."
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Забыли пароль?",
|
||||
@@ -426,7 +427,8 @@
|
||||
"activeFor": "Активен",
|
||||
"certificateExpiry": "Истечение сертификата",
|
||||
"lastCheck": "Последняя проверка",
|
||||
"lastResponseTime": "Последнее время ответа"
|
||||
"lastResponseTime": "Последнее время ответа",
|
||||
"serviceIsDown": "Сервис недоступен"
|
||||
},
|
||||
"status": {
|
||||
"down": "недоступен",
|
||||
@@ -540,7 +542,7 @@
|
||||
"ping": "Ведите IP-адрес или имя хоста для пинга (например, 192.168.1.100 или example.com) и добавьте понятное имя, которое будет отображаться на панели инструментов.",
|
||||
"port": "Введите URL-адрес или IP-адрес сервера, номер порта и понятное имя, которое будет отображаться на панели инструментов.",
|
||||
"docker": "Введите Docker ID вашего контейнера. Docker ID должен быть полным 64-символьным Docker ID. Вы можете запустить docker inspect <short_id>, чтобы получить полный ID контейнера.",
|
||||
"game": "Введите IP-адрес или имя хоста и номер порта для пинга (например, 192.168.1.100 или example.com) и выберите тип игры.",
|
||||
"game": "Введите IP-адрес или имя хоста и номер порта для пинга (например, 192.168.1.100 или example.com) и выберите тип игры. Некоторым играм требуется включить серверные запросы в файле конфигурации — проверьте документацию вашего игрового сервера. См. <gamedigLink>список поддерживаемых игр</gamedigLink> для деталей протокола.",
|
||||
"pagespeed": "Отслеживайте производительность загрузки, Core Web Vitals и показатели оптимизации для Вашего сайта.",
|
||||
"grpc": "Введите имя хоста и порт gRPC-сервера, при необходимости укажите имя сервиса для проверки состояния и добавьте отображаемое имя.",
|
||||
"websocket": "Введите WebSocket URL для мониторинга (напр., wss://example.com/socket) и добавьте отображаемое имя.",
|
||||
@@ -767,6 +769,7 @@
|
||||
"memory": "Использование памяти",
|
||||
"netBytesRecv": "{{name}} - байт получено",
|
||||
"netBytesSent": "{{name}} - байт отправлено",
|
||||
"netNoTraffic": "Capture сообщает о нулевом трафике на всех интерфейсах. Это нормально, когда Capture работает внутри контейнера, который видит только loopback-интерфейсы — запустите Capture непосредственно на хосте, чтобы увидеть реальную сетевую активность.",
|
||||
"temp": "Температура"
|
||||
}
|
||||
},
|
||||
@@ -1016,6 +1019,26 @@
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "ID чата",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
},
|
||||
"pushover": {
|
||||
"title": "Настройка Pushover",
|
||||
"description": "Введите токен приложения и ключ пользователя Pushover.",
|
||||
"optionAppToken": "Токен приложения",
|
||||
"placeholderAppToken": "Введите токен приложения",
|
||||
"optionUserKey": "Ключ пользователя",
|
||||
"placeholderUserKey": "Введите ключ пользователя"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "Настройка Twilio",
|
||||
"description": "Введите учётные данные аккаунта Twilio для отправки SMS.",
|
||||
"optionAccountSid": "SID аккаунта",
|
||||
"placeholderAccountSid": "Введите SID аккаунта",
|
||||
"optionAuthToken": "Токен аутентификации",
|
||||
"placeholderAuthToken": "Введите токен аутентификации",
|
||||
"optionFromNumber": "Номер отправителя",
|
||||
"placeholderFromNumber": "+1234567890",
|
||||
"optionToNumber": "Номер получателя",
|
||||
"placeholderToNumber": "+1234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1215,10 +1238,11 @@
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "Сброс системы",
|
||||
"description": "Удалить все мониторы из Вашей системы.",
|
||||
"description": "Удалить все мониторы из вашей системы.",
|
||||
"dialog": {
|
||||
"title": "Удалить все мониторы?",
|
||||
"description": "Это действие нельзя отменить."
|
||||
"description": "Это действие нельзя отменить.",
|
||||
"paragraph": "Также будут удалены вся история проверок, инциденты, запланированные окна обслуживания и страницы состояния, использовавшие эти мониторы. Сначала экспортируйте мониторы, если хотите сохранить резервную копию их данных."
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
@@ -1271,7 +1295,18 @@
|
||||
"allDown": "Все системы недоступны",
|
||||
"allUp": "Все системы работают",
|
||||
"degraded": "Некоторые системы испытывают проблемы",
|
||||
"unknown": "Не удаётся определить статус системы"
|
||||
"unknown": "Не удаётся определить статус системы",
|
||||
"allBreached": "Все системы превысили пороговые значения",
|
||||
"allMaintenance": "Все системы на техническом обслуживании",
|
||||
"allPaused": "Все системы приостановлены",
|
||||
"allInitializing": "Все системы инициализируются",
|
||||
"breachedAndDown": "Некоторые системы превысили пороги и недоступны",
|
||||
"breached": "Некоторые системы превысили пороги",
|
||||
"maintenanceAndDown": "Некоторые системы недоступны во время техобслуживания",
|
||||
"maintenance": "Некоторые системы на техобслуживании",
|
||||
"partiallyPaused": "Некоторые системы приостановлены",
|
||||
"initializing": "Некоторые системы инициализируются",
|
||||
"noMonitors": "Мониторы не настроены"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
|
||||
@@ -364,7 +364,8 @@
|
||||
"register": {
|
||||
"title": "ยินดีต้อนรับกลับสู่ Checkmate!",
|
||||
"subtitle": "สมัครสมาชิกเพื่อเริ่มต้น",
|
||||
"submit": "ลงทะเบียน"
|
||||
"submit": "ลงทะเบียน",
|
||||
"setupNotice": "หน้านี้พร้อมใช้งานเฉพาะระหว่างการตั้งค่าเริ่มต้นเท่านั้น เมื่อลงทะเบียนผู้ดูแลระบบสูงสุดแล้ว สามารถเชิญผู้ใช้เพิ่มเติมได้จากการตั้งค่าทีม"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "ลืมรหัสผ่านใช่ไหม?",
|
||||
@@ -426,7 +427,8 @@
|
||||
"activeFor": "ทำงานมาแล้ว",
|
||||
"certificateExpiry": "วันหมดอายุใบรับรอง",
|
||||
"lastCheck": "ตรวจสอบล่าสุด",
|
||||
"lastResponseTime": "เวลาตอบสนองล่าสุด"
|
||||
"lastResponseTime": "เวลาตอบสนองล่าสุด",
|
||||
"serviceIsDown": "บริการขัดข้อง"
|
||||
},
|
||||
"status": {
|
||||
"down": "ล่ม",
|
||||
@@ -540,7 +542,7 @@
|
||||
"ping": "กรอกที่อยู่ IP หรือชื่อโฮสต์ที่ต้องการ ping (เช่น 192.168.1.100 หรือ example.com) และตั้งชื่อที่ชัดเจนให้แสดงบนแดชบอร์ด",
|
||||
"port": "กรอก URL หรือ IP ของเซิร์ฟเวอร์ หมายเลขพอร์ต และตั้งชื่อที่ชัดเจนให้แสดงบนแดชบอร์ด",
|
||||
"docker": "กรอกชื่อหรือ ID ของคอนเทนเนอร์ Docker คุณสามารถใช้ชื่อคอนเทนเนอร์ (เช่น my-app) หรือ ID ของคอนเทนเนอร์ (ID เต็ม 64 ตัวอักษร หรือ ID สั้น)",
|
||||
"game": "กรอกที่อยู่ IP หรือชื่อโฮสต์และหมายเลขพอร์ตเพื่อทำการ ping (เช่น 192.168.1.100 หรือ example.com) และเลือกประเภทเกม",
|
||||
"game": "กรอกที่อยู่ IP หรือชื่อโฮสต์และหมายเลขพอร์ตเพื่อทำการ ping (เช่น 192.168.1.100 หรือ example.com) และเลือกประเภทเกม เกมบางเกมต้องเปิดใช้งานการสอบถามเซิร์ฟเวอร์ในไฟล์การตั้งค่า — โปรดดูเอกสารของเซิร์ฟเวอร์เกมของคุณ ดู<gamedigLink>รายการเกมที่รองรับ</gamedigLink>สำหรับรายละเอียดของโปรโตคอล",
|
||||
"pagespeed": "ติดตามประสิทธิภาพการโหลดหน้า Core Web Vitals และคะแนนการเพิ่มประสิทธิภาพของเว็บไซต์",
|
||||
"grpc": "กรอกชื่อโฮสต์และพอร์ตของเซิร์ฟเวอร์ gRPC ระบุชื่อบริการสำหรับ health check (ไม่บังคับ) และตั้งชื่อที่แสดง",
|
||||
"websocket": "กรอก URL ของ WebSocket ที่ต้องการตรวจสอบ (เช่น wss://example.com/socket) และตั้งชื่อที่แสดง",
|
||||
@@ -767,6 +769,7 @@
|
||||
"memory": "การใช้งานหน่วยความจำ",
|
||||
"netBytesRecv": "{{name}} - ไบต์ที่รับ",
|
||||
"netBytesSent": "{{name}} - ไบต์ที่ส่ง",
|
||||
"netNoTraffic": "Capture รายงานว่าไม่มีการรับส่งข้อมูลบนอินเทอร์เฟซใดๆ เลย ซึ่งเป็นเรื่องปกติเมื่อ Capture ทำงานอยู่ในคอนเทนเนอร์ที่มองเห็นเพียงอินเทอร์เฟซ loopback เท่านั้น — รัน Capture บนโฮสต์โดยตรงเพื่อดูกิจกรรมเครือข่ายจริง",
|
||||
"temp": "อุณหภูมิ"
|
||||
}
|
||||
},
|
||||
@@ -1016,6 +1019,26 @@
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "Chat ID",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
},
|
||||
"pushover": {
|
||||
"title": "การตั้งค่า Pushover",
|
||||
"description": "ป้อนโทเค็นแอปและคีย์ผู้ใช้ Pushover ของคุณ",
|
||||
"optionAppToken": "โทเค็นแอป",
|
||||
"placeholderAppToken": "ป้อนโทเค็นแอป",
|
||||
"optionUserKey": "คีย์ผู้ใช้",
|
||||
"placeholderUserKey": "ป้อนคีย์ผู้ใช้"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "การตั้งค่า Twilio",
|
||||
"description": "ป้อนข้อมูลรับรองบัญชี Twilio เพื่อส่ง SMS",
|
||||
"optionAccountSid": "SID บัญชี",
|
||||
"placeholderAccountSid": "ป้อน SID บัญชี",
|
||||
"optionAuthToken": "โทเค็นการรับรอง",
|
||||
"placeholderAuthToken": "ป้อนโทเค็นการรับรอง",
|
||||
"optionFromNumber": "หมายเลขผู้ส่ง",
|
||||
"placeholderFromNumber": "+1234567890",
|
||||
"optionToNumber": "หมายเลขผู้รับ",
|
||||
"placeholderToNumber": "+1234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1215,10 +1238,11 @@
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "รีเซ็ตระบบ",
|
||||
"description": "ลบมอนิเตอร์ทั้งหมดออกจากระบบ",
|
||||
"description": "ลบมอนิเตอร์ทั้งหมดจากระบบของคุณ",
|
||||
"dialog": {
|
||||
"title": "ลบมอนิเตอร์ทั้งหมด?",
|
||||
"description": "ไม่สามารถย้อนกลับได้"
|
||||
"description": "การกระทำนี้ไม่สามารถย้อนกลับได้",
|
||||
"paragraph": "ประวัติการตรวจสอบทั้งหมด เหตุการณ์ ช่วงเวลาบำรุงรักษาที่กำหนดไว้ และหน้าสถานะที่ใช้มอนิเตอร์เหล่านี้จะถูกลบไปด้วย ส่งออกมอนิเตอร์ก่อนหากต้องการเก็บสำเนาข้อมูลสำรองไว้"
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
@@ -1271,7 +1295,18 @@
|
||||
"allDown": "ระบบทั้งหมดล่ม",
|
||||
"allUp": "ระบบทั้งหมดทำงานปกติ",
|
||||
"degraded": "บางระบบกำลังประสบปัญหา",
|
||||
"unknown": "ไม่สามารถระบุสถานะระบบได้"
|
||||
"unknown": "ไม่สามารถระบุสถานะระบบได้",
|
||||
"allBreached": "ทุกระบบเกินขีดจำกัด",
|
||||
"allMaintenance": "ทุกระบบอยู่ระหว่างการบำรุงรักษา",
|
||||
"allPaused": "ทุกระบบถูกหยุดชั่วคราว",
|
||||
"allInitializing": "ทุกระบบกำลังเริ่มต้น",
|
||||
"breachedAndDown": "บางระบบเกินขีดจำกัดและขัดข้อง",
|
||||
"breached": "บางระบบเกินขีดจำกัด",
|
||||
"maintenanceAndDown": "บางระบบขัดข้องระหว่างการบำรุงรักษา",
|
||||
"maintenance": "บางระบบอยู่ระหว่างการบำรุงรักษา",
|
||||
"partiallyPaused": "บางระบบถูกหยุดชั่วคราว",
|
||||
"initializing": "บางระบบกำลังเริ่มต้น",
|
||||
"noMonitors": "ยังไม่มีการกำหนดค่ามอนิเตอร์"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
|
||||
+47
-12
@@ -35,7 +35,7 @@
|
||||
"sendInvite": "Davet gönder",
|
||||
"test": "Test",
|
||||
"testNotifications": "Bildirimleri test et",
|
||||
"toggleTheme": "",
|
||||
"toggleTheme": "Açık / koyu temayı değiştir",
|
||||
"flushQueue": "Kuyruğu temizle",
|
||||
"notFound": "Ana panele git",
|
||||
"resetPassword": "Parolayı sıfırla",
|
||||
@@ -86,7 +86,7 @@
|
||||
"name": {
|
||||
"option": {
|
||||
"firstName": {
|
||||
"label": "",
|
||||
"label": "Ad",
|
||||
"placeholder": "Jordan"
|
||||
},
|
||||
"lastName": {
|
||||
@@ -155,13 +155,13 @@
|
||||
"serverUnreachable": "Sunucuya ulaşılamıyor",
|
||||
"retry": "Tekrar dene",
|
||||
"retrying": "Yeniden deneniyor...",
|
||||
"reconnected": ""
|
||||
"reconnected": "Bağlantı yeniden kuruldu"
|
||||
},
|
||||
"sidebar": {
|
||||
"menu": {
|
||||
"uptime": "",
|
||||
"uptime": "Çalışma süresi",
|
||||
"pagespeed": "Sayfa hızı",
|
||||
"infrastructure": "",
|
||||
"infrastructure": "Altyapı",
|
||||
"notifications": "Bildirimler",
|
||||
"checks": "Kontroller",
|
||||
"incidents": "Olaylar",
|
||||
@@ -364,7 +364,8 @@
|
||||
"register": {
|
||||
"title": "Checkmate'e tekrar hoş geldiniz!",
|
||||
"subtitle": "Başlamak için kaydolun",
|
||||
"submit": "Kaydol"
|
||||
"submit": "Kaydol",
|
||||
"setupNotice": "Bu sayfa yalnızca ilk kurulum sırasında kullanılabilir. Süper yönetici kaydedildikten sonra, ek kullanıcılar takım ayarlarından davet edilebilir."
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Parolanızı mı unuttunuz?",
|
||||
@@ -426,7 +427,8 @@
|
||||
"activeFor": "Aktif süresi",
|
||||
"certificateExpiry": "Sertifika bitiş tarihi",
|
||||
"lastCheck": "Son kontrol",
|
||||
"lastResponseTime": "Son yanıt süresi"
|
||||
"lastResponseTime": "Son yanıt süresi",
|
||||
"serviceIsDown": "Servis çalışmıyor"
|
||||
},
|
||||
"status": {
|
||||
"down": "çevrimdışı",
|
||||
@@ -540,7 +542,7 @@
|
||||
"ping": "",
|
||||
"port": "",
|
||||
"docker": "",
|
||||
"game": "",
|
||||
"game": "Ping atılacak IP adresini veya ana bilgisayar adını ve port numarasını girin (ör. 192.168.1.100 veya example.com) ve oyun türünü seçin. Bazı oyunlar yapılandırma dosyasında sunucu sorgularının etkinleştirilmesini gerektirir — oyun sunucunuzun belgelerine bakın. Protokol ayrıntıları için <gamedigLink>desteklenen oyunlar listesine</gamedigLink> bakın.",
|
||||
"pagespeed": "Web sitenizin sayfa yükleme performansını, Core Web Vitals metriklerini ve optimizasyon puanlarını takip edin.",
|
||||
"grpc": "gRPC sunucusunun ana bilgisayar adını ve portunu girin, isteğe bağlı olarak sağlık kontrolü için bir servis adı belirtin ve bir görünen ad ekleyin.",
|
||||
"websocket": "İzlenecek WebSocket URL'sini girin (örn. wss://example.com/socket) ve bir görünen ad ekleyin.",
|
||||
@@ -767,6 +769,7 @@
|
||||
"memory": "Bellek kullanımı",
|
||||
"netBytesRecv": "{{name}} - Alınan bayt",
|
||||
"netBytesSent": "{{name}} - Gönderilen bayt",
|
||||
"netNoTraffic": "Capture tüm arabirimlerde sıfır trafik raporluyor. Bu, Capture yalnızca loopback arabirimlerini görebilen bir konteyner içinde çalışırken normaldir — gerçek ağ etkinliğini görmek için Capture'ı doğrudan host üzerinde çalıştırın.",
|
||||
"temp": "Sıcaklık"
|
||||
}
|
||||
},
|
||||
@@ -1016,6 +1019,26 @@
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "Sohbet ID",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
},
|
||||
"pushover": {
|
||||
"title": "Pushover yapılandırması",
|
||||
"description": "Pushover uygulama belirtecinizi ve kullanıcı anahtarınızı girin.",
|
||||
"optionAppToken": "Uygulama belirteci",
|
||||
"placeholderAppToken": "Uygulama belirtecini girin",
|
||||
"optionUserKey": "Kullanıcı anahtarı",
|
||||
"placeholderUserKey": "Kullanıcı anahtarını girin"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "Twilio yapılandırması",
|
||||
"description": "SMS göndermek için Twilio hesap kimlik bilgilerinizi girin.",
|
||||
"optionAccountSid": "Hesap SID",
|
||||
"placeholderAccountSid": "Hesap SID girin",
|
||||
"optionAuthToken": "Kimlik doğrulama belirteci",
|
||||
"placeholderAuthToken": "Kimlik doğrulama belirtecini girin",
|
||||
"optionFromNumber": "Gönderen numara",
|
||||
"placeholderFromNumber": "+1234567890",
|
||||
"optionToNumber": "Alıcı numara",
|
||||
"placeholderToNumber": "+1234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1214,11 +1237,12 @@
|
||||
"description": "Gösterim amaçlı örnek monitörler ekleyin."
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "Sistem sıfırlama",
|
||||
"description": "Sisteminizdeki tüm monitörleri kaldırın.",
|
||||
"title": "Sistemi sıfırla",
|
||||
"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": {
|
||||
@@ -1271,7 +1295,18 @@
|
||||
"allDown": "Tüm sistemler çevrimdışı",
|
||||
"allUp": "Tüm sistemler çalışıyor",
|
||||
"degraded": "Bazı sistemlerde sorun yaşanıyor",
|
||||
"unknown": "Sistem durumu belirlenemiyor"
|
||||
"unknown": "Sistem durumu belirlenemiyor",
|
||||
"allBreached": "Tüm sistemler eşik değerlerini aştı",
|
||||
"allMaintenance": "Tüm sistemler bakımda",
|
||||
"allPaused": "Tüm sistemler duraklatıldı",
|
||||
"allInitializing": "Tüm sistemler başlatılıyor",
|
||||
"breachedAndDown": "Bazı sistemler eşik değerlerini aştı ve çalışmıyor",
|
||||
"breached": "Bazı sistemler eşik değerlerini aştı",
|
||||
"maintenanceAndDown": "Bazı sistemler bakım sırasında çalışmıyor",
|
||||
"maintenance": "Bazı sistemler bakımda",
|
||||
"partiallyPaused": "Bazı sistemler duraklatıldı",
|
||||
"initializing": "Bazı sistemler başlatılıyor",
|
||||
"noMonitors": "Hiç monitör yapılandırılmadı"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
|
||||
+83
-48
@@ -13,7 +13,7 @@
|
||||
"content": "Увага: Ви ще не додали ключ API Google PageSpeed. Перейдіть у <settingsLink>Налаштування</settingsLink>, щоб додати його. Без нього моніторинг PageSpeed не працюватиме."
|
||||
}
|
||||
},
|
||||
"appName": "",
|
||||
"appName": "Checkmate",
|
||||
"breadcrumbs": {
|
||||
"details": "Деталі",
|
||||
"home": "Головна"
|
||||
@@ -31,15 +31,15 @@
|
||||
"inviteMember": "Запросити учасника",
|
||||
"pause": "Призупинити",
|
||||
"resume": "Відновити",
|
||||
"save": "",
|
||||
"save": "Зберегти",
|
||||
"sendInvite": "Надіслати запрошення",
|
||||
"test": "Тест",
|
||||
"testNotifications": "Тестові сповіщення",
|
||||
"toggleTheme": "",
|
||||
"toggleTheme": "Перемкнути світлу / темну",
|
||||
"flushQueue": "Очистити чергу",
|
||||
"notFound": "Перейти на головну панель",
|
||||
"resetPassword": "",
|
||||
"reset": "",
|
||||
"resetPassword": "Скинути пароль",
|
||||
"reset": "Скинути",
|
||||
"clear": "Очистити",
|
||||
"addDemo": "Додати демо-монітори",
|
||||
"removeMonitors": "Видалити монітори",
|
||||
@@ -53,8 +53,8 @@
|
||||
"labels": {
|
||||
"averageResponseTime": "Середній час відповіді",
|
||||
"downtime": "Час простою",
|
||||
"high": "",
|
||||
"low": "",
|
||||
"high": "Високий",
|
||||
"low": "Низький",
|
||||
"uptime": "Час роботи"
|
||||
},
|
||||
"histogram": {
|
||||
@@ -86,12 +86,12 @@
|
||||
"name": {
|
||||
"option": {
|
||||
"firstName": {
|
||||
"label": "",
|
||||
"placeholder": ""
|
||||
"label": "Ім'я",
|
||||
"placeholder": "Введіть ім'я"
|
||||
},
|
||||
"lastName": {
|
||||
"label": "",
|
||||
"placeholder": ""
|
||||
"label": "Прізвище",
|
||||
"placeholder": "Введіть прізвище"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -107,14 +107,14 @@
|
||||
"table": {
|
||||
"empty": "Тут нічого немає",
|
||||
"headers": {
|
||||
"actions": "",
|
||||
"actions": "Дії",
|
||||
"dateTime": "Дата і час",
|
||||
"message": "Повідомлення",
|
||||
"monitor": "Монітор",
|
||||
"monitorId": "ID монітора",
|
||||
"name": "Ім'я",
|
||||
"status": "Статус",
|
||||
"type": "",
|
||||
"type": "Тип",
|
||||
"url": "URL",
|
||||
"interval": "Інтервал",
|
||||
"active": "Активний",
|
||||
@@ -125,17 +125,17 @@
|
||||
"components": {
|
||||
"imageUpload": {
|
||||
"clickToUpload": "Натисніть, щоб завантажити",
|
||||
"dragAndDrop": "",
|
||||
"supportedFormats": "",
|
||||
"maxSize": "",
|
||||
"dragAndDrop": "або перетягніть",
|
||||
"supportedFormats": "Підтримувані формати",
|
||||
"maxSize": "Макс. розмір",
|
||||
"orDragAndDrop": "або перетягніть файл",
|
||||
"errors": {
|
||||
"invalidFileSize": "",
|
||||
"invalidFileFormat": ""
|
||||
"invalidFileSize": "Розмір файлу занадто великий!",
|
||||
"invalidFileFormat": "Непідтримуваний формат файлу!"
|
||||
}
|
||||
},
|
||||
"headerStatusPageControls": {
|
||||
"publicLink": ""
|
||||
"publicLink": "Публічне посилання"
|
||||
},
|
||||
"headerTimeRange": {
|
||||
"labels": {
|
||||
@@ -155,26 +155,26 @@
|
||||
"serverUnreachable": "Не вдається з'єднатися з сервером",
|
||||
"retry": "Повторити",
|
||||
"retrying": "Повторна спроба...",
|
||||
"reconnected": ""
|
||||
"reconnected": "З'єднання відновлено"
|
||||
},
|
||||
"sidebar": {
|
||||
"menu": {
|
||||
"uptime": "",
|
||||
"pagespeed": "",
|
||||
"infrastructure": "",
|
||||
"notifications": "",
|
||||
"uptime": "Доступність",
|
||||
"pagespeed": "Pagespeed",
|
||||
"infrastructure": "Інфраструктура",
|
||||
"notifications": "Сповіщення",
|
||||
"checks": "Перевірки",
|
||||
"incidents": "",
|
||||
"statusPages": "",
|
||||
"maintenance": "",
|
||||
"logs": "",
|
||||
"settings": ""
|
||||
"incidents": "Інциденти",
|
||||
"statusPages": "Сторінки статусу",
|
||||
"maintenance": "Обслуговування",
|
||||
"logs": "Журнали",
|
||||
"settings": "Налаштування"
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "",
|
||||
"discussions": "",
|
||||
"docs": "",
|
||||
"changelog": ""
|
||||
"support": "Підтримка",
|
||||
"discussions": "Обговорення",
|
||||
"docs": "Документація",
|
||||
"changelog": "Журнал змін"
|
||||
},
|
||||
"accountMenu": {
|
||||
"profile": "Профіль",
|
||||
@@ -186,13 +186,13 @@
|
||||
"description": "Переглядайте останні оновлення та допомагайте розвивати спільноту на GitHub"
|
||||
},
|
||||
"authFooter": {
|
||||
"navControls": "",
|
||||
"logOut": "",
|
||||
"navControls": "Керування",
|
||||
"logOut": "Вийти",
|
||||
"roles": {
|
||||
"superAdmin": "Суперадміністратор",
|
||||
"admin": "Адміністратор",
|
||||
"user": "Користувач",
|
||||
"demoUser": ""
|
||||
"demoUser": "Демо-користувач"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -259,15 +259,15 @@
|
||||
"team": {
|
||||
"filter": {
|
||||
"placeholder": "Фільтрувати за роллю",
|
||||
"all": "",
|
||||
"all": "Усі ролі",
|
||||
"admin": "Адміністратор",
|
||||
"member": ""
|
||||
"member": "Учасник"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"email": "Електронна пошта",
|
||||
"role": "",
|
||||
"created": ""
|
||||
"role": "Роль",
|
||||
"created": "Створено"
|
||||
}
|
||||
},
|
||||
"addMember": {
|
||||
@@ -364,7 +364,8 @@
|
||||
"register": {
|
||||
"title": "Ласкаво просимо до Checkmate!",
|
||||
"subtitle": "Зареєструйтесь, щоб розпочати",
|
||||
"submit": "Зареєструватися"
|
||||
"submit": "Зареєструватися",
|
||||
"setupNotice": "Ця сторінка доступна лише під час початкового налаштування. Після реєстрації суперадміністратора додаткових користувачів можна запросити з налаштувань команди."
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Забули пароль?",
|
||||
@@ -426,7 +427,8 @@
|
||||
"activeFor": "Активний протягом",
|
||||
"certificateExpiry": "Закінчення сертифіката",
|
||||
"lastCheck": "Остання перевірка",
|
||||
"lastResponseTime": "Останній час відповіді"
|
||||
"lastResponseTime": "Останній час відповіді",
|
||||
"serviceIsDown": "Сервіс недоступний"
|
||||
},
|
||||
"status": {
|
||||
"down": "недоступний",
|
||||
@@ -540,7 +542,7 @@
|
||||
"ping": "",
|
||||
"port": "",
|
||||
"docker": "",
|
||||
"game": "",
|
||||
"game": "Введіть IP-адресу або ім'я хоста та номер порту для пінгу (наприклад, 192.168.1.100 або example.com) і виберіть тип гри. Деяким іграм потрібно ввімкнути серверні запити у файлі конфігурації — перевірте документацію вашого ігрового сервера. Деталі протоколу див. у <gamedigLink>списку підтримуваних ігор</gamedigLink>.",
|
||||
"pagespeed": "Відстежуйте швидкість завантаження сторінки, показники Core Web Vitals та оцінки оптимізації Вашого сайту.",
|
||||
"grpc": "Введіть ім'я хоста та порт gRPC-сервера, за бажанням вкажіть назву сервісу для перевірки стану та додайте відображуване ім'я.",
|
||||
"websocket": "Введіть WebSocket URL для моніторингу (напр., wss://example.com/socket) та додайте відображуване ім'я.",
|
||||
@@ -767,6 +769,7 @@
|
||||
"memory": "Використання пам'яті",
|
||||
"netBytesRecv": "{{name}} - Отримано байтів",
|
||||
"netBytesSent": "{{name}} - Надіслано байтів",
|
||||
"netNoTraffic": "Capture повідомляє про нульовий трафік на всіх інтерфейсах. Це нормально, коли Capture працює всередині контейнера, який бачить лише loopback-інтерфейси — запустіть Capture безпосередньо на хості, щоб побачити реальну мережеву активність.",
|
||||
"temp": "Темп."
|
||||
}
|
||||
},
|
||||
@@ -826,8 +829,8 @@
|
||||
},
|
||||
"tabs": {
|
||||
"diagnostics": "Діагностика",
|
||||
"logs": "",
|
||||
"queue": ""
|
||||
"logs": "Журнали",
|
||||
"queue": "Черга завдань"
|
||||
},
|
||||
"logLevelSelect": {
|
||||
"label": "Рівень логів"
|
||||
@@ -1016,6 +1019,26 @@
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "ID чату",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
},
|
||||
"pushover": {
|
||||
"title": "Налаштування Pushover",
|
||||
"description": "Введіть токен застосунку та ключ користувача Pushover.",
|
||||
"optionAppToken": "Токен застосунку",
|
||||
"placeholderAppToken": "Введіть токен застосунку",
|
||||
"optionUserKey": "Ключ користувача",
|
||||
"placeholderUserKey": "Введіть ключ користувача"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "Налаштування Twilio",
|
||||
"description": "Введіть облікові дані Twilio для надсилання SMS.",
|
||||
"optionAccountSid": "SID акаунта",
|
||||
"placeholderAccountSid": "Введіть SID акаунта",
|
||||
"optionAuthToken": "Токен автентифікації",
|
||||
"placeholderAuthToken": "Введіть токен автентифікації",
|
||||
"optionFromNumber": "Номер відправника",
|
||||
"placeholderFromNumber": "+1234567890",
|
||||
"optionToNumber": "Номер отримувача",
|
||||
"placeholderToNumber": "+1234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1215,10 +1238,11 @@
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "Скидання системи",
|
||||
"description": "Видалити всі монітори з Вашої системи.",
|
||||
"description": "Видалити всі монітори з вашої системи.",
|
||||
"dialog": {
|
||||
"title": "Видалити всі монітори?",
|
||||
"description": "Це не можна скасувати."
|
||||
"description": "Цю дію не можна скасувати.",
|
||||
"paragraph": "Також будуть видалені вся історія перевірок, інциденти, заплановані вікна обслуговування та сторінки статусу, які використовували ці монітори. Спочатку експортуйте монітори, якщо хочете зберегти резервну копію їхніх даних."
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
@@ -1271,7 +1295,18 @@
|
||||
"allDown": "Усі системи недоступні",
|
||||
"allUp": "Усі системи працюють",
|
||||
"degraded": "Деякі системи мають проблеми",
|
||||
"unknown": "Неможливо визначити стан системи"
|
||||
"unknown": "Неможливо визначити стан системи",
|
||||
"allBreached": "Усі системи перевищили порогові значення",
|
||||
"allMaintenance": "Усі системи на технічному обслуговуванні",
|
||||
"allPaused": "Усі системи призупинено",
|
||||
"allInitializing": "Усі системи ініціалізуються",
|
||||
"breachedAndDown": "Деякі системи перевищили пороги та недоступні",
|
||||
"breached": "Деякі системи перевищили пороги",
|
||||
"maintenanceAndDown": "Деякі системи недоступні під час техобслуговування",
|
||||
"maintenance": "Деякі системи на техобслуговуванні",
|
||||
"partiallyPaused": "Деякі системи призупинено",
|
||||
"initializing": "Деякі системи ініціалізуються",
|
||||
"noMonitors": "Не налаштовано жодного монітора"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
|
||||
+83
-48
@@ -13,7 +13,7 @@
|
||||
"content": "Cảnh báo: Bạn chưa thêm khóa API Google PageSpeed. Truy cập <settingsLink>Cài đặt</settingsLink> để thêm. Nếu không có khóa này, trình giám sát PageSpeed sẽ không hoạt động."
|
||||
}
|
||||
},
|
||||
"appName": "",
|
||||
"appName": "Checkmate",
|
||||
"breadcrumbs": {
|
||||
"details": "Chi tiết",
|
||||
"home": "Trang chủ"
|
||||
@@ -31,15 +31,15 @@
|
||||
"inviteMember": "Mời thành viên",
|
||||
"pause": "Tạm dừng",
|
||||
"resume": "Tiếp tục",
|
||||
"save": "",
|
||||
"save": "Lưu",
|
||||
"sendInvite": "Gửi lời mời",
|
||||
"test": "Kiểm tra",
|
||||
"testNotifications": "Kiểm tra thông báo",
|
||||
"toggleTheme": "",
|
||||
"toggleTheme": "Chuyển sáng / tối",
|
||||
"flushQueue": "Xóa hàng đợi",
|
||||
"notFound": "Về bảng điều khiển chính",
|
||||
"resetPassword": "",
|
||||
"reset": "",
|
||||
"resetPassword": "Đặt lại mật khẩu",
|
||||
"reset": "Đặt lại",
|
||||
"clear": "Xóa",
|
||||
"addDemo": "Thêm trình giám sát mẫu",
|
||||
"removeMonitors": "Xóa trình giám sát",
|
||||
@@ -53,8 +53,8 @@
|
||||
"labels": {
|
||||
"averageResponseTime": "Thời gian phản hồi trung bình",
|
||||
"downtime": "Thời gian ngừng hoạt động",
|
||||
"high": "",
|
||||
"low": "",
|
||||
"high": "Cao",
|
||||
"low": "Thấp",
|
||||
"uptime": "Thời gian hoạt động"
|
||||
},
|
||||
"histogram": {
|
||||
@@ -86,12 +86,12 @@
|
||||
"name": {
|
||||
"option": {
|
||||
"firstName": {
|
||||
"label": "",
|
||||
"placeholder": ""
|
||||
"label": "Tên",
|
||||
"placeholder": "Nhập tên của bạn"
|
||||
},
|
||||
"lastName": {
|
||||
"label": "",
|
||||
"placeholder": ""
|
||||
"label": "Họ",
|
||||
"placeholder": "Nhập họ của bạn"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -107,14 +107,14 @@
|
||||
"table": {
|
||||
"empty": "Không có dữ liệu",
|
||||
"headers": {
|
||||
"actions": "",
|
||||
"actions": "Hành động",
|
||||
"dateTime": "Ngày & giờ",
|
||||
"message": "Tin nhắn",
|
||||
"monitor": "Trình giám sát",
|
||||
"monitorId": "ID trình giám sát",
|
||||
"name": "Tên",
|
||||
"status": "Trạng thái",
|
||||
"type": "",
|
||||
"type": "Loại",
|
||||
"url": "Url",
|
||||
"interval": "Khoảng thời gian",
|
||||
"active": "Đang hoạt động",
|
||||
@@ -125,17 +125,17 @@
|
||||
"components": {
|
||||
"imageUpload": {
|
||||
"clickToUpload": "Nhấn để tải lên",
|
||||
"dragAndDrop": "",
|
||||
"supportedFormats": "",
|
||||
"maxSize": "",
|
||||
"dragAndDrop": "hoặc kéo và thả",
|
||||
"supportedFormats": "Định dạng hỗ trợ",
|
||||
"maxSize": "Kích thước tối đa",
|
||||
"orDragAndDrop": "hoặc kéo và thả",
|
||||
"errors": {
|
||||
"invalidFileSize": "",
|
||||
"invalidFileFormat": ""
|
||||
"invalidFileSize": "Tệp quá lớn!",
|
||||
"invalidFileFormat": "Định dạng tệp không được hỗ trợ!"
|
||||
}
|
||||
},
|
||||
"headerStatusPageControls": {
|
||||
"publicLink": ""
|
||||
"publicLink": "Liên kết công khai"
|
||||
},
|
||||
"headerTimeRange": {
|
||||
"labels": {
|
||||
@@ -155,26 +155,26 @@
|
||||
"serverUnreachable": "Không thể kết nối đến máy chủ",
|
||||
"retry": "Thử lại",
|
||||
"retrying": "Đang thử lại...",
|
||||
"reconnected": ""
|
||||
"reconnected": "Đã khôi phục kết nối"
|
||||
},
|
||||
"sidebar": {
|
||||
"menu": {
|
||||
"uptime": "",
|
||||
"pagespeed": "",
|
||||
"infrastructure": "",
|
||||
"notifications": "",
|
||||
"uptime": "Thời gian hoạt động",
|
||||
"pagespeed": "Pagespeed",
|
||||
"infrastructure": "Hạ tầng",
|
||||
"notifications": "Thông báo",
|
||||
"checks": "Kiểm tra",
|
||||
"incidents": "",
|
||||
"statusPages": "",
|
||||
"maintenance": "",
|
||||
"logs": "",
|
||||
"settings": ""
|
||||
"incidents": "Sự cố",
|
||||
"statusPages": "Trang trạng thái",
|
||||
"maintenance": "Bảo trì",
|
||||
"logs": "Nhật ký",
|
||||
"settings": "Cài đặt"
|
||||
},
|
||||
"bottomMenu": {
|
||||
"support": "",
|
||||
"discussions": "",
|
||||
"docs": "",
|
||||
"changelog": ""
|
||||
"support": "Hỗ trợ",
|
||||
"discussions": "Thảo luận",
|
||||
"docs": "Tài liệu",
|
||||
"changelog": "Nhật ký thay đổi"
|
||||
},
|
||||
"accountMenu": {
|
||||
"profile": "Hồ sơ",
|
||||
@@ -186,13 +186,13 @@
|
||||
"description": "Xem các bản phát hành mới nhất và giúp phát triển cộng đồng trên GitHub"
|
||||
},
|
||||
"authFooter": {
|
||||
"navControls": "",
|
||||
"logOut": "",
|
||||
"navControls": "Điều khiển",
|
||||
"logOut": "Đăng xuất",
|
||||
"roles": {
|
||||
"superAdmin": "Quản trị viên cấp cao",
|
||||
"admin": "Quản trị viên",
|
||||
"user": "Người dùng",
|
||||
"demoUser": ""
|
||||
"demoUser": "Người dùng demo"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -259,15 +259,15 @@
|
||||
"team": {
|
||||
"filter": {
|
||||
"placeholder": "Lọc theo vai trò",
|
||||
"all": "",
|
||||
"all": "Tất cả vai trò",
|
||||
"admin": "Quản trị viên",
|
||||
"member": ""
|
||||
"member": "Thành viên"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"email": "Email",
|
||||
"role": "",
|
||||
"created": ""
|
||||
"role": "Vai trò",
|
||||
"created": "Đã tạo"
|
||||
}
|
||||
},
|
||||
"addMember": {
|
||||
@@ -364,7 +364,8 @@
|
||||
"register": {
|
||||
"title": "Chào mừng bạn trở lại Checkmate!",
|
||||
"subtitle": "Đăng ký để bắt đầu",
|
||||
"submit": "Đăng ký"
|
||||
"submit": "Đăng ký",
|
||||
"setupNotice": "Trang này chỉ khả dụng trong quá trình thiết lập ban đầu. Sau khi siêu quản trị viên được đăng ký, có thể mời thêm người dùng từ cài đặt nhóm."
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Quên mật khẩu?",
|
||||
@@ -426,7 +427,8 @@
|
||||
"activeFor": "Hoạt động từ",
|
||||
"certificateExpiry": "Hạn chứng chỉ",
|
||||
"lastCheck": "Lần kiểm tra cuối",
|
||||
"lastResponseTime": "Thời gian phản hồi cuối"
|
||||
"lastResponseTime": "Thời gian phản hồi cuối",
|
||||
"serviceIsDown": "Dịch vụ đang ngừng hoạt động"
|
||||
},
|
||||
"status": {
|
||||
"down": "ngừng hoạt động",
|
||||
@@ -540,7 +542,7 @@
|
||||
"ping": "",
|
||||
"port": "",
|
||||
"docker": "",
|
||||
"game": "",
|
||||
"game": "Nhập địa chỉ IP hoặc tên máy chủ và số cổng để ping (ví dụ: 192.168.1.100 hoặc example.com) và chọn loại trò chơi. Một số trò chơi yêu cầu bật truy vấn máy chủ trong tệp cấu hình của chúng — hãy xem tài liệu của máy chủ trò chơi. Xem <gamedigLink>danh sách các trò chơi được hỗ trợ</gamedigLink> để biết chi tiết giao thức.",
|
||||
"pagespeed": "Theo dõi hiệu suất tải trang, Core Web Vitals và điểm tối ưu hóa cho trang web của bạn.",
|
||||
"grpc": "Nhập tên máy chủ và cổng của máy chủ gRPC, tùy chọn chỉ định tên dịch vụ cho kiểm tra sức khỏe và thêm tên hiển thị.",
|
||||
"websocket": "Nhập URL WebSocket cần giám sát (vd: wss://example.com/socket) và thêm tên hiển thị.",
|
||||
@@ -767,6 +769,7 @@
|
||||
"memory": "Sử dụng bộ nhớ",
|
||||
"netBytesRecv": "{{name}} - Byte nhận",
|
||||
"netBytesSent": "{{name}} - Byte gửi",
|
||||
"netNoTraffic": "Capture đang báo cáo lưu lượng bằng không trên mọi giao diện. Điều này là bình thường khi Capture chạy bên trong một container chỉ có thể thấy các giao diện loopback — hãy chạy Capture trực tiếp trên máy chủ để xem hoạt động mạng thực tế.",
|
||||
"temp": "Nhiệt độ"
|
||||
}
|
||||
},
|
||||
@@ -826,8 +829,8 @@
|
||||
},
|
||||
"tabs": {
|
||||
"diagnostics": "Chẩn đoán",
|
||||
"logs": "",
|
||||
"queue": ""
|
||||
"logs": "Nhật ký",
|
||||
"queue": "Hàng đợi tác vụ"
|
||||
},
|
||||
"logLevelSelect": {
|
||||
"label": "Mức nhật ký"
|
||||
@@ -1016,6 +1019,26 @@
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "ID cuộc trò chuyện",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
},
|
||||
"pushover": {
|
||||
"title": "Cấu hình Pushover",
|
||||
"description": "Nhập mã thông báo ứng dụng và khóa người dùng Pushover của bạn.",
|
||||
"optionAppToken": "Mã thông báo ứng dụng",
|
||||
"placeholderAppToken": "Nhập mã thông báo ứng dụng",
|
||||
"optionUserKey": "Khóa người dùng",
|
||||
"placeholderUserKey": "Nhập khóa người dùng"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "Cấu hình Twilio",
|
||||
"description": "Nhập thông tin xác thực tài khoản Twilio để gửi SMS.",
|
||||
"optionAccountSid": "SID tài khoản",
|
||||
"placeholderAccountSid": "Nhập SID tài khoản",
|
||||
"optionAuthToken": "Mã thông báo xác thực",
|
||||
"placeholderAuthToken": "Nhập mã thông báo xác thực",
|
||||
"optionFromNumber": "Số gửi",
|
||||
"placeholderFromNumber": "+1234567890",
|
||||
"optionToNumber": "Số nhận",
|
||||
"placeholderToNumber": "+1234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1215,10 +1238,11 @@
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "Đặt lại hệ thống",
|
||||
"description": "Xóa tất cả trình giám sát khỏi hệ thống.",
|
||||
"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": {
|
||||
@@ -1271,7 +1295,18 @@
|
||||
"allDown": "Tất cả hệ thống đều ngừng hoạt động",
|
||||
"allUp": "Tất cả hệ thống đang hoạt động",
|
||||
"degraded": "Một số hệ thống đang gặp sự cố",
|
||||
"unknown": "Không thể xác định trạng thái hệ thống"
|
||||
"unknown": "Không thể xác định trạng thái hệ thống",
|
||||
"allBreached": "Tất cả hệ thống đã vượt ngưỡng",
|
||||
"allMaintenance": "Tất cả hệ thống đang bảo trì",
|
||||
"allPaused": "Tất cả hệ thống đã tạm dừng",
|
||||
"allInitializing": "Tất cả hệ thống đang khởi tạo",
|
||||
"breachedAndDown": "Một số hệ thống đã vượt ngưỡng và đang ngừng hoạt động",
|
||||
"breached": "Một số hệ thống đã vượt ngưỡng",
|
||||
"maintenanceAndDown": "Một số hệ thống ngừng hoạt động trong quá trình bảo trì",
|
||||
"maintenance": "Một số hệ thống đang bảo trì",
|
||||
"partiallyPaused": "Một số hệ thống đã tạm dừng",
|
||||
"initializing": "Một số hệ thống đang khởi tạo",
|
||||
"noMonitors": "Chưa cấu hình màn hình giám sát nào"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
|
||||
@@ -364,7 +364,8 @@
|
||||
"register": {
|
||||
"title": "欢迎回到 Checkmate!",
|
||||
"subtitle": "注册以开始使用",
|
||||
"submit": "注册"
|
||||
"submit": "注册",
|
||||
"setupNotice": "此页面仅在初始设置期间可用。注册超级管理员后,可以从团队设置中邀请更多用户。"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "忘记密码了?",
|
||||
@@ -426,7 +427,8 @@
|
||||
"activeFor": "已活跃",
|
||||
"certificateExpiry": "证书到期",
|
||||
"lastCheck": "上次检查",
|
||||
"lastResponseTime": "上次响应时间"
|
||||
"lastResponseTime": "上次响应时间",
|
||||
"serviceIsDown": "服务已停止"
|
||||
},
|
||||
"status": {
|
||||
"down": "宕机",
|
||||
@@ -540,7 +542,7 @@
|
||||
"ping": "输入要 ping 的 IP 地址或主机名(例如.. 192.168.1.100 或 example.com),并添加显示在仪表板上的清晰显示名称。",
|
||||
"port": "输入服务器的 URL 或 IP、端口号和仪表板上显示的清晰显示名称。",
|
||||
"docker": "码输入 Docker 容器名称或 ID。您可以使用容器名称(例如.. my-app)或容器 ID(完整的 64 字符 ID 或短 ID)。",
|
||||
"game": "输入要 ping 的 IP 地址或主机名和端口号(例如.. 192.168.1.100 或 example.com)并选择游戏类型。",
|
||||
"game": "输入要 ping 的 IP 地址或主机名和端口号(例如.. 192.168.1.100 或 example.com)并选择游戏类型。某些游戏需要在配置文件中启用服务器查询 — 请查阅你的游戏服务器文档。详见<gamedigLink>支持的游戏列表</gamedigLink>了解协议细节。",
|
||||
"pagespeed": "跟踪网站的页面加载性能、Core Web Vitals 和优化评分。",
|
||||
"grpc": "输入 gRPC 服务器的主机名和端口,可选指定健康检查的服务名称,并添加显示名称。",
|
||||
"websocket": "输入要监控的 WebSocket URL(例如 wss://example.com/socket),并添加显示名称。",
|
||||
@@ -767,6 +769,7 @@
|
||||
"memory": "内存使用率",
|
||||
"netBytesRecv": "{{name}} - 接收字节",
|
||||
"netBytesSent": "{{name}} - 发送字节",
|
||||
"netNoTraffic": "Capture 报告所有接口的流量均为零。当 Capture 在只能看到回环接口的容器内运行时,这属于正常情况——请直接在主机上运行 Capture 才能看到真实的网络活动。",
|
||||
"temp": "温度"
|
||||
}
|
||||
},
|
||||
@@ -1016,6 +1019,26 @@
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "聊天 ID",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
},
|
||||
"pushover": {
|
||||
"title": "Pushover 配置",
|
||||
"description": "输入您的 Pushover 应用令牌和用户密钥。",
|
||||
"optionAppToken": "应用令牌",
|
||||
"placeholderAppToken": "输入应用令牌",
|
||||
"optionUserKey": "用户密钥",
|
||||
"placeholderUserKey": "输入用户密钥"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "Twilio 配置",
|
||||
"description": "输入 Twilio 帐户凭据以发送短信。",
|
||||
"optionAccountSid": "帐户 SID",
|
||||
"placeholderAccountSid": "输入帐户 SID",
|
||||
"optionAuthToken": "认证令牌",
|
||||
"placeholderAuthToken": "输入认证令牌",
|
||||
"optionFromNumber": "发送号码",
|
||||
"placeholderFromNumber": "+1234567890",
|
||||
"optionToNumber": "接收号码",
|
||||
"placeholderToNumber": "+1234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1215,10 +1238,11 @@
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "系统重置",
|
||||
"description": "从系统中移除所有监控。",
|
||||
"description": "从系统中移除所有监视器。",
|
||||
"dialog": {
|
||||
"title": "移除所有监控?",
|
||||
"description": "此操作无法撤销。"
|
||||
"description": "此操作无法撤销。",
|
||||
"paragraph": "所有检查历史、事件、计划维护窗口以及使用这些监控的状态页面也将一并被移除。如需保留数据备份,请先导出监控。"
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
@@ -1271,7 +1295,18 @@
|
||||
"allDown": "所有系统已宕机",
|
||||
"allUp": "所有系统运行正常",
|
||||
"degraded": "部分系统正在出现问题",
|
||||
"unknown": "无法确定系统状态"
|
||||
"unknown": "无法确定系统状态",
|
||||
"allBreached": "所有系统已超过阈值",
|
||||
"allMaintenance": "所有系统正在维护中",
|
||||
"allPaused": "所有系统已暂停",
|
||||
"allInitializing": "所有系统正在初始化",
|
||||
"breachedAndDown": "部分系统超过阈值且已停止",
|
||||
"breached": "部分系统超过阈值",
|
||||
"maintenanceAndDown": "部分系统在维护期间停止",
|
||||
"maintenance": "部分系统正在维护",
|
||||
"partiallyPaused": "部分系统已暂停",
|
||||
"initializing": "部分系统正在初始化",
|
||||
"noMonitors": "尚未配置任何监控器"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
|
||||
@@ -364,7 +364,8 @@
|
||||
"register": {
|
||||
"title": "歡迎回到 Checkmate!",
|
||||
"subtitle": "註冊以開始使用",
|
||||
"submit": "註冊"
|
||||
"submit": "註冊",
|
||||
"setupNotice": "此頁面僅在初始設定期間可用。註冊超級管理員後,可以從團隊設定中邀請更多使用者。"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "忘記密碼了嗎?",
|
||||
@@ -426,7 +427,8 @@
|
||||
"activeFor": "已啟用",
|
||||
"certificateExpiry": "憑證到期",
|
||||
"lastCheck": "最後檢查",
|
||||
"lastResponseTime": "最後回應時間"
|
||||
"lastResponseTime": "最後回應時間",
|
||||
"serviceIsDown": "服務已停止"
|
||||
},
|
||||
"status": {
|
||||
"down": "離線",
|
||||
@@ -540,7 +542,7 @@
|
||||
"ping": "輸入要 Ping 的 IP 位址或主機名稱(例如 192.168.1.100 或 example.com),並新增一個顯示在儀表板上的清晰顯示名稱。",
|
||||
"port": "輸入伺服器的 URL 或 IP、連接埠號碼,以及顯示在儀表板上的清晰顯示名稱。",
|
||||
"docker": "輸入 Docker 容器名稱或 ID。您可以使用容器名稱(例如 my-app)或容器 ID(完整的 64 字元 ID 或短 ID)。",
|
||||
"game": "輸入要 Ping 的 IP 位址或主機名稱及連接埠號碼(例如 192.168.1.100 或 example.com),並選擇遊戲類型。",
|
||||
"game": "輸入要 Ping 的 IP 位址或主機名稱及連接埠號碼(例如 192.168.1.100 或 example.com),並選擇遊戲類型。部分遊戲需要在設定檔中啟用伺服器查詢 — 請參閱你的遊戲伺服器文件。詳見<gamedigLink>支援的遊戲清單</gamedigLink>了解協定細節。",
|
||||
"pagespeed": "追蹤您網站的頁面載入效能、Core Web Vitals 和最佳化分數。",
|
||||
"grpc": "輸入 gRPC 伺服器的主機名稱和連接埠,可選擇指定健康檢查的服務名稱,並新增一個顯示名稱。",
|
||||
"websocket": "輸入要監控的 WebSocket URL(例如 wss://example.com/socket),並新增一個顯示名稱。",
|
||||
@@ -767,6 +769,7 @@
|
||||
"memory": "記憶體使用率",
|
||||
"netBytesRecv": "{{name}} - 接收位元組",
|
||||
"netBytesSent": "{{name}} - 發送位元組",
|
||||
"netNoTraffic": "Capture 回報所有介面的流量皆為零。當 Capture 在只能看到回環介面的容器中執行時,這是正常情況——請直接在主機上執行 Capture 才能看到實際的網路活動。",
|
||||
"temp": "溫度"
|
||||
}
|
||||
},
|
||||
@@ -1016,6 +1019,26 @@
|
||||
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
||||
"optionChatId": "聊天室 ID",
|
||||
"placeholderChatId": "-1001234567890"
|
||||
},
|
||||
"pushover": {
|
||||
"title": "Pushover 設定",
|
||||
"description": "輸入您的 Pushover 應用程式權杖和使用者金鑰。",
|
||||
"optionAppToken": "應用程式權杖",
|
||||
"placeholderAppToken": "輸入應用程式權杖",
|
||||
"optionUserKey": "使用者金鑰",
|
||||
"placeholderUserKey": "輸入使用者金鑰"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "Twilio 設定",
|
||||
"description": "輸入 Twilio 帳戶憑證以傳送簡訊。",
|
||||
"optionAccountSid": "帳戶 SID",
|
||||
"placeholderAccountSid": "輸入帳戶 SID",
|
||||
"optionAuthToken": "驗證權杖",
|
||||
"placeholderAuthToken": "輸入驗證權杖",
|
||||
"optionFromNumber": "發送號碼",
|
||||
"placeholderFromNumber": "+1234567890",
|
||||
"optionToNumber": "接收號碼",
|
||||
"placeholderToNumber": "+1234567890"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1214,11 +1237,12 @@
|
||||
"description": "新增範例監控以供示範用途。"
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "系統重設",
|
||||
"description": "從系統中移除所有監控。",
|
||||
"title": "系統重置",
|
||||
"description": "從系統中移除所有監視器。",
|
||||
"dialog": {
|
||||
"title": "移除所有監控?",
|
||||
"description": "此操作無法復原。"
|
||||
"description": "此操作無法復原。",
|
||||
"paragraph": "所有檢查歷史、事件、排定的維護時段以及使用這些監控的狀態頁面也將一併被移除。如需保留資料備份,請先匯出監控。"
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
@@ -1271,7 +1295,18 @@
|
||||
"allDown": "所有系統已停機",
|
||||
"allUp": "所有系統運作正常",
|
||||
"degraded": "部分系統正在發生問題",
|
||||
"unknown": "無法判斷系統狀態"
|
||||
"unknown": "無法判斷系統狀態",
|
||||
"allBreached": "所有系統已超過閾值",
|
||||
"allMaintenance": "所有系統正在維護中",
|
||||
"allPaused": "所有系統已暫停",
|
||||
"allInitializing": "所有系統正在初始化",
|
||||
"breachedAndDown": "部分系統超過閾值且已停止",
|
||||
"breached": "部分系統超過閾值",
|
||||
"maintenanceAndDown": "部分系統在維護期間停止",
|
||||
"maintenance": "部分系統正在維護",
|
||||
"partiallyPaused": "部分系統已暫停",
|
||||
"initializing": "部分系統正在初始化",
|
||||
"noMonitors": "尚未設定任何監視器"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
|
||||
@@ -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
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
p: theme.spacing(LAYOUT.MD),
|
||||
borderRadius: 1,
|
||||
border: `1px solid ${alpha(tone, 0.45)}`,
|
||||
backgroundColor: alpha(tone, 0.08),
|
||||
textAlign: "left",
|
||||
}}
|
||||
/>
|
||||
|
||||
// GOOD — every key that has a native prop is hoisted
|
||||
<Box
|
||||
width={"100%"}
|
||||
p={theme.spacing(LAYOUT.MD)}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
border={`1px solid ${alpha(tone, 0.45)}`}
|
||||
bgcolor={alpha(tone, 0.08)}
|
||||
textAlign={"left"}
|
||||
/>
|
||||
```
|
||||
|
||||
```tsx
|
||||
// BAD
|
||||
<Typography sx={{ color: theme.palette.text.primary, lineHeight: 1.55, fontSize: 13 }}>
|
||||
|
||||
// GOOD — hoist what you can; drop fontSize:13 because it's the body default
|
||||
<Typography
|
||||
color={theme.palette.text.primary}
|
||||
lineHeight={1.55}
|
||||
>
|
||||
```
|
||||
|
||||
### When `sx` is still correct
|
||||
|
||||
Keep `sx` for things that don't have a native prop:
|
||||
|
||||
```tsx
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/settings"
|
||||
color={theme.palette.primary.main}
|
||||
fontWeight={600}
|
||||
sx={{ textDecoration: "underline" }} // OK — no native prop for textDecoration
|
||||
/>
|
||||
```
|
||||
|
||||
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
|
||||
<Typography color="text.secondary">…</Typography>
|
||||
<Typography color="primary.main">…</Typography>
|
||||
|
||||
// GOOD
|
||||
const theme = useTheme();
|
||||
<Typography color={theme.palette.text.secondary}>…</Typography>
|
||||
<Typography color={theme.palette.primary.main}>…</Typography>
|
||||
```
|
||||
|
||||
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
|
||||
<Box sx={{ mt: "2px" }} />
|
||||
<Stack spacing={theme.spacing(2)} />
|
||||
<Box sx={{ borderRadius: 1 }} />
|
||||
<Typography fontSize={18} />
|
||||
<Typography fontSize={13} /> // 13 is the body default — drop the prop entirely
|
||||
|
||||
// GOOD
|
||||
<Box mt={LAYOUT.XXS} />
|
||||
<Stack spacing={theme.spacing(LAYOUT.XXS)} />
|
||||
<Box borderRadius={theme.shape.borderRadius} />
|
||||
<Typography fontSize={typographyLevels.xl} />
|
||||
<Typography /> // 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 }) => (
|
||||
<Typography color={theme.palette.text.secondary}>{children}</Typography>
|
||||
);
|
||||
|
||||
// GOOD
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
const SectionHeading = ({ children }) => {
|
||||
const theme = useTheme();
|
||||
return <Typography color={theme.palette.text.secondary}>{children}</Typography>;
|
||||
};
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -24,3 +24,9 @@ CLIENT_HOST=http://localhost:5173
|
||||
# Optional Configuration
|
||||
# ----------------------
|
||||
ORIGIN=localhost
|
||||
|
||||
# Feature Flags
|
||||
# -------------
|
||||
# Set to "false" to disable the selectable theme feature on status pages.
|
||||
# When disabled, all status pages render with the default "refined" theme.
|
||||
STATUS_PAGE_THEMES_ENABLED=true
|
||||
@@ -2878,6 +2878,12 @@
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Custom URL for the status page"
|
||||
},
|
||||
"theme": {
|
||||
"$ref": "#/components/schemas/StatusPageTheme"
|
||||
},
|
||||
"themeMode": {
|
||||
"$ref": "#/components/schemas/StatusPageThemeMode"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2930,6 +2936,12 @@
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Custom URL for the status page"
|
||||
},
|
||||
"theme": {
|
||||
"$ref": "#/components/schemas/StatusPageTheme"
|
||||
},
|
||||
"themeMode": {
|
||||
"$ref": "#/components/schemas/StatusPageThemeMode"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3410,6 +3422,18 @@
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"StatusPageTheme": {
|
||||
"type": "string",
|
||||
"enum": ["refined", "modern", "bold", "editorial"],
|
||||
"default": "refined",
|
||||
"description": "Visual theme for the public status page. Defaults to 'refined'."
|
||||
},
|
||||
"StatusPageThemeMode": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "light", "dark"],
|
||||
"default": "auto",
|
||||
"description": "Forces a light/dark mode on the public page. 'auto' follows the visitor's OS preference. Defaults to 'auto'."
|
||||
},
|
||||
"SuccessResponse": {
|
||||
"type": "object",
|
||||
"required": ["success", "msg"],
|
||||
|
||||
@@ -348,7 +348,7 @@ export const initializeServices = async ({
|
||||
incidentsRepository,
|
||||
});
|
||||
|
||||
const statusPageService = new StatusPageService(statusPagesRepository);
|
||||
const statusPageService = new StatusPageService(statusPagesRepository, settingsService);
|
||||
|
||||
const services = {
|
||||
settingsService,
|
||||
|
||||
@@ -89,6 +89,7 @@ class SettingsController implements ISettingsController {
|
||||
systemEmailHost,
|
||||
systemEmailPort,
|
||||
systemEmailAddress,
|
||||
systemEmailDisplayName,
|
||||
systemEmailPassword,
|
||||
systemEmailUser,
|
||||
systemEmailConnectionHost,
|
||||
@@ -112,6 +113,7 @@ class SettingsController implements ISettingsController {
|
||||
systemEmailPort,
|
||||
systemEmailUser,
|
||||
systemEmailAddress,
|
||||
systemEmailDisplayName,
|
||||
systemEmailPassword,
|
||||
systemEmailConnectionHost,
|
||||
systemEmailSecure,
|
||||
|
||||
@@ -66,6 +66,7 @@ class StatusPageController implements IStatusPageController {
|
||||
if (req.file) {
|
||||
imageValidation.parse(req.file);
|
||||
}
|
||||
|
||||
const teamId = requireTeamId(req?.user?.teamId);
|
||||
const statusPageId = req.params.id as string;
|
||||
if (!statusPageId) {
|
||||
@@ -127,7 +128,10 @@ class StatusPageController implements IStatusPageController {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Status page retrieved successfully",
|
||||
data: { statusPage, monitors: normalizedMonitors },
|
||||
data: {
|
||||
statusPage,
|
||||
monitors: normalizedMonitors,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, type StatusPage } from "@/types/statusPage.js";
|
||||
|
||||
/**
|
||||
* When STATUS_PAGE_THEMES_ENABLED is false, the controller drops incoming
|
||||
* theme fields before persisting so an admin client can't accidentally store
|
||||
* a value the public page won't honour. Whatever a caller sends here for
|
||||
* `theme` / `themeMode` is silently ignored — the rest of the body is
|
||||
* preserved verbatim.
|
||||
*
|
||||
* The strip is silent (not a 400) because the kill switch is an ops-side
|
||||
* concern: API clients should not have to know whether the operator has
|
||||
* disabled themes today, only that their saved value won't take effect.
|
||||
* If we later decide we'd rather fail loudly, change this to throw a
|
||||
* 400 AppError; downstream consumers already handle it.
|
||||
*/
|
||||
export const withoutThemeFields = (body: Record<string, unknown>): Record<string, unknown> => {
|
||||
const rest = { ...body };
|
||||
delete rest.theme;
|
||||
delete rest.themeMode;
|
||||
return rest;
|
||||
};
|
||||
|
||||
/**
|
||||
* When STATUS_PAGE_THEMES_ENABLED is false, callers reading a status page
|
||||
* should see the canonical default theme / mode regardless of what's stored
|
||||
* in Mongo. Whatever the user previously chose stays in the DB so flipping
|
||||
* the flag back on resurfaces their selection.
|
||||
*/
|
||||
export const applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({
|
||||
...statusPage,
|
||||
theme: DEFAULT_STATUS_PAGE_THEME,
|
||||
themeMode: DEFAULT_STATUS_PAGE_THEME_MODE,
|
||||
});
|
||||
@@ -26,6 +26,7 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
|
||||
systemEmailHost: { type: String },
|
||||
systemEmailPort: { type: Number },
|
||||
systemEmailAddress: { type: String },
|
||||
systemEmailDisplayName: { type: String },
|
||||
systemEmailPassword: { type: String },
|
||||
systemEmailUser: { type: String },
|
||||
systemEmailConnectionHost: { type: String, default: "localhost" },
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Schema, model, type Types } from "mongoose";
|
||||
import type { StatusPage, StatusPageLogoDocument } from "@/types/statusPage.js";
|
||||
import { StatusPageTypes } from "@/types/statusPage.js";
|
||||
import {
|
||||
DEFAULT_STATUS_PAGE_THEME,
|
||||
DEFAULT_STATUS_PAGE_THEME_MODE,
|
||||
StatusPageThemeModes,
|
||||
StatusPageThemes,
|
||||
StatusPageTypes,
|
||||
} from "@/types/statusPage.js";
|
||||
|
||||
type StatusPageDocumentBase = Omit<
|
||||
StatusPage,
|
||||
@@ -108,6 +114,16 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
enum: StatusPageThemes,
|
||||
default: DEFAULT_STATUS_PAGE_THEME,
|
||||
},
|
||||
themeMode: {
|
||||
type: String,
|
||||
enum: StatusPageThemeModes,
|
||||
default: DEFAULT_STATUS_PAGE_THEME_MODE,
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
@@ -59,6 +59,8 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
|
||||
showAdminLoginLink: doc.showAdminLoginLink,
|
||||
showInfrastructure: doc.showInfrastructure,
|
||||
customCSS: doc.customCSS,
|
||||
theme: doc.theme,
|
||||
themeMode: doc.themeMode,
|
||||
createdAt: this.toDateString(doc.createdAt),
|
||||
updatedAt: this.toDateString(doc.updatedAt),
|
||||
};
|
||||
|
||||
@@ -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<StatusPage>): Promise<StatusPage>;
|
||||
getStatusPageByUrl(url: string): Promise<StatusPage>;
|
||||
@@ -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<StatusPage>): Partial<StatusPage> => {
|
||||
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<StatusPage>): Partial<StatusPage> =>
|
||||
this.settingsService.areStatusPageThemesEnabled() ? data : this.withoutThemeFields(data);
|
||||
|
||||
createStatusPage = async (
|
||||
userId: string,
|
||||
teamId: string,
|
||||
image: Express.Multer.File | undefined,
|
||||
data: Partial<StatusPage>
|
||||
): Promise<StatusPage> => {
|
||||
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<StatusPage> => {
|
||||
return await this.statusPagesRepository.findByUrl(url);
|
||||
const statusPage = await this.statusPagesRepository.findByUrl(url);
|
||||
return this.normalizeTheme(statusPage);
|
||||
};
|
||||
|
||||
getStatusPagesByTeamId = async (teamId: string): Promise<StatusPage[]> => {
|
||||
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<StatusPage>): Promise<StatusPage> => {
|
||||
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<StatusPage> => {
|
||||
|
||||
@@ -118,6 +118,7 @@ export class EmailService implements IEmailService {
|
||||
systemEmailPool,
|
||||
systemEmailUser,
|
||||
systemEmailAddress,
|
||||
systemEmailDisplayName,
|
||||
systemEmailPassword,
|
||||
systemEmailConnectionHost,
|
||||
systemEmailTLSServername,
|
||||
@@ -158,10 +159,13 @@ export class EmailService implements IEmailService {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmedDisplayName = systemEmailDisplayName?.trim();
|
||||
const from = trimmedDisplayName && systemEmailAddress ? { name: trimmedDisplayName, address: systemEmailAddress } : systemEmailAddress;
|
||||
|
||||
try {
|
||||
const info = await this.transporter.sendMail({
|
||||
to: to,
|
||||
from: systemEmailAddress,
|
||||
from: from,
|
||||
subject: subject,
|
||||
html: html,
|
||||
});
|
||||
|
||||
@@ -232,10 +232,17 @@ export class StatusService implements IStatusService {
|
||||
// Build the status patch — computed against the projected window
|
||||
const patch: Partial<Monitor> = {};
|
||||
|
||||
// Not enough data points yet
|
||||
if (projectedWindow.length < monitor.statusWindowSize) {
|
||||
patch.status = status === true ? "up" : "down";
|
||||
// Resolve "initializing" up-front, incidents should be created on initialization && down
|
||||
let newStatus: MonitorStatus = monitor.status;
|
||||
let statusChanged = false;
|
||||
if (monitor.status === "initializing") {
|
||||
newStatus = status === true ? "up" : "down";
|
||||
patch.status = newStatus;
|
||||
statusChanged = newStatus === "down";
|
||||
}
|
||||
|
||||
// Not enough data points yet — record the check and return
|
||||
if (projectedWindow.length < monitor.statusWindowSize) {
|
||||
const updated = await this.monitorsRepository.updateStatusWindowAndChecks(
|
||||
monitor.id,
|
||||
monitor.teamId,
|
||||
@@ -248,24 +255,13 @@ export class StatusService implements IStatusService {
|
||||
|
||||
return {
|
||||
monitor: updated,
|
||||
statusChanged: false,
|
||||
statusChanged,
|
||||
prevStatus,
|
||||
code,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// With a full window, a single raw check must not change UNLESS we are initializing.
|
||||
// Otherwise, only the sliding-window threshold can trigger a transition.
|
||||
let newStatus: MonitorStatus;
|
||||
if (monitor.status === "initializing") {
|
||||
newStatus = status === true ? "up" : "down";
|
||||
} else {
|
||||
newStatus = monitor.status;
|
||||
}
|
||||
|
||||
let statusChanged = false;
|
||||
|
||||
// First evaluate reachability-based status changes, which apply to all monitor types
|
||||
// and take precedence over hardware breaches.
|
||||
const reachabilityResult = this.computeReachability(newStatus, projectedWindow, monitor.statusWindowThreshold);
|
||||
|
||||
@@ -13,12 +13,14 @@ export type EnvConfig = {
|
||||
clientHost: string;
|
||||
dbConnectionString: string;
|
||||
dbType: DbType;
|
||||
statusPageThemesEnabled: boolean;
|
||||
};
|
||||
|
||||
export interface ISettingsService {
|
||||
readonly serviceName: string;
|
||||
loadSettings(): EnvConfig;
|
||||
getSettings(): EnvConfig;
|
||||
areStatusPageThemesEnabled(): boolean;
|
||||
getDBSettings(): Promise<Settings>;
|
||||
updateDbSettings(newSettings: SettingsUpdate): Promise<Settings>;
|
||||
}
|
||||
@@ -37,6 +39,7 @@ export class SettingsService implements ISettingsService {
|
||||
clientHost: env.CLIENT_HOST,
|
||||
dbConnectionString: env.DB_CONNECTION_STRING,
|
||||
dbType: env.DB_TYPE,
|
||||
statusPageThemesEnabled: env.STATUS_PAGE_THEMES_ENABLED,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +59,10 @@ export class SettingsService implements ISettingsService {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
areStatusPageThemesEnabled() {
|
||||
return this.settings.statusPageThemesEnabled;
|
||||
}
|
||||
|
||||
private getRepository(): ISettingsRepository {
|
||||
if (!this.settingsRepository) {
|
||||
throw new AppError({
|
||||
|
||||
@@ -5,6 +5,7 @@ export type EmailTransportConfig = Pick<
|
||||
| "systemEmailHost"
|
||||
| "systemEmailPort"
|
||||
| "systemEmailAddress"
|
||||
| "systemEmailDisplayName"
|
||||
| "systemEmailPassword"
|
||||
| "systemEmailUser"
|
||||
| "systemEmailConnectionHost"
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface Settings {
|
||||
systemEmailHost?: string;
|
||||
systemEmailPort?: number;
|
||||
systemEmailAddress?: string;
|
||||
systemEmailDisplayName?: string;
|
||||
systemEmailPassword?: string;
|
||||
systemEmailUser?: string;
|
||||
systemEmailConnectionHost?: string;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
export const StatusPageTypes = ["uptime", "infrastructure"] as const;
|
||||
export type StatusPageType = (typeof StatusPageTypes)[number];
|
||||
|
||||
export const StatusPageThemes = ["refined", "modern", "bold", "editorial"] as const;
|
||||
export type StatusPageTheme = (typeof StatusPageThemes)[number];
|
||||
export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined";
|
||||
|
||||
export const StatusPageThemeModes = ["auto", "light", "dark"] as const;
|
||||
export type StatusPageThemeMode = (typeof StatusPageThemeModes)[number];
|
||||
export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto";
|
||||
|
||||
export interface StatusPageLogo {
|
||||
data: string;
|
||||
contentType: string;
|
||||
@@ -30,6 +38,8 @@ export interface StatusPage {
|
||||
showAdminLoginLink: boolean;
|
||||
showInfrastructure: boolean;
|
||||
customCSS: string;
|
||||
theme: StatusPageTheme;
|
||||
themeMode: StatusPageThemeMode;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { DbTypes } from "@/types/settings.js";
|
||||
import { booleanCoercion } from "./shared.js";
|
||||
|
||||
const envSchema = z.object({
|
||||
// Server Configuration
|
||||
@@ -20,6 +21,9 @@ const envSchema = z.object({
|
||||
|
||||
// Optional
|
||||
ORIGIN: z.string().optional(),
|
||||
|
||||
// Feature flags
|
||||
STATUS_PAGE_THEMES_ENABLED: booleanCoercion.default(true),
|
||||
});
|
||||
|
||||
export type ValidatedEnv = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -113,6 +113,7 @@ export const sendTestEmailBodyValidation = z.object({
|
||||
systemEmailSecure: z.boolean().optional(),
|
||||
systemEmailPool: z.boolean().optional(),
|
||||
systemEmailAddress: z.string().optional(),
|
||||
systemEmailDisplayName: z.string().optional(),
|
||||
systemEmailPassword: z.string().optional(),
|
||||
systemEmailUser: z.string().optional(),
|
||||
systemEmailConnectionHost: z.union([z.string(), z.literal("")]).optional(),
|
||||
|
||||
@@ -14,6 +14,12 @@ export const updateAppSettingsBodyValidation = z
|
||||
timezone: z.string().optional(),
|
||||
systemEmailHost: z.string().nullable().optional(),
|
||||
systemEmailAddress: z.string().nullable().optional(),
|
||||
systemEmailDisplayName: z
|
||||
.string()
|
||||
.max(100)
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.nullable()
|
||||
.optional(),
|
||||
systemEmailPassword: z.string().nullable().optional(),
|
||||
systemEmailUser: z.string().nullable().optional(),
|
||||
systemEmailConnectionHost: z.string().nullable().optional(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { booleanCoercion } from "./shared.js";
|
||||
import { StatusPageTypes } from "@/types/statusPage.js";
|
||||
import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/types/statusPage.js";
|
||||
|
||||
//****************************************
|
||||
// Status Page Validations
|
||||
@@ -33,6 +33,8 @@ export const createStatusPageBodyValidation = z
|
||||
showAdminLoginLink: booleanCoercion.optional(),
|
||||
showInfrastructure: booleanCoercion.optional(),
|
||||
removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(),
|
||||
theme: z.enum(StatusPageThemes).optional(),
|
||||
themeMode: z.enum(StatusPageThemeModes).optional(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { StatusPage } from "../../src/types/index.ts";
|
||||
|
||||
export const makeStatusPage = (overrides?: Partial<StatusPage>): StatusPage =>
|
||||
({
|
||||
id: "sp-1",
|
||||
teamId: "team-1",
|
||||
userId: "user-1",
|
||||
url: "my-status-page",
|
||||
companyName: "Test Co",
|
||||
monitors: ["mon-1"],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
}) as StatusPage;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { applyDefaultTheme, withoutThemeFields } from "../../../src/controllers/statusPageThemeFlag.ts";
|
||||
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/types/statusPage.ts";
|
||||
import { makeStatusPage } from "../../helpers/makeStatusPage.ts";
|
||||
|
||||
describe("statusPageThemeFlag", () => {
|
||||
describe("withoutThemeFields", () => {
|
||||
it("removes theme and themeMode from a body that has them", () => {
|
||||
const body = { companyName: "Co", theme: "modern", themeMode: "dark" };
|
||||
const result = withoutThemeFields(body);
|
||||
expect(result).toEqual({ companyName: "Co" });
|
||||
});
|
||||
|
||||
it("is a no-op when neither field is present", () => {
|
||||
const body = { companyName: "Co", url: "page" };
|
||||
const result = withoutThemeFields(body);
|
||||
expect(result).toEqual({ companyName: "Co", url: "page" });
|
||||
});
|
||||
|
||||
it("removes only the theme fields when one is present", () => {
|
||||
const body = { companyName: "Co", theme: "bold" };
|
||||
const result = withoutThemeFields(body);
|
||||
expect(result).toEqual({ companyName: "Co" });
|
||||
});
|
||||
|
||||
it("does not mutate the input body", () => {
|
||||
const body = { companyName: "Co", theme: "modern", themeMode: "dark" };
|
||||
withoutThemeFields(body);
|
||||
expect(body).toEqual({ companyName: "Co", theme: "modern", themeMode: "dark" });
|
||||
});
|
||||
|
||||
it("preserves all other fields verbatim", () => {
|
||||
const body = {
|
||||
companyName: "Co",
|
||||
monitors: ["m-1", "m-2"],
|
||||
isPublished: true,
|
||||
showCharts: false,
|
||||
color: "#13715B",
|
||||
theme: "editorial",
|
||||
themeMode: "light",
|
||||
};
|
||||
const result = withoutThemeFields(body);
|
||||
expect(result).toEqual({
|
||||
companyName: "Co",
|
||||
monitors: ["m-1", "m-2"],
|
||||
isPublished: true,
|
||||
showCharts: false,
|
||||
color: "#13715B",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDefaultTheme", () => {
|
||||
it("overwrites theme and themeMode with the canonical defaults", () => {
|
||||
const sp = makeStatusPage({ theme: "modern", themeMode: "dark" });
|
||||
const result = applyDefaultTheme(sp);
|
||||
expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME);
|
||||
expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE);
|
||||
});
|
||||
|
||||
it("leaves all other fields untouched", () => {
|
||||
const sp = makeStatusPage({
|
||||
companyName: "Test Co",
|
||||
url: "my-page",
|
||||
monitors: ["mon-1", "mon-2"],
|
||||
isPublished: true,
|
||||
color: "#13715B",
|
||||
});
|
||||
const result = applyDefaultTheme(sp);
|
||||
expect(result.companyName).toBe("Test Co");
|
||||
expect(result.url).toBe("my-page");
|
||||
expect(result.monitors).toEqual(["mon-1", "mon-2"]);
|
||||
expect(result.isPublished).toBe(true);
|
||||
expect(result.color).toBe("#13715B");
|
||||
});
|
||||
|
||||
it("does not mutate the input status page", () => {
|
||||
const sp = makeStatusPage({ theme: "modern", themeMode: "dark" });
|
||||
applyDefaultTheme(sp);
|
||||
expect(sp.theme).toBe("modern");
|
||||
expect(sp.themeMode).toBe("dark");
|
||||
});
|
||||
|
||||
it("works on a status page that has no theme set yet", () => {
|
||||
const sp = makeStatusPage({ theme: undefined, themeMode: undefined });
|
||||
const result = applyDefaultTheme(sp);
|
||||
expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME);
|
||||
expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -396,5 +396,80 @@ describe("EmailService", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// ── from-header formatting ───────────────────────────────────────
|
||||
|
||||
describe("from header formatting", () => {
|
||||
const sendAndGetFrom = async (overrides: Partial<EmailTransportConfig>) => {
|
||||
const transporter = createMockTransporter();
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service } = createService({ nodemailer });
|
||||
|
||||
await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig(overrides));
|
||||
|
||||
const call = (transporter.sendMail as jest.Mock).mock.calls[0]?.[0] as { from: unknown } | undefined;
|
||||
return call?.from;
|
||||
};
|
||||
|
||||
it("uses bare address when systemEmailDisplayName is undefined", async () => {
|
||||
const from = await sendAndGetFrom({
|
||||
systemEmailAddress: "noreply@example.com",
|
||||
systemEmailDisplayName: undefined,
|
||||
});
|
||||
|
||||
expect(from).toBe("noreply@example.com");
|
||||
});
|
||||
|
||||
it("uses bare address when systemEmailDisplayName is empty string", async () => {
|
||||
const from = await sendAndGetFrom({
|
||||
systemEmailAddress: "noreply@example.com",
|
||||
systemEmailDisplayName: "",
|
||||
});
|
||||
|
||||
expect(from).toBe("noreply@example.com");
|
||||
});
|
||||
|
||||
it("uses bare address when systemEmailDisplayName is whitespace-only", async () => {
|
||||
const from = await sendAndGetFrom({
|
||||
systemEmailAddress: "noreply@example.com",
|
||||
systemEmailDisplayName: " ",
|
||||
});
|
||||
|
||||
expect(from).toBe("noreply@example.com");
|
||||
});
|
||||
|
||||
it("uses {name, address} when display name and address are both set", async () => {
|
||||
const from = await sendAndGetFrom({
|
||||
systemEmailAddress: "noreply@example.com",
|
||||
systemEmailDisplayName: "Checkmate notifications",
|
||||
});
|
||||
|
||||
expect(from).toEqual({
|
||||
name: "Checkmate notifications",
|
||||
address: "noreply@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace from display name", async () => {
|
||||
const from = await sendAndGetFrom({
|
||||
systemEmailAddress: "noreply@example.com",
|
||||
systemEmailDisplayName: " Checkmate ",
|
||||
});
|
||||
|
||||
expect(from).toEqual({
|
||||
name: "Checkmate",
|
||||
address: "noreply@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to bare address when display name is set but address is empty", async () => {
|
||||
const from = await sendAndGetFrom({
|
||||
systemEmailAddress: "",
|
||||
systemEmailDisplayName: "Checkmate",
|
||||
});
|
||||
|
||||
expect(from).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user