Compare commits
149
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
940fc38056 | ||
|
|
bf369aa546 | ||
|
|
c72287cb21 | ||
|
|
ba2d23fd82 | ||
|
|
e725128b10 | ||
|
|
baf7344e2d | ||
|
|
387cb35494 | ||
|
|
6a8f97ae65 | ||
|
|
3365a8f7da | ||
|
|
2e9b87ba32 | ||
|
|
1fd56610ea | ||
|
|
22230b8a08 | ||
|
|
f648ab1b24 | ||
|
|
281fc99136 | ||
|
|
6f0cc307dc | ||
|
|
cb218a46a9 | ||
|
|
39807c2de1 | ||
|
|
59d97f0d25 | ||
|
|
657a35e948 | ||
|
|
1b0b17b589 | ||
|
|
ff34c872c8 | ||
|
|
1458cf4e4a | ||
|
|
cb285bbb66 | ||
|
|
13b13b6818 | ||
|
|
63c1a60b06 | ||
|
|
d5cd957bbd | ||
|
|
5b25666054 | ||
|
|
34feb6d3fa | ||
|
|
a57da284d7 | ||
|
|
3f9019f34c | ||
|
|
4e284c1468 | ||
|
|
6b9759e455 | ||
|
|
094def117a | ||
|
|
28baeaf218 | ||
|
|
c535ba8c1a | ||
|
|
4c04294f71 | ||
|
|
212c1fec52 | ||
|
|
05798914a8 | ||
|
|
6a4d8a3f6b | ||
|
|
e906b237a0 | ||
|
|
bacbf8404e | ||
|
|
d47cc65fed | ||
|
|
e077fe52e7 | ||
|
|
f39029a37f | ||
|
|
c8c54ce49e | ||
|
|
021eda098d | ||
|
|
8993ecadce | ||
|
|
f92e0fae3a | ||
|
|
a4b73bc4ed | ||
|
|
87b7ff7452 | ||
|
|
61de273a73 | ||
|
|
04332c47d5 | ||
|
|
f263c78a4d | ||
|
|
de67ea9ee6 | ||
|
|
c9c6c42116 | ||
|
|
79d2eb68fa | ||
|
|
bc9441bcd0 | ||
|
|
d868f6931d | ||
|
|
29dbebd228 | ||
|
|
906d306827 | ||
|
|
aaf7d7e47d | ||
|
|
c40f4e9f1c | ||
|
|
35daed605f | ||
|
|
6434afe2c9 | ||
|
|
788ca78234 | ||
|
|
3adadf0d07 | ||
|
|
55b9702fc3 | ||
|
|
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 | ||
|
|
5a72a11ac2 | ||
|
|
66418bf591 | ||
|
|
944ee74a1d | ||
|
|
ad964446de | ||
|
|
726e4de6ab |
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,26 @@ When working on anything related to check scheduling, incident lifecycle, or not
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Coding conventions (mandatory)
|
||||
Read `docs/coding-conventions.md` before touching any `.tsx` or `.ts` file. The doc covers both frontend and backend rules, all enforced in code review.
|
||||
|
||||
**Universal — rule 0:** look at peer files first and follow the established pattern. Don't sneak in novel shapes.
|
||||
|
||||
**Frontend (`client/src`):**
|
||||
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]`.
|
||||
|
||||
**Backend (`server/src`):**
|
||||
6. Layering: Controller → Service → Repository → Mongoose. No DB calls outside repositories.
|
||||
7. Provider conventions: DI for stdlib clients, `SERVICE_NAME` + `TIMEOUT_MS` constants, `timeRequest()` helper, inline `setTimeout` race for timeout, outer try/catch into `AppError`.
|
||||
8. Mongoose schema fields with closed value sets must declare `enum` reusing the same `types/*` const tuple.
|
||||
9. Provider tests at `server/test/unit/providers/network/<name>.test.ts`, `from "@jest/globals"`, `testStatusProviderContract`, inline `setup()` per test (not `beforeEach`).
|
||||
10. Centralize validation enums in `types/*` const tuples; never inline `z.enum([…])` more than once.
|
||||
11. New entity fields must land in the validator's `*ResponseSchema` so the auto-generated OpenAPI spec sees them.
|
||||
|
||||
### Internationalization
|
||||
All user-facing strings must use the translation function:
|
||||
```javascript
|
||||
@@ -174,12 +196,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.
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||

|
||||

|
||||

|
||||
[](https://www.bestpractices.dev/projects/9901)
|
||||
[](https://deepwiki.com/bluewave-labs/checkmate)
|
||||
|
||||
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
|
||||
@@ -16,7 +15,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 +26,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 +38,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 +88,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 +166,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
+386
-30
@@ -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",
|
||||
@@ -18,7 +18,7 @@
|
||||
"@mui/x-date-pickers": "7.29.4",
|
||||
"@reduxjs/toolkit": "2.7.0",
|
||||
"@types/maplibre-gl": "^1.13.2",
|
||||
"axios": "^1.7.4",
|
||||
"axios": "^1.15.2",
|
||||
"dayjs": "1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"human-interval": "2.0.1",
|
||||
@@ -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",
|
||||
@@ -57,7 +59,7 @@
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "6.4.1"
|
||||
"vite": "^6.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -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,
|
||||
@@ -2681,14 +2925,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
||||
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-macros": {
|
||||
@@ -2995,9 +3239,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cosmiconfig/node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
|
||||
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -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,
|
||||
@@ -3904,12 +4191,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -4170,6 +4461,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",
|
||||
@@ -5005,7 +5317,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@@ -5137,6 +5451,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",
|
||||
@@ -5152,9 +5476,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5563,7 +5887,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -5594,7 +5920,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -5686,14 +6014,19 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
@@ -6393,6 +6726,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 +7136,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"
|
||||
@@ -7024,9 +7380,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
@@ -7307,9 +7663,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
|
||||
+7
-4
@@ -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 ."
|
||||
@@ -26,7 +27,7 @@
|
||||
"@mui/x-date-pickers": "7.29.4",
|
||||
"@reduxjs/toolkit": "2.7.0",
|
||||
"@types/maplibre-gl": "^1.13.2",
|
||||
"axios": "^1.7.4",
|
||||
"axios": "^1.15.2",
|
||||
"dayjs": "1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"human-interval": "2.0.1",
|
||||
@@ -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",
|
||||
@@ -65,6 +68,6 @@
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "6.4.1"
|
||||
"vite": "^6.4.2"
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { getResponseColor } from "@/Utils/DataUtils";
|
||||
import { HeatmapResponseTimeTooltip } from "@/Components/common/charts/HeatmapResponseTimeTooltip";
|
||||
import type { SxProps } from "@mui/material/styles";
|
||||
import type { ResponsiveStyleValue } from "@mui/system";
|
||||
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
interface PlaceholderCheck {
|
||||
status: "placeholder";
|
||||
responseTime: 0;
|
||||
@@ -32,11 +32,11 @@ export const HeatmapResponseTime = ({
|
||||
|
||||
if (!checks || checks.length === 0) return null;
|
||||
|
||||
const latestChecks = checks.slice(-25);
|
||||
const latestChecks = checks.slice(-MAX_RECENT_CHECKS);
|
||||
|
||||
let data: Array<CheckSnapshot | PlaceholderCheck>;
|
||||
if (latestChecks.length !== 25) {
|
||||
const placeholders = Array(25 - latestChecks.length).fill({
|
||||
if (latestChecks.length !== MAX_RECENT_CHECKS) {
|
||||
const placeholders = Array(MAX_RECENT_CHECKS - latestChecks.length).fill({
|
||||
status: "placeholder" as const,
|
||||
});
|
||||
data = [...latestChecks, ...placeholders];
|
||||
@@ -44,13 +44,13 @@ export const HeatmapResponseTime = ({
|
||||
data = latestChecks;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Box width={"100%"}>
|
||||
<Box
|
||||
width={"100%"}
|
||||
display={"grid"}
|
||||
gap={gap}
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: gap,
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
@@ -82,50 +82,50 @@ export const HeatmapResponseTime = ({
|
||||
check={check}
|
||||
>
|
||||
<Box
|
||||
display={"grid"}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateRows: "auto auto",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display={"flex"}
|
||||
gap={theme.spacing(2)}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
width={"100%"}
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "10",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
bgcolor={statusBg}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
bgcolor: statusBg,
|
||||
borderRadius: theme.spacing(0.5),
|
||||
...availabilityCellSx,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
borderRadius: theme.spacing(0.5),
|
||||
boxShadow: `inset 0 0 0 2px ${statusBorder}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
width={"100%"}
|
||||
bgcolor={respBg}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
sx={{
|
||||
width: "100%",
|
||||
aspectRatio: "1 / 1",
|
||||
bgcolor: respBg,
|
||||
borderRadius: theme.spacing(0.5),
|
||||
...responseCellSx,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { CheckSnapshot } from "@/Types/Check";
|
||||
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
interface HistogramResponseTimeProps {
|
||||
checks: CheckSnapshot[];
|
||||
@@ -57,8 +58,11 @@ export const HistogramResponseTime = ({
|
||||
if (!Array.isArray(checks) || checks.length === 0) return null;
|
||||
|
||||
const data =
|
||||
checks.length !== 25
|
||||
? [...checks, ...Array(25 - checks.length).fill({ status: "placeholder" })]
|
||||
checks.length !== MAX_RECENT_CHECKS
|
||||
? [
|
||||
...checks,
|
||||
...Array(MAX_RECENT_CHECKS - checks.length).fill({ status: "placeholder" }),
|
||||
]
|
||||
: checks;
|
||||
|
||||
const chartHeight = typeof height === "number" ? `${height}px` : height;
|
||||
@@ -86,17 +90,17 @@ export const HistogramResponseTime = ({
|
||||
<Stack
|
||||
direction={statsPosition === "left" ? "row-reverse" : "row"}
|
||||
alignItems="center"
|
||||
sx={{ width: "100%" }}
|
||||
width={"100%"}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
width={"100%"}
|
||||
display={"grid"}
|
||||
gap={gridGap}
|
||||
height={chartHeight}
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: gridGap,
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
alignItems: "end",
|
||||
height: chartHeight,
|
||||
}}
|
||||
>
|
||||
{data.map((check, index) => {
|
||||
@@ -108,23 +112,23 @@ export const HistogramResponseTime = ({
|
||||
: theme.palette.error.light;
|
||||
const bar = (
|
||||
<Box
|
||||
width={"100%"}
|
||||
height={"100%"}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
bgcolor={theme.palette.action.hover}
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: theme.spacing(1),
|
||||
bgcolor: theme.palette.action.hover,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
width={"100%"}
|
||||
height={isPlaceholder ? 0 : heightPct}
|
||||
bgcolor={barColor}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: isPlaceholder ? 0 : heightPct,
|
||||
bgcolor: barColor,
|
||||
transition: "height 500ms cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
}}
|
||||
/>
|
||||
@@ -133,8 +137,8 @@ export const HistogramResponseTime = ({
|
||||
|
||||
return isPlaceholder ? (
|
||||
<Box
|
||||
height={"100%"}
|
||||
key={index}
|
||||
sx={{ height: "100%" }}
|
||||
>
|
||||
{bar}
|
||||
</Box>
|
||||
|
||||
@@ -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;
|
||||
}) => {
|
||||
|
||||
@@ -35,6 +35,9 @@ import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
export type Header<T> = {
|
||||
id: number | string;
|
||||
content: React.ReactNode;
|
||||
mobileLabel?: React.ReactNode;
|
||||
hideMobileLabel?: boolean;
|
||||
align?: "left" | "center" | "right" | "justify" | "inherit";
|
||||
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
|
||||
render: (row: T) => React.ReactNode;
|
||||
};
|
||||
@@ -109,6 +112,24 @@ export function DataTable<
|
||||
key={key}
|
||||
>
|
||||
{headers.map((header) => {
|
||||
if (header.hideMobileLabel) {
|
||||
return (
|
||||
<Grid2
|
||||
container
|
||||
key={header.id}
|
||||
>
|
||||
<Grid2
|
||||
size={12}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
{header.render(row)}
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid2
|
||||
container
|
||||
@@ -123,7 +144,7 @@ export function DataTable<
|
||||
component="div"
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{header.content}
|
||||
{header.mobileLabel ?? header.content}
|
||||
</Typography>
|
||||
</Grid2>
|
||||
<Grid2
|
||||
@@ -193,7 +214,7 @@ export function DataTable<
|
||||
{headers.map((header, idx) => {
|
||||
return (
|
||||
<TableCell
|
||||
align={idx === 0 ? "left" : "center"}
|
||||
align={header.align ?? (idx === 0 ? "left" : "center")}
|
||||
key={header.id}
|
||||
>
|
||||
{header.content}
|
||||
@@ -222,7 +243,7 @@ export function DataTable<
|
||||
{headers.map((header, index) => {
|
||||
return (
|
||||
<TableCell
|
||||
align={index === 0 ? "left" : "center"}
|
||||
align={header.align ?? (index === 0 ? "left" : "center")}
|
||||
key={header.id}
|
||||
onClick={
|
||||
header.onClick ? (e) => header.onClick!(e, row) : undefined
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Slide from "@mui/material/Slide";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { useSidebar } from "@/Hooks/useSidebar";
|
||||
|
||||
interface BulkActionsBarProps {
|
||||
selectedCount: number;
|
||||
onCancel: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BulkActionsBar = ({
|
||||
selectedCount,
|
||||
onCancel,
|
||||
children,
|
||||
}: BulkActionsBarProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isOpen = selectedCount > 0;
|
||||
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const { width, collapsedWidth, transition } = useSidebar();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "fixed",
|
||||
bottom: theme.spacing(LAYOUT.XS),
|
||||
left: isSmall ? collapsedWidth : width,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
zIndex: theme.zIndex.snackbar,
|
||||
transition: transition.replace("width", "left"),
|
||||
}}
|
||||
>
|
||||
<Slide
|
||||
direction="up"
|
||||
in={isOpen}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
>
|
||||
<Paper
|
||||
elevation={6}
|
||||
sx={{
|
||||
pointerEvents: "auto",
|
||||
px: LAYOUT.MD,
|
||||
py: LAYOUT.XS,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: LAYOUT.XS,
|
||||
boxShadow: theme.shadows[8],
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={LAYOUT.XXS}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={600}
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{t("pages.common.monitors.actions.bulkSelected", {
|
||||
count: selectedCount,
|
||||
})}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onCancel}
|
||||
aria-label={t("common.buttons.cancel")}
|
||||
>
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={LAYOUT.XXS}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Slide>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -14,3 +14,4 @@ export * from "./charts/HistogramPageSpeedDetails";
|
||||
export * from "./charts/HistogramPageSpeedDetailsTooltip";
|
||||
export * from "./charts/HistogramInfrastructure";
|
||||
export * from "./HeaderMonitorsSummary";
|
||||
export * from "./BulkActionsBar";
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { post } from "@/Utils/ApiClient";
|
||||
import { useToast } from "@/Hooks/UseToast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
interface ApiResponse {
|
||||
success: boolean;
|
||||
msg: string;
|
||||
data: Monitor[];
|
||||
}
|
||||
|
||||
interface UseBulkMonitorActionsReturn {
|
||||
selectedRows: string[];
|
||||
setSelectedRows: (rows: string[]) => void;
|
||||
handleBulkPause: () => Promise<void>;
|
||||
handleBulkResume: () => Promise<void>;
|
||||
handleCancelSelection: () => void;
|
||||
}
|
||||
|
||||
export const useBulkMonitorActions = (
|
||||
refetch: () => void,
|
||||
page?: number
|
||||
): UseBulkMonitorActionsReturn => {
|
||||
const [selectedRows, setSelectedRows] = useState<string[]>([]);
|
||||
const { toastSuccess, toastError, toastInfo } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Clear selection when page changes
|
||||
useEffect(() => {
|
||||
setSelectedRows([]);
|
||||
}, [page]);
|
||||
|
||||
const executeBulkAction = async (pause: boolean) => {
|
||||
try {
|
||||
const res = await post<ApiResponse>("/monitors/bulk/pause", {
|
||||
monitorIds: selectedRows,
|
||||
pause,
|
||||
});
|
||||
|
||||
const affectedCount = res.data?.data?.length ?? 0;
|
||||
|
||||
if (affectedCount === 0) {
|
||||
const key = pause
|
||||
? "pages.common.monitors.bulkPause.alreadyPaused"
|
||||
: "pages.common.monitors.bulkPause.alreadyRunning";
|
||||
toastInfo(t(key, { count: selectedRows.length }));
|
||||
} else {
|
||||
const key = pause
|
||||
? "pages.common.monitors.bulkPause.paused"
|
||||
: "pages.common.monitors.bulkPause.resumed";
|
||||
toastSuccess(t(key, { count: affectedCount }));
|
||||
}
|
||||
|
||||
setSelectedRows([]);
|
||||
refetch();
|
||||
} catch (err: unknown) {
|
||||
let errMsg = "An error occurred";
|
||||
|
||||
if (axios.isAxiosError(err)) {
|
||||
errMsg = err.response?.data?.msg || err.message || errMsg;
|
||||
} else if (err instanceof Error) {
|
||||
errMsg = err.message;
|
||||
}
|
||||
|
||||
logger.error("Bulk pause/resume failed", err instanceof Error ? err : undefined, {
|
||||
pause,
|
||||
});
|
||||
toastError(errMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkPause = async () => {
|
||||
await executeBulkAction(true);
|
||||
};
|
||||
|
||||
const handleBulkResume = async () => {
|
||||
await executeBulkAction(false);
|
||||
};
|
||||
|
||||
const handleCancelSelection = () => {
|
||||
setSelectedRows([]);
|
||||
};
|
||||
|
||||
return {
|
||||
selectedRows,
|
||||
setSelectedRows,
|
||||
handleBulkPause,
|
||||
handleBulkResume,
|
||||
handleCancelSelection,
|
||||
};
|
||||
};
|
||||
@@ -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,7 +1,12 @@
|
||||
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";
|
||||
import { resolveTimezone } from "@/Utils/TimeUtils";
|
||||
|
||||
interface UseStatusPageFormOptions {
|
||||
data?: StatusPage | null;
|
||||
@@ -26,7 +31,7 @@ export const useStatusPageForm = ({
|
||||
const defaults: StatusPageFormData = {
|
||||
companyName: data?.companyName || "",
|
||||
url: data?.url || generateDefaultUrl(),
|
||||
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
timezone: resolveTimezone(data?.timezone),
|
||||
type: data?.type || ["uptime"],
|
||||
color: data?.color || "#13715B",
|
||||
monitors: data?.monitors || [],
|
||||
@@ -36,6 +41,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}
|
||||
|
||||
@@ -136,8 +136,8 @@ const getGeneralSettingsConfig = (
|
||||
showIgnoreTls: false,
|
||||
},
|
||||
hardware: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
|
||||
urlLabel: t("pages.createMonitor.form.general.option.captureUrl.label"),
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showPort: false,
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Table, Pagination, StatusLabel, Gauge } from "@/Components/design-elements";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
@@ -12,6 +13,24 @@ import { useNavigate } from "react-router-dom";
|
||||
import { usePost } from "@/Hooks/UseApi";
|
||||
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import { Checkbox } from "@/Components/inputs";
|
||||
|
||||
interface InfraMonitorsTableProps {
|
||||
monitors: Monitor[];
|
||||
refetch: () => void;
|
||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
selectedRows?: string[];
|
||||
onSelectionChange?: (selected: string[]) => void;
|
||||
}
|
||||
|
||||
export const InfraMonitorsTable = ({
|
||||
monitors,
|
||||
@@ -26,29 +45,32 @@ export const InfraMonitorsTable = ({
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
monitors: Monitor[];
|
||||
refetch: Function;
|
||||
setSelectedMonitor: Function;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
selectedRows = [],
|
||||
onSelectionChange,
|
||||
}: InfraMonitorsTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
post,
|
||||
// loading: isPatching,
|
||||
// error: postError,
|
||||
} = usePost<any, Monitor>();
|
||||
const { post } = usePost<Record<string, never>, Monitor>();
|
||||
|
||||
const selectedSet = new Set(selectedRows);
|
||||
const isAllSelected =
|
||||
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
|
||||
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
|
||||
};
|
||||
|
||||
const handleSelectRow = (id: string, checked: boolean) => {
|
||||
if (!onSelectionChange) return;
|
||||
onSelectionChange(
|
||||
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
|
||||
);
|
||||
};
|
||||
|
||||
const isRowSelected = (id: string) => selectedSet.has(id);
|
||||
|
||||
const handlePageChange = (
|
||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||
@@ -65,7 +87,7 @@ export const InfraMonitorsTable = ({
|
||||
setRowsPerPage(value);
|
||||
};
|
||||
|
||||
const handleSort = (e: any, field: string) => {
|
||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (sortField === field) {
|
||||
@@ -101,13 +123,6 @@ export const InfraMonitorsTable = ({
|
||||
navigate(`/infrastructure/configure/${monitor.id}`);
|
||||
},
|
||||
},
|
||||
// {
|
||||
// id: 5,
|
||||
// label: "Clone",
|
||||
// action: () => {
|
||||
|
||||
// },
|
||||
// },
|
||||
{
|
||||
id: 6,
|
||||
label:
|
||||
@@ -152,8 +167,35 @@ export const InfraMonitorsTable = ({
|
||||
</Box>
|
||||
);
|
||||
const headers: Header<Monitor>[] = [
|
||||
{
|
||||
id: "selection",
|
||||
content: (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectAll")}
|
||||
indeterminate={isSomeSelected}
|
||||
checked={isAllSelected}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
),
|
||||
hideMobileLabel: true,
|
||||
render: (row) => (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectMonitor", {
|
||||
name: row.name,
|
||||
})}
|
||||
checked={isRowSelected(row.id)}
|
||||
onChange={(e) => {
|
||||
handleSelectRow(row.id, e.target.checked);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
onClick: (e) => e?.stopPropagation(),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
align: "left",
|
||||
mobileLabel: t("common.table.headers.name"),
|
||||
content: (
|
||||
<Typography
|
||||
component="div"
|
||||
@@ -173,18 +215,20 @@ export const InfraMonitorsTable = ({
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
mobileLabel: t("common.table.headers.status"),
|
||||
content: (
|
||||
<Typography
|
||||
component="div"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
direction={"row"}
|
||||
justifyContent={"center"}
|
||||
alignItems={"center"}
|
||||
onClick={(e) => handleSort(e, "status")}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<Box width={theme.spacing(8)} />
|
||||
{t("common.table.headers.status")}
|
||||
{renderSortIcon(sortField === "status")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
),
|
||||
render: (row) => {
|
||||
return <StatusLabel status={row.status} />;
|
||||
@@ -248,6 +292,11 @@ export const InfraMonitorsTable = ({
|
||||
onRowClick={(row) => {
|
||||
navigate(`/infrastructure/${row.id}`);
|
||||
}}
|
||||
getRowSx={(row) => ({
|
||||
backgroundColor: isRowSelected(row.id)
|
||||
? theme.palette.action.selected
|
||||
: "inherit",
|
||||
})}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
|
||||
@@ -2,8 +2,13 @@ import Stack from "@mui/material/Stack";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { MonitorBasePageWithStates } from "@/Components/design-elements";
|
||||
import { HeaderCreate } from "@/Components/common";
|
||||
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
|
||||
import { TextField, Dialog } from "@/Components/inputs";
|
||||
import {
|
||||
ControlsFilter,
|
||||
HeaderMonitorsSummary,
|
||||
BulkActionsBar,
|
||||
} from "@/Components/monitors";
|
||||
import { TextField, Dialog, Button } from "@/Components/inputs";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
|
||||
import { useGet, useDelete } from "@/Hooks/UseApi";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
@@ -16,6 +21,7 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { InfraMonitorsTable } from "./Components/MonitorsTable";
|
||||
import useDebounce from "@/Hooks/useDebounce";
|
||||
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||
|
||||
const InfrastructureMonitors = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -37,6 +43,7 @@ const InfrastructureMonitors = () => {
|
||||
const [sortField, setSortField] = useState<string>("");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||
|
||||
const isDialogOpen = Boolean(selectedMonitor);
|
||||
|
||||
const debouncedSearch = useDebounce<string>(search, 300);
|
||||
@@ -97,6 +104,15 @@ const InfrastructureMonitors = () => {
|
||||
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
|
||||
const isLoading = monitorsWithChecksLoading;
|
||||
|
||||
// Bulk actions
|
||||
const {
|
||||
selectedRows,
|
||||
setSelectedRows,
|
||||
handleBulkPause,
|
||||
handleBulkResume,
|
||||
handleCancelSelection,
|
||||
} = useBulkMonitorActions(refetch, page);
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = Boolean(selectedStatus || selectedState || search);
|
||||
|
||||
@@ -159,6 +175,29 @@ const InfrastructureMonitors = () => {
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{!isLoading && (
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedRows.length}
|
||||
onCancel={handleCancelSelection}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Play size={16} />}
|
||||
onClick={handleBulkResume}
|
||||
>
|
||||
{t("common.buttons.resume")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Pause size={16} />}
|
||||
onClick={handleBulkPause}
|
||||
>
|
||||
{t("common.buttons.pause")}
|
||||
</Button>
|
||||
</BulkActionsBar>
|
||||
)}
|
||||
|
||||
<InfraMonitorsTable
|
||||
monitors={monitorsWithChecksData?.monitors || []}
|
||||
refetch={refetch}
|
||||
@@ -180,6 +219,8 @@ const InfrastructureMonitors = () => {
|
||||
);
|
||||
setPage(0);
|
||||
}}
|
||||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { QueueData } from "@/Types/Queue";
|
||||
import { Metrics } from "@/Pages/Logs/components/Metrics";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { EmptyState } from "@/Components/design-elements";
|
||||
import { SPACING } from "@/Utils/Theme/constants";
|
||||
|
||||
export const TabQueue = () => {
|
||||
const theme = useTheme();
|
||||
@@ -19,7 +20,7 @@ export const TabQueue = () => {
|
||||
isLoading,
|
||||
// error,
|
||||
refetch,
|
||||
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 5000 });
|
||||
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 1000 });
|
||||
|
||||
const { post, loading: isFlushing } = usePost();
|
||||
|
||||
@@ -49,32 +50,22 @@ export const TabQueue = () => {
|
||||
<Stack gap={theme.spacing(1)}>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color="text.secondary"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{t("pages.logs.jobQueue")}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
<Trans
|
||||
i18nKey="pages.logs.jobQueueExplainer"
|
||||
components={{
|
||||
highlight: (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
backgroundColor:
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(19, 113, 91, 0.18)"
|
||||
: "#ECF7F2",
|
||||
color: theme.palette.text.primary,
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 0.5,
|
||||
}}
|
||||
bgcolor={theme.palette.rowStatus.running}
|
||||
color={theme.palette.text.primary}
|
||||
px={SPACING.SM}
|
||||
py={SPACING.XXS}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
@@ -87,16 +78,11 @@ export const TabQueue = () => {
|
||||
<Stack gap={theme.spacing(1)}>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color="text.secondary"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{t("pages.logs.failedJobs")}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{t("pages.logs.failedJobsExplainer")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -30,6 +30,22 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
};
|
||||
|
||||
const headers: Header<QueueJobWithId>[] = [
|
||||
{
|
||||
id: "state",
|
||||
content: t("pages.logs.table.headers.state"),
|
||||
render: (row) => {
|
||||
if (row.lockedAt) {
|
||||
return t("common.labels.running");
|
||||
}
|
||||
if (row.monitorActive === true) {
|
||||
return t("common.labels.active");
|
||||
}
|
||||
if (row.monitorActive === false) {
|
||||
return t("common.labels.paused");
|
||||
}
|
||||
return t("common.labels.idle");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "id",
|
||||
content: t("common.table.headers.monitorId"),
|
||||
@@ -47,7 +63,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
id: "url",
|
||||
content: t("common.table.headers.url"),
|
||||
render: (row) => (
|
||||
<Typography title={row.monitorUrl ?? ""} sx={cellSx}>
|
||||
<Typography
|
||||
title={row.monitorUrl ?? ""}
|
||||
sx={cellSx}
|
||||
>
|
||||
{row.monitorUrl}
|
||||
</Typography>
|
||||
),
|
||||
@@ -60,9 +79,15 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
{
|
||||
id: "interval",
|
||||
content: t("common.table.headers.interval"),
|
||||
render: (row) => (
|
||||
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
|
||||
),
|
||||
render: (row) => {
|
||||
let interval;
|
||||
if (row.monitorId.toString().includes("geo")) {
|
||||
interval = row.monitorGeoInterval ?? 0;
|
||||
} else {
|
||||
interval = row.repeat ?? 0;
|
||||
}
|
||||
return <Typography sx={cellSx}>{prettyMilliseconds(interval)}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lastRun",
|
||||
@@ -70,7 +95,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
render: (row) => {
|
||||
const v = formatTimestamp(row.lastRunAt) ?? "-";
|
||||
return (
|
||||
<Typography title={v} sx={cellSx}>
|
||||
<Typography
|
||||
title={v}
|
||||
sx={cellSx}
|
||||
>
|
||||
{v}
|
||||
</Typography>
|
||||
);
|
||||
@@ -90,7 +118,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
render: (row) => {
|
||||
const v = formatTimestamp(row.lockedAt) ?? "-";
|
||||
return (
|
||||
<Typography title={v} sx={cellSx}>
|
||||
<Typography
|
||||
title={v}
|
||||
sx={cellSx}
|
||||
>
|
||||
{v}
|
||||
</Typography>
|
||||
);
|
||||
@@ -98,16 +129,16 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
},
|
||||
];
|
||||
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
const runningBg = isDark ? "rgba(19, 113, 91, 0.18)" : "#ECF7F2";
|
||||
|
||||
return (
|
||||
<Table
|
||||
headers={headers}
|
||||
data={jobsWithId}
|
||||
getRowSx={(row) => ({
|
||||
...(row.lockedAt && {
|
||||
"& td": { backgroundColor: runningBg },
|
||||
"& td": { backgroundColor: theme.palette.rowStatus.running },
|
||||
}),
|
||||
...(row.monitorActive === false && {
|
||||
"& td": { backgroundColor: theme.palette.rowStatus.paused },
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
interface DummyChartProps {
|
||||
chartType: string;
|
||||
@@ -10,7 +11,7 @@ interface DummyChartProps {
|
||||
|
||||
const generateDummyChecks = (): CheckSnapshot[] => {
|
||||
const checks: CheckSnapshot[] = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
|
||||
const isUp = Math.random() > 0.1;
|
||||
const responseTime = Math.floor(Math.random() * 80) + 20;
|
||||
checks.push({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -32,8 +32,10 @@ import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"
|
||||
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||
import timezones from "@/Utils/timezones.json";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { mutate } from "swr";
|
||||
import axios from "axios";
|
||||
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
|
||||
import { ThemePicker } from "./Components/ThemePicker";
|
||||
|
||||
const monitorsUrl = (() => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -48,6 +50,9 @@ interface TimezoneOption {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const buildStatusPageKey = (slug: string | null | undefined) =>
|
||||
slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null;
|
||||
|
||||
const CreateStatusPage = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
@@ -58,9 +63,7 @@ const CreateStatusPage = () => {
|
||||
|
||||
// Fetch existing status page data when configuring
|
||||
const { data: statusPageData, isLoading: isLoadingStatusPage } =
|
||||
useGet<StatusPageResponse>(
|
||||
isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure`
|
||||
);
|
||||
useGet<StatusPageResponse>(isCreate ? null : buildStatusPageKey(url));
|
||||
|
||||
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
|
||||
const monitors = monitorsResponse ?? [];
|
||||
@@ -139,6 +142,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);
|
||||
@@ -178,6 +183,10 @@ const CreateStatusPage = () => {
|
||||
}
|
||||
|
||||
if (result) {
|
||||
await mutate(buildStatusPageKey(data.url));
|
||||
if (!isCreate && url !== data.url) {
|
||||
await mutate(buildStatusPageKey(url));
|
||||
}
|
||||
navigate(`/status/${data.url}`);
|
||||
}
|
||||
};
|
||||
@@ -438,6 +447,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,64 @@
|
||||
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 { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls";
|
||||
import { resolveStatusPageTheme } from "@/Types/StatusPage";
|
||||
import {
|
||||
PUBLIC_STATUS_PAGE_PREFIX,
|
||||
type StatusPageResponse,
|
||||
type StatusPageTheme,
|
||||
} from "@/Types/StatusPage";
|
||||
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
|
||||
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
||||
import {
|
||||
BaseStatusPage,
|
||||
type ThemeConfig,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import { BrowserFrame } from "@/Pages/StatusPage/Status/themes/BrowserFrame";
|
||||
import { refinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
|
||||
import { RefinedHeader } from "@/Pages/StatusPage/Status/themes/refined/RefinedHeader";
|
||||
import { RefinedHero } from "@/Pages/StatusPage/Status/themes/refined/RefinedHero";
|
||||
import { modernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
|
||||
import { ModernHeader } from "@/Pages/StatusPage/Status/themes/modern/ModernHeader";
|
||||
import { ModernHero } from "@/Pages/StatusPage/Status/themes/modern/ModernHero";
|
||||
import { boldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
|
||||
import { BoldHeader } from "@/Pages/StatusPage/Status/themes/bold/BoldHeader";
|
||||
import { BoldHero } from "@/Pages/StatusPage/Status/themes/bold/BoldHero";
|
||||
import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
|
||||
import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader";
|
||||
import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero";
|
||||
|
||||
const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
|
||||
refined: {
|
||||
createStyles: refinedStyles,
|
||||
HeaderSlot: RefinedHeader,
|
||||
HeroSlot: RefinedHero,
|
||||
},
|
||||
modern: {
|
||||
createStyles: modernStyles,
|
||||
HeaderSlot: ModernHeader,
|
||||
HeroSlot: ModernHero,
|
||||
overallStatusOptions: { iconSize: 20 },
|
||||
},
|
||||
bold: {
|
||||
createStyles: boldStyles,
|
||||
HeaderSlot: BoldHeader,
|
||||
HeroSlot: BoldHero,
|
||||
overallStatusOptions: { iconSize: 18 },
|
||||
},
|
||||
editorial: {
|
||||
createStyles: editorialStyles,
|
||||
HeaderSlot: EditorialHeader,
|
||||
HeroSlot: EditorialHero,
|
||||
overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" },
|
||||
},
|
||||
};
|
||||
|
||||
const StatusPageView = () => {
|
||||
const theme = useTheme();
|
||||
@@ -20,8 +66,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 +111,50 @@ const StatusPageView = () => {
|
||||
);
|
||||
}
|
||||
|
||||
let sx: React.CSSProperties = {};
|
||||
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
|
||||
const themedRenderer = (
|
||||
<BaseStatusPage
|
||||
statusPage={statusPage}
|
||||
monitors={monitors}
|
||||
config={themeConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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}
|
||||
timezone={statusPage.timezone}
|
||||
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}
|
||||
timezone={statusPage.timezone}
|
||||
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,153 @@
|
||||
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 { resolveTimezone } from "@/Utils/TimeUtils";
|
||||
import { themeTokens, type StatusPageThemeTokens } from "./tokens";
|
||||
|
||||
type ResolvedMode = "light" | "dark";
|
||||
|
||||
interface StatusPageThemeContextValue {
|
||||
theme: StatusPageTheme;
|
||||
mode: ResolvedMode;
|
||||
tokens: StatusPageThemeTokens;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
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;
|
||||
timezone?: string;
|
||||
/**
|
||||
* 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,
|
||||
timezone,
|
||||
paintBody = false,
|
||||
transparent = false,
|
||||
children,
|
||||
}: Props) => {
|
||||
const resolvedTheme = resolveStatusPageTheme(theme);
|
||||
const resolvedThemeMode = resolveStatusPageThemeMode(themeMode);
|
||||
const resolvedTimezone = resolveTimezone(timezone);
|
||||
|
||||
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,
|
||||
timezone: resolvedTimezone,
|
||||
}),
|
||||
[resolvedTheme, resolvedMode, tokens, resolvedTimezone]
|
||||
);
|
||||
|
||||
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()],
|
||||
timezone: resolveTimezone(),
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
|
||||
|
||||
export const BoldHeader = ({ statusPage, logoSrc, styles }: SlotProps<BoldStyles>) => (
|
||||
<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>
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
|
||||
|
||||
export const BoldHero = ({ overall, monitorCount, styles }: SlotProps<BoldStyles>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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: monitorCount,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,405 @@
|
||||
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";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
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(${MAX_RECENT_CHECKS}, 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(${MAX_RECENT_CHECKS}, 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,36 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
|
||||
|
||||
export const EditorialHeader = ({
|
||||
statusPage,
|
||||
logoSrc,
|
||||
styles,
|
||||
}: SlotProps<EditorialStyles>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMemo } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
|
||||
|
||||
export const EditorialHero = ({
|
||||
overall,
|
||||
monitorCount,
|
||||
styles,
|
||||
}: SlotProps<EditorialStyles>) => {
|
||||
const { t } = useTranslation();
|
||||
const todayLabel = useMemo(
|
||||
() =>
|
||||
new Date().toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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: monitorCount,
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,341 @@
|
||||
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";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
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,
|
||||
_isDark: boolean
|
||||
): 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(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gap: "2px",
|
||||
height: 40,
|
||||
},
|
||||
heatmapCell: (kind) => ({
|
||||
background: heatCellBg[kind],
|
||||
opacity: kind === "empty" ? 0.5 : 1,
|
||||
}),
|
||||
|
||||
histogram: {
|
||||
mt: "16px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 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,24 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
|
||||
|
||||
export const ModernHeader = ({
|
||||
statusPage,
|
||||
logoSrc,
|
||||
styles,
|
||||
}: SlotProps<ModernStyles>) => (
|
||||
<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>
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
|
||||
|
||||
export const ModernHero = ({
|
||||
overall,
|
||||
monitorCount,
|
||||
styles,
|
||||
}: SlotProps<ModernStyles>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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: monitorCount,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,429 @@
|
||||
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";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
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(${MAX_RECENT_CHECKS}, 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(${MAX_RECENT_CHECKS}, 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,29 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
|
||||
|
||||
export const RefinedHeader = ({
|
||||
statusPage,
|
||||
logoSrc,
|
||||
styles,
|
||||
}: SlotProps<RefinedStyles>) => (
|
||||
<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>
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
|
||||
|
||||
export const RefinedHero = ({
|
||||
overall,
|
||||
monitorCount,
|
||||
styles,
|
||||
}: SlotProps<RefinedStyles>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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: monitorCount,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,368 @@
|
||||
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";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
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,
|
||||
_isDark: boolean
|
||||
): 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(${MAX_RECENT_CHECKS}, 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(${MAX_RECENT_CHECKS}, 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,250 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { StatusPage } from "@/Types/StatusPage";
|
||||
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
|
||||
import {
|
||||
ThemedHeatmap,
|
||||
type HeatCellKind,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
|
||||
import {
|
||||
ThemedHistogram,
|
||||
type BarKind,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
|
||||
import {
|
||||
ThemedInfrastructure,
|
||||
type GaugeFillLevel,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure";
|
||||
import {
|
||||
type OverallStatus,
|
||||
type OverallTone,
|
||||
monitorBadgeTone,
|
||||
resolveOverallStatus,
|
||||
statusBadgeKey,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
|
||||
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
||||
|
||||
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
|
||||
|
||||
export interface BaseStyles {
|
||||
page: SxProps<Theme>;
|
||||
top: 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: HeatCellKind) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: BarKind, 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: GaugeFillLevel, widthPct: number) => SxProps<Theme>;
|
||||
gaugeSub: SxProps<Theme>;
|
||||
footer: SxProps<Theme>;
|
||||
}
|
||||
|
||||
export interface SlotProps<S extends BaseStyles = BaseStyles> {
|
||||
statusPage: StatusPage;
|
||||
logoSrc: string | null;
|
||||
overall: OverallStatus;
|
||||
monitorCount: number;
|
||||
styles: S;
|
||||
}
|
||||
|
||||
export interface ThemeConfig<S extends BaseStyles = BaseStyles> {
|
||||
createStyles: (tokens: StatusPageThemeTokens, isDark: boolean) => S;
|
||||
HeaderSlot: React.ComponentType<SlotProps<S>>;
|
||||
HeroSlot: React.ComponentType<SlotProps<S>>;
|
||||
overallStatusOptions?: { iconSize?: number; allUpKey?: string };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
statusPage: StatusPage;
|
||||
monitors: StatusPageMonitor[];
|
||||
config: ThemeConfig<any>;
|
||||
}
|
||||
|
||||
export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { tokens, mode } = useStatusPageTheme();
|
||||
const styles = useMemo(
|
||||
() => config.createStyles(tokens, mode === "dark"),
|
||||
[config, tokens, mode]
|
||||
);
|
||||
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
|
||||
|
||||
const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions);
|
||||
const logoSrc = statusPage.logo?.data
|
||||
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
|
||||
: null;
|
||||
|
||||
const { HeaderSlot, HeroSlot } = config;
|
||||
|
||||
return (
|
||||
<Box sx={styles.page}>
|
||||
<Box
|
||||
component="header"
|
||||
sx={styles.top}
|
||||
>
|
||||
<HeaderSlot
|
||||
statusPage={statusPage}
|
||||
logoSrc={logoSrc}
|
||||
overall={overall}
|
||||
monitorCount={monitors.length}
|
||||
styles={styles}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<HeroSlot
|
||||
statusPage={statusPage}
|
||||
logoSrc={logoSrc}
|
||||
overall={overall}
|
||||
monitorCount={monitors.length}
|
||||
styles={styles}
|
||||
/>
|
||||
|
||||
{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,90 @@
|
||||
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 type { CheckSnapshot } from "@/Types/Check";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
||||
|
||||
const CELLS = MAX_RECENT_CHECKS;
|
||||
|
||||
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 { timezone } = useStatusPageTheme();
|
||||
|
||||
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", timezone)}
|
||||
</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,115 @@
|
||||
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 type { CheckSnapshot } from "@/Types/Check";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
||||
const CELLS = MAX_RECENT_CHECKS;
|
||||
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 { timezone } = useStatusPageTheme();
|
||||
|
||||
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",
|
||||
timezone
|
||||
)}
|
||||
</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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,6 +20,24 @@ import { useSelector } from "react-redux";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { ActionMenuItem } from "@/Components/actions-menu";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { Checkbox } from "@/Components/inputs";
|
||||
|
||||
interface MonitorTableProps {
|
||||
monitors: Monitor[];
|
||||
refetch: () => void;
|
||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
selectedRows?: string[];
|
||||
onSelectionChange?: (selected: string[]) => void;
|
||||
}
|
||||
|
||||
export const MonitorTable = ({
|
||||
monitors,
|
||||
@@ -34,31 +52,34 @@ export const MonitorTable = ({
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
monitors: Monitor[];
|
||||
refetch: Function;
|
||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
selectedRows = [],
|
||||
onSelectionChange,
|
||||
}: MonitorTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram");
|
||||
const {
|
||||
post,
|
||||
// loading: isPosting,
|
||||
// error: postError,
|
||||
} = usePost<any, Monitor>();
|
||||
const { post } = usePost<Record<string, never>, Monitor>();
|
||||
|
||||
const handleSort = (e: any, field: string) => {
|
||||
const selectedSet = new Set(selectedRows);
|
||||
const isAllSelected =
|
||||
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
|
||||
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
|
||||
};
|
||||
|
||||
const handleSelectRow = (id: string, checked: boolean) => {
|
||||
if (!onSelectionChange) return;
|
||||
onSelectionChange(
|
||||
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
|
||||
);
|
||||
};
|
||||
|
||||
const isRowSelected = (id: string) => selectedSet.has(id);
|
||||
|
||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (sortField === field) {
|
||||
@@ -110,13 +131,6 @@ export const MonitorTable = ({
|
||||
navigate(`/uptime/configure/${monitor.id}`);
|
||||
},
|
||||
},
|
||||
// {
|
||||
// id: 5,
|
||||
// label: "Clone",
|
||||
// action: () => {
|
||||
|
||||
// },
|
||||
// },
|
||||
{
|
||||
id: 6,
|
||||
label:
|
||||
@@ -160,8 +174,35 @@ export const MonitorTable = ({
|
||||
</Box>
|
||||
);
|
||||
const headers: Header<Monitor>[] = [
|
||||
{
|
||||
id: "selection",
|
||||
content: (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectAll")}
|
||||
indeterminate={isSomeSelected}
|
||||
checked={isAllSelected}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
),
|
||||
hideMobileLabel: true,
|
||||
render: (row) => (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectMonitor", {
|
||||
name: row.name,
|
||||
})}
|
||||
checked={isRowSelected(row.id)}
|
||||
onChange={(e) => {
|
||||
handleSelectRow(row.id, e.target.checked);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
onClick: (e) => e?.stopPropagation(),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
align: "left",
|
||||
mobileLabel: t("common.table.headers.name"),
|
||||
content: (
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
@@ -181,6 +222,7 @@ export const MonitorTable = ({
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
mobileLabel: t("common.table.headers.status"),
|
||||
content: (
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
@@ -212,6 +254,7 @@ export const MonitorTable = ({
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
mobileLabel: t("common.table.headers.type"),
|
||||
content: (
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
@@ -251,6 +294,11 @@ export const MonitorTable = ({
|
||||
onRowClick={(row) => {
|
||||
navigate(`/uptime/${row.id}`);
|
||||
}}
|
||||
getRowSx={(row) => ({
|
||||
backgroundColor: isRowSelected(row.id)
|
||||
? theme.palette.action.selected
|
||||
: "inherit",
|
||||
})}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
|
||||
import {
|
||||
ControlsFilter,
|
||||
HeaderMonitorsSummary,
|
||||
BulkActionsBar,
|
||||
} from "@/Components/monitors";
|
||||
import { MonitorBasePageWithStates } from "@/Components/design-elements";
|
||||
import { TextField, Dialog } from "@/Components/inputs";
|
||||
import { TextField, Dialog, Button } from "@/Components/inputs";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
|
||||
import { HeaderCreate } from "@/Components/common";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
@@ -16,6 +21,7 @@ import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useTheme } from "@mui/material";
|
||||
import useDebounce from "@/Hooks/useDebounce";
|
||||
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||
|
||||
const UptimeMonitorsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -37,6 +43,7 @@ const UptimeMonitorsPage = () => {
|
||||
const [sortField, setSortField] = useState<string>("");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||
|
||||
const isDialogOpen = Boolean(selectedMonitor);
|
||||
|
||||
const debouncedSearch = useDebounce<string>(search, 300);
|
||||
@@ -102,6 +109,15 @@ const UptimeMonitorsPage = () => {
|
||||
count,
|
||||
} = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 };
|
||||
|
||||
// Bulk actions
|
||||
const {
|
||||
selectedRows,
|
||||
setSelectedRows,
|
||||
handleBulkPause,
|
||||
handleBulkResume,
|
||||
handleCancelSelection,
|
||||
} = useBulkMonitorActions(refetch, page);
|
||||
|
||||
// Delete hook
|
||||
const { deleteFn, loading: isDeleting } = useDelete();
|
||||
|
||||
@@ -177,6 +193,29 @@ const UptimeMonitorsPage = () => {
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{!isLoading && (
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedRows.length}
|
||||
onCancel={handleCancelSelection}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Play size={16} />}
|
||||
onClick={handleBulkResume}
|
||||
>
|
||||
{t("common.buttons.resume")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Pause size={16} />}
|
||||
onClick={handleBulkPause}
|
||||
>
|
||||
{t("common.buttons.pause")}
|
||||
</Button>
|
||||
</BulkActionsBar>
|
||||
)}
|
||||
|
||||
<MonitorTable
|
||||
monitors={monitorsWithChecks || []}
|
||||
refetch={refetch}
|
||||
@@ -198,6 +237,8 @@ const UptimeMonitorsPage = () => {
|
||||
);
|
||||
setPage(0);
|
||||
}}
|
||||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { PageSpeedGroupedCheck } from "@/Types/Check";
|
||||
import type { GeoContinent } from "@/Types/GeoCheck";
|
||||
export type { GeoContinent } from "@/Types/GeoCheck";
|
||||
|
||||
export const MAX_RECENT_CHECKS = 50;
|
||||
|
||||
export const MonitorTypes = [
|
||||
"http",
|
||||
"ping",
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface QueueJobSummary {
|
||||
monitorUrl: string | null;
|
||||
monitorType: MonitorType | null;
|
||||
monitorInterval: number | null;
|
||||
monitorGeoInterval: number | null;
|
||||
monitorActive: boolean | null;
|
||||
active: boolean;
|
||||
lockedAt: number | null;
|
||||
runCount: number;
|
||||
@@ -32,6 +34,7 @@ export interface QueueJobSummary {
|
||||
lastFinishedAt: number | null;
|
||||
lastRunTook: number | null;
|
||||
lastFailedAt: number | null;
|
||||
repeat: number | null;
|
||||
}
|
||||
|
||||
export interface QueueData {
|
||||
|
||||
@@ -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,15 @@
|
||||
import { alpha } from "@mui/material/styles";
|
||||
|
||||
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 = {
|
||||
@@ -16,6 +19,9 @@ export const colors = {
|
||||
gray850: "#1c1c21",
|
||||
brandGreen: "#13715B",
|
||||
brandGreenLight: "#4DAF94",
|
||||
green200: "#ECF7F2",
|
||||
yellow200: "#FFF4E5",
|
||||
orange500: "#FFA500",
|
||||
};
|
||||
|
||||
export const lightPalette = {
|
||||
@@ -28,6 +34,10 @@ export const lightPalette = {
|
||||
sidebar: {
|
||||
accent: colors.brandGreen,
|
||||
},
|
||||
rowStatus: {
|
||||
running: colors.green200,
|
||||
paused: colors.yellow200,
|
||||
},
|
||||
};
|
||||
|
||||
export const darkPalette = {
|
||||
@@ -44,4 +54,8 @@ export const darkPalette = {
|
||||
sidebar: {
|
||||
accent: colors.brandGreenLight,
|
||||
},
|
||||
rowStatus: {
|
||||
running: alpha(colors.brandGreen, 0.18),
|
||||
paused: alpha(colors.orange500, 0.18),
|
||||
},
|
||||
};
|
||||
@@ -15,9 +15,11 @@ declare module "@mui/material/styles" {
|
||||
}
|
||||
interface Palette {
|
||||
sidebar: { accent: string };
|
||||
rowStatus: { running: string; paused: string };
|
||||
}
|
||||
interface PaletteOptions {
|
||||
sidebar?: { accent: string };
|
||||
rowStatus?: { running: string; paused: string };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +60,7 @@ export const theme = (mode: string, palette: any) =>
|
||||
fontFamilyMonospace,
|
||||
fontSize: typographyLevels.base,
|
||||
h1: {
|
||||
fontSize: typographyLevels.xl,
|
||||
fontSize: typographyLevels.xxl,
|
||||
fontWeight: 500,
|
||||
},
|
||||
h2: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const SPACING = {
|
||||
XXS: 0.25,
|
||||
XS: 0.5,
|
||||
SM: 1,
|
||||
MD: 1.5,
|
||||
@@ -8,6 +9,7 @@ export const SPACING = {
|
||||
} as const;
|
||||
|
||||
export const LAYOUT = {
|
||||
XXS: 2,
|
||||
XS: 4,
|
||||
SM: 6,
|
||||
MD: 8,
|
||||
@@ -15,3 +17,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;
|
||||
@@ -15,12 +15,33 @@ export const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
||||
export const MS_PER_DAY = 24 * MS_PER_HOUR;
|
||||
export const MS_PER_WEEK = MS_PER_DAY * 7;
|
||||
|
||||
export const formatDateWithTz = (timestamp: string, format: string, timezone: string) => {
|
||||
const resolveSystemTimezone = (): string => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
} catch {
|
||||
return "UTC";
|
||||
}
|
||||
};
|
||||
|
||||
export const SYSTEM_TIMEZONE = resolveSystemTimezone();
|
||||
|
||||
export const resolveTimezone = (preferred?: string | null): string =>
|
||||
preferred?.trim() || SYSTEM_TIMEZONE;
|
||||
|
||||
export const formatDateWithTz = (
|
||||
timestamp: string,
|
||||
format: string,
|
||||
timezone?: string
|
||||
) => {
|
||||
if (!timestamp) {
|
||||
return "Unknown time";
|
||||
}
|
||||
const formattedDate = dayjs(timestamp).tz(timezone).format(format);
|
||||
return formattedDate;
|
||||
const tz = resolveTimezone(timezone);
|
||||
try {
|
||||
return dayjs(timestamp).tz(tz).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(),
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "دعوة عضو",
|
||||
"openSite": "فتح الموقع",
|
||||
"pause": "إيقاف مؤقت",
|
||||
"resume": "استئناف"
|
||||
"resume": "استئناف",
|
||||
"selectAll": "تحديد جميع الشاشات",
|
||||
"selectMonitor": "تحديد {{name}}",
|
||||
"bulkSelected_one": "{{count}} شاشة محددة",
|
||||
"bulkSelected_other": "{{count}} شاشات محددة"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "نشط منذ",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "البنية التحتية",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "تم إيقاف {{count}} شاشة مؤقتاً",
|
||||
"paused_other": "تم إيقاف {{count}} شاشات مؤقتاً",
|
||||
"resumed_one": "تم استئناف {{count}} شاشة",
|
||||
"resumed_other": "تم استئناف {{count}} شاشات",
|
||||
"alreadyPaused_one": "الشاشة المحددة متوقفة مؤقتاً بالفعل",
|
||||
"alreadyPaused_other": "جميع الشاشات المحددة متوقفة مؤقتاً بالفعل",
|
||||
"alreadyRunning_one": "الشاشة المحددة قيد التشغيل بالفعل",
|
||||
"alreadyRunning_other": "جميع الشاشات المحددة قيد التشغيل بالفعل"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -534,6 +548,10 @@
|
||||
"wsUrl": {
|
||||
"label": "رابط WebSocket",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "نقطة نهاية Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "الإعدادات العامة",
|
||||
@@ -542,11 +560,11 @@
|
||||
"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) وأضف اسم عرض.",
|
||||
"hardware": "مراقبة استخدام CPU والذاكرة والقرص ودرجة الحرارة للبنية التحتية الخاصة بك."
|
||||
"hardware": "راقب استخدام وحدة المعالجة المركزية والذاكرة والقرص ودرجة الحرارة لبنيتك التحتية. يجب أن يتضمن عنوان URL لنقطة نهاية Capture المسار الكامل /api/v1/metrics (مثال: http://192.168.1.10:5001/api/v1/metrics). بالنسبة لمفتاح التفويض، الصق سلسلة الرمز فقط — لا تُضمّن كلمة 'Bearer'."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -769,6 +787,7 @@
|
||||
"memory": "استخدام الذاكرة",
|
||||
"netBytesRecv": "{{name}} - البايتات المستلمة",
|
||||
"netBytesSent": "{{name}} - البايتات المرسلة",
|
||||
"netNoTraffic": "Capture يبلغ عن عدم وجود حركة بيانات على أي واجهة. هذا أمر طبيعي عندما يعمل Capture داخل حاوية لا ترى سوى واجهات الاسترجاع — قم بتشغيل Capture مباشرة على المضيف لرؤية نشاط الشبكة الحقيقي.",
|
||||
"temp": "الحرارة"
|
||||
}
|
||||
},
|
||||
@@ -1236,11 +1255,12 @@
|
||||
"description": "إضافة مراقبات نموذجية لأغراض العرض التوضيحي."
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "إعادة تعيين النظام",
|
||||
"title": "إعادة ضبط النظام",
|
||||
"description": "إزالة جميع المراقبات من نظامك.",
|
||||
"dialog": {
|
||||
"title": "إزالة جميع المراقبات؟",
|
||||
"description": "لا يمكن التراجع عن هذا."
|
||||
"description": "لا يمكن التراجع عن هذا الإجراء.",
|
||||
"paragraph": "سيتم أيضًا إزالة جميع سجلات الفحص والحوادث ونوافذ الصيانة المجدولة وأي صفحات حالة كانت تعتمد على هذه المراقبات. صدّر المراقبات أولًا للاحتفاظ بنسخة احتياطية من بياناتها."
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Pozvat člena",
|
||||
"openSite": "Otevřít stránku",
|
||||
"pause": "Pozastavit",
|
||||
"resume": "Obnovit"
|
||||
"resume": "Obnovit",
|
||||
"selectAll": "Vybrat všechny monitory",
|
||||
"selectMonitor": "Vybrat {{name}}",
|
||||
"bulkSelected_one": "Vybrán {{count}} monitor",
|
||||
"bulkSelected_other": "Vybráno {{count}} monitorů"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktivní po dobu",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Infrastruktura",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} monitor pozastaven",
|
||||
"paused_other": "{{count}} monitorů pozastaveno",
|
||||
"resumed_one": "{{count}} monitor obnoven",
|
||||
"resumed_other": "{{count}} monitorů obnoveno",
|
||||
"alreadyPaused_one": "Vybraný monitor je již pozastaven",
|
||||
"alreadyPaused_other": "Všechny vybrané monitory jsou již pozastaveny",
|
||||
"alreadyRunning_one": "Vybraný monitor již běží",
|
||||
"alreadyRunning_other": "Všechny vybrané monitory již běží"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -534,6 +548,10 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Endpoint Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Obecná nastavení",
|
||||
@@ -542,11 +560,11 @@
|
||||
"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.",
|
||||
"hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury."
|
||||
"hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury. URL endpointu Capture musí obsahovat celou cestu /api/v1/metrics (např. http://192.168.1.10:5001/api/v1/metrics). Do autorizačního klíče vložte pouze řetězec tokenu — nezahrnujte 'Bearer'."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -769,6 +787,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"
|
||||
}
|
||||
},
|
||||
@@ -1236,11 +1255,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": {
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Mitglied einladen",
|
||||
"openSite": "Seite öffnen",
|
||||
"pause": "Pausieren",
|
||||
"resume": "Fortsetzen"
|
||||
"resume": "Fortsetzen",
|
||||
"selectAll": "Alle Monitore auswählen",
|
||||
"selectMonitor": "{{name}} auswählen",
|
||||
"bulkSelected_one": "{{count}} Monitor ausgewählt",
|
||||
"bulkSelected_other": "{{count}} Monitore ausgewählt"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktiv seit",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Infrastruktur",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} Monitor pausiert",
|
||||
"paused_other": "{{count}} Monitore pausiert",
|
||||
"resumed_one": "{{count}} Monitor fortgesetzt",
|
||||
"resumed_other": "{{count}} Monitore fortgesetzt",
|
||||
"alreadyPaused_one": "Der ausgewählte Monitor ist bereits pausiert",
|
||||
"alreadyPaused_other": "Alle ausgewählten Monitore sind bereits pausiert",
|
||||
"alreadyRunning_one": "Der ausgewählte Monitor wird bereits ausgeführt",
|
||||
"alreadyRunning_other": "Alle ausgewählten Monitore werden bereits ausgeführt"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -534,6 +548,10 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket-URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Capture-Endpunkt",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Allgemeine Einstellungen",
|
||||
@@ -542,11 +560,11 @@
|
||||
"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.",
|
||||
"hardware": "Überwachen Sie CPU-, Arbeitsspeicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur."
|
||||
"hardware": "Überwachen Sie CPU-, Speicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur. Die URL des Capture-Endpunkts muss den vollständigen Pfad /api/v1/metrics enthalten (z. B. http://192.168.1.10:5001/api/v1/metrics). Fügen Sie für das Autorisierungsgeheimnis nur den Token-String ein — geben Sie 'Bearer' nicht mit an."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -769,6 +787,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."
|
||||
}
|
||||
},
|
||||
@@ -1237,10 +1256,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": {
|
||||
|
||||
@@ -70,7 +70,9 @@
|
||||
},
|
||||
"labels": {
|
||||
"active": "Active",
|
||||
"paused": "paused",
|
||||
"running": "Running",
|
||||
"paused": "Paused",
|
||||
"idle": "Idle",
|
||||
"na": "N/A",
|
||||
"resolved": "Resolved",
|
||||
"responseTime": "Response time"
|
||||
@@ -421,7 +423,21 @@
|
||||
"inviteMember": "Invite member",
|
||||
"openSite": "Open site",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume"
|
||||
"resume": "Resume",
|
||||
"bulkSelected_one": "{{count}} monitor selected",
|
||||
"bulkSelected_other": "{{count}} monitors selected",
|
||||
"selectAll": "Select all monitors",
|
||||
"selectMonitor": "Select {{name}}"
|
||||
},
|
||||
"bulkPause": {
|
||||
"alreadyPaused_one": "The selected monitor is already paused",
|
||||
"alreadyPaused_other": "All selected monitors are already paused",
|
||||
"alreadyRunning_one": "The selected monitor is already running",
|
||||
"alreadyRunning_other": "All selected monitors are already running",
|
||||
"paused_one": "{{count}} monitor paused",
|
||||
"paused_other": "{{count}} monitors paused",
|
||||
"resumed_one": "{{count}} monitor resumed",
|
||||
"resumed_other": "{{count}} monitors resumed"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Active for",
|
||||
@@ -534,6 +550,10 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Capture endpoint",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "General settings",
|
||||
@@ -542,11 +562,11 @@
|
||||
"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.",
|
||||
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure."
|
||||
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -769,6 +789,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"
|
||||
}
|
||||
},
|
||||
@@ -882,6 +903,7 @@
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"state": "State",
|
||||
"timestamp": "Timestamp",
|
||||
"level": "Level",
|
||||
"service": "Service",
|
||||
@@ -1197,6 +1219,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 +1266,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 +1301,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 +1331,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 +1364,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 +1428,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.",
|
||||
|
||||
@@ -428,7 +428,11 @@
|
||||
"inviteMember": "Invitar miembro",
|
||||
"openSite": "Abrir sitio",
|
||||
"pause": "Pausar",
|
||||
"resume": "Reanudar"
|
||||
"resume": "Reanudar",
|
||||
"selectAll": "Seleccionar todos los monitores",
|
||||
"selectMonitor": "Seleccionar {{name}}",
|
||||
"bulkSelected_one": "{{count}} monitor seleccionado",
|
||||
"bulkSelected_other": "{{count}} monitores seleccionados"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Activo desde hace",
|
||||
@@ -456,6 +460,16 @@
|
||||
"optionHardware": "Infraestructura",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} monitor pausado",
|
||||
"paused_other": "{{count}} monitores pausados",
|
||||
"resumed_one": "{{count}} monitor reanudado",
|
||||
"resumed_other": "{{count}} monitores reanudados",
|
||||
"alreadyPaused_one": "El monitor seleccionado ya está pausado",
|
||||
"alreadyPaused_other": "Todos los monitores seleccionados ya están pausados",
|
||||
"alreadyRunning_one": "El monitor seleccionado ya se está ejecutando",
|
||||
"alreadyRunning_other": "Todos los monitores seleccionados ya se están ejecutando"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -541,6 +555,10 @@
|
||||
"wsUrl": {
|
||||
"label": "URL de WebSocket",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Endpoint de Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Configuración general",
|
||||
@@ -549,11 +567,11 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"hardware": "Monitoree el uso de CPU, memoria, disco y temperatura de su infraestructura."
|
||||
"hardware": "Supervisa el uso de CPU, memoria, disco y temperatura de tu infraestructura. La URL del endpoint de Capture debe incluir la ruta completa /api/v1/metrics (p. ej., http://192.168.1.10:5001/api/v1/metrics). Para el secreto de autorización, pega solo la cadena del token — no incluyas 'Bearer'."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -776,6 +794,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"
|
||||
}
|
||||
},
|
||||
@@ -1243,11 +1262,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": {
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Kutsu jäsen",
|
||||
"openSite": "Avaa sivusto",
|
||||
"pause": "Keskeytä",
|
||||
"resume": "Jatka"
|
||||
"resume": "Jatka",
|
||||
"selectAll": "Valitse kaikki näytöt",
|
||||
"selectMonitor": "Valitse {{name}}",
|
||||
"bulkSelected_one": "{{count}} näyttö valittu",
|
||||
"bulkSelected_other": "{{count}} näyttöä valittu"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktiivinen",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Infrastruktuuri",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} näyttö keskeytetty",
|
||||
"paused_other": "{{count}} näyttöä keskeytetty",
|
||||
"resumed_one": "{{count}} näyttö jatkettu",
|
||||
"resumed_other": "{{count}} näyttöä jatkettu",
|
||||
"alreadyPaused_one": "Valittu näyttö on jo keskeytetty",
|
||||
"alreadyPaused_other": "Kaikki valitut näytöt on jo keskeytetty",
|
||||
"alreadyRunning_one": "Valittu näyttö on jo käynnissä",
|
||||
"alreadyRunning_other": "Kaikki valitut näytöt ovat jo käynnissä"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -534,6 +548,10 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Capture-päätepiste",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Yleiset asetukset",
|
||||
@@ -542,11 +560,11 @@
|
||||
"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.",
|
||||
"hardware": "Monitoroi infrastruktuurisi CPU:n, muistin ja levyn käyttöä sekä lämpötilaa."
|
||||
"hardware": "Seuraa infrastruktuurisi suorittimen, muistin, levyn käyttöä ja lämpötilaa. Capture-päätepisteen URL-osoitteen on sisällettävä koko polku /api/v1/metrics (esim. http://192.168.1.10:5001/api/v1/metrics). Liitä valtuutusavaimeen vain tokenin merkkijono — älä sisällytä sanaa 'Bearer'."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -769,6 +787,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ö"
|
||||
}
|
||||
},
|
||||
@@ -1236,11 +1255,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": {
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Inviter un membre",
|
||||
"openSite": "Ouvrir le site",
|
||||
"pause": "Pause",
|
||||
"resume": "Reprendre"
|
||||
"resume": "Reprendre",
|
||||
"selectAll": "Sélectionner tous les moniteurs",
|
||||
"selectMonitor": "Sélectionner {{name}}",
|
||||
"bulkSelected_one": "{{count}} moniteur sélectionné",
|
||||
"bulkSelected_other": "{{count}} moniteurs sélectionnés"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Actif depuis",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Infrastructure",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} moniteur en pause",
|
||||
"paused_other": "{{count}} moniteurs en pause",
|
||||
"resumed_one": "{{count}} moniteur repris",
|
||||
"resumed_other": "{{count}} moniteurs repris",
|
||||
"alreadyPaused_one": "Le moniteur sélectionné est déjà en pause",
|
||||
"alreadyPaused_other": "Tous les moniteurs sélectionnés sont déjà en pause",
|
||||
"alreadyRunning_one": "Le moniteur sélectionné est déjà en cours d'exécution",
|
||||
"alreadyRunning_other": "Tous les moniteurs sélectionnés sont déjà en cours d'exécution"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -534,6 +548,10 @@
|
||||
"wsUrl": {
|
||||
"label": "URL WebSocket",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Point de terminaison Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Paramètres généraux",
|
||||
@@ -542,11 +560,11 @@
|
||||
"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.",
|
||||
"hardware": "Surveillez l'utilisation du CPU, de la mémoire, du disque et la température de votre infrastructure."
|
||||
"hardware": "Surveillez l'utilisation du processeur, de la mémoire, du disque et la température de votre infrastructure. L'URL du point de terminaison Capture doit inclure le chemin complet /api/v1/metrics (par ex. http://192.168.1.10:5001/api/v1/metrics). Pour le secret d'autorisation, collez uniquement la chaîne du jeton — n'incluez pas 'Bearer'."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -769,6 +787,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."
|
||||
}
|
||||
},
|
||||
@@ -1236,11 +1255,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": {
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "メンバーを招待",
|
||||
"openSite": "サイトを開く",
|
||||
"pause": "一時停止",
|
||||
"resume": "再開"
|
||||
"resume": "再開",
|
||||
"selectAll": "すべてのモニターを選択",
|
||||
"selectMonitor": "{{name}} を選択",
|
||||
"bulkSelected_one": "{{count}} 個のモニターが選択されました",
|
||||
"bulkSelected_other": "{{count}} 個のモニターが選択されました"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "稼働期間",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "インフラストラクチャ",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} 個のモニターを一時停止しました",
|
||||
"paused_other": "{{count}} 個のモニターを一時停止しました",
|
||||
"resumed_one": "{{count}} 個のモニターを再開しました",
|
||||
"resumed_other": "{{count}} 個のモニターを再開しました",
|
||||
"alreadyPaused_one": "選択したモニターはすでに一時停止されています",
|
||||
"alreadyPaused_other": "選択したすべてのモニターはすでに一時停止されています",
|
||||
"alreadyRunning_one": "選択したモニターはすでに実行中です",
|
||||
"alreadyRunning_other": "選択したすべてのモニターはすでに実行中です"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -534,6 +548,10 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Capture エンドポイント",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "一般設定",
|
||||
@@ -542,11 +560,11 @@
|
||||
"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)、表示名を追加してください。",
|
||||
"hardware": "インフラストラクチャのCPU、メモリ、ディスク使用量、温度を監視します。"
|
||||
"hardware": "インフラの CPU、メモリ、ディスク使用率、温度を監視します。Capture エンドポイント URL には完全なパス /api/v1/metrics を含める必要があります (例: http://192.168.1.10:5001/api/v1/metrics)。認証シークレットにはトークン文字列のみを貼り付けてください — 'Bearer' は含めないでください。"
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -769,6 +787,7 @@
|
||||
"memory": "メモリ使用率",
|
||||
"netBytesRecv": "{{name}} - 受信バイト数",
|
||||
"netBytesSent": "{{name}} - 送信バイト数",
|
||||
"netNoTraffic": "Capture はすべてのインターフェイスでゼロのトラフィックを報告しています。これは Capture がループバックインターフェイスのみを認識できるコンテナ内で実行されている場合に正常です — 実際のネットワークアクティビティを表示するには、ホスト上で Capture を直接実行してください。",
|
||||
"temp": "温度"
|
||||
}
|
||||
},
|
||||
@@ -1240,7 +1259,8 @@
|
||||
"description": "システムからすべてのモニターを削除します。",
|
||||
"dialog": {
|
||||
"title": "すべてのモニターを削除しますか?",
|
||||
"description": "この操作は元に戻せません。"
|
||||
"description": "この操作は元に戻せません。",
|
||||
"paragraph": "これらのモニターに関連するチェック履歴、インシデント、予定されたメンテナンスウィンドウ、ステータスページもすべて削除されます。データのバックアップを残したい場合は、先にモニターをエクスポートしてください。"
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Convidar membro",
|
||||
"openSite": "Abrir site",
|
||||
"pause": "Pausar",
|
||||
"resume": "Retomar"
|
||||
"resume": "Retomar",
|
||||
"selectAll": "Selecionar todos os monitores",
|
||||
"selectMonitor": "Selecionar {{name}}",
|
||||
"bulkSelected_one": "{{count}} monitor selecionado",
|
||||
"bulkSelected_other": "{{count}} monitores selecionados"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Ativo há",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Infraestrutura",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} monitor pausado",
|
||||
"paused_other": "{{count}} monitores pausados",
|
||||
"resumed_one": "{{count}} monitor retomado",
|
||||
"resumed_other": "{{count}} monitores retomados",
|
||||
"alreadyPaused_one": "O monitor selecionado já está pausado",
|
||||
"alreadyPaused_other": "Todos os monitores selecionados já estão pausados",
|
||||
"alreadyRunning_one": "O monitor selecionado já está em execução",
|
||||
"alreadyRunning_other": "Todos os monitores selecionados já estão em execução"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -534,6 +548,10 @@
|
||||
"wsUrl": {
|
||||
"label": "URL do WebSocket",
|
||||
"placeholder": "wss://exemplo.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Endpoint do Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Configurações gerais",
|
||||
@@ -542,11 +560,11 @@
|
||||
"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.",
|
||||
"hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura."
|
||||
"hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura. A URL do endpoint do Capture deve incluir o caminho completo /api/v1/metrics (por exemplo, http://192.168.1.10:5001/api/v1/metrics). Para a chave de autorização, cole apenas a string do token — não inclua 'Bearer'."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -769,6 +787,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"
|
||||
}
|
||||
},
|
||||
@@ -1236,11 +1255,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": {
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Пригласить участника",
|
||||
"openSite": "Открыть сайт",
|
||||
"pause": "Пауза",
|
||||
"resume": "Продолжить"
|
||||
"resume": "Продолжить",
|
||||
"selectAll": "Выбрать все мониторы",
|
||||
"selectMonitor": "Выбрать {{name}}",
|
||||
"bulkSelected_one": "Выбран {{count}} монитор",
|
||||
"bulkSelected_other": "Выбрано {{count}} мониторов"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Активен",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Инфраструктура",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} монитор приостановлен",
|
||||
"paused_other": "{{count}} мониторов приостановлено",
|
||||
"resumed_one": "{{count}} монитор возобновлен",
|
||||
"resumed_other": "{{count}} мониторов возобновлено",
|
||||
"alreadyPaused_one": "Выбранный монитор уже приостановлен",
|
||||
"alreadyPaused_other": "Все выбранные мониторы уже приостановлены",
|
||||
"alreadyRunning_one": "Выбранный монитор уже запущен",
|
||||
"alreadyRunning_other": "Все выбранные мониторы уже запущены"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -534,6 +548,10 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Эндпоинт Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Основные настройки",
|
||||
@@ -542,11 +560,11 @@
|
||||
"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) и добавьте отображаемое имя.",
|
||||
"hardware": "Мониторинг CPU, памяти, дисков и температуры Вашей инфраструктуры."
|
||||
"hardware": "Отслеживайте использование ЦП, памяти, диска и температуру вашей инфраструктуры. URL эндпоинта Capture должен включать полный путь /api/v1/metrics (например, http://192.168.1.10:5001/api/v1/metrics). В секрете авторизации укажите только строку токена — не включайте 'Bearer'."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -769,6 +787,7 @@
|
||||
"memory": "Использование памяти",
|
||||
"netBytesRecv": "{{name}} - байт получено",
|
||||
"netBytesSent": "{{name}} - байт отправлено",
|
||||
"netNoTraffic": "Capture сообщает о нулевом трафике на всех интерфейсах. Это нормально, когда Capture работает внутри контейнера, который видит только loopback-интерфейсы — запустите Capture непосредственно на хосте, чтобы увидеть реальную сетевую активность.",
|
||||
"temp": "Температура"
|
||||
}
|
||||
},
|
||||
@@ -1237,10 +1256,11 @@
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "Сброс системы",
|
||||
"description": "Удалить все мониторы из Вашей системы.",
|
||||
"description": "Удалить все мониторы из вашей системы.",
|
||||
"dialog": {
|
||||
"title": "Удалить все мониторы?",
|
||||
"description": "Это действие нельзя отменить."
|
||||
"description": "Это действие нельзя отменить.",
|
||||
"paragraph": "Также будут удалены вся история проверок, инциденты, запланированные окна обслуживания и страницы состояния, использовавшие эти мониторы. Сначала экспортируйте мониторы, если хотите сохранить резервную копию их данных."
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "เชิญสมาชิก",
|
||||
"openSite": "เปิดเว็บไซต์",
|
||||
"pause": "หยุดชั่วคราว",
|
||||
"resume": "ดำเนินการต่อ"
|
||||
"resume": "ดำเนินการต่อ",
|
||||
"selectAll": "เลือกจอภาพทั้งหมด",
|
||||
"selectMonitor": "เลือก {{name}}",
|
||||
"bulkSelected_one": "เลือกแล้ว {{count}} จอภาพ",
|
||||
"bulkSelected_other": "เลือกแล้ว {{count}} จอภาพ"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "ทำงานมาแล้ว",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "โครงสร้างพื้นฐาน",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "หยุดจอภาพชั่วคราว {{count}} จอ",
|
||||
"paused_other": "หยุดจอภาพชั่วคราว {{count}} จอ",
|
||||
"resumed_one": "กลับมาทำงานต่อ {{count}} จอ",
|
||||
"resumed_other": "กลับมาทำงานต่อ {{count}} จอ",
|
||||
"alreadyPaused_one": "จอภาพที่เลือกถูกหยุดชั่วคราวแล้ว",
|
||||
"alreadyPaused_other": "จอภาพที่เลือกทั้งหมดถูกหยุดชั่วคราวแล้ว",
|
||||
"alreadyRunning_one": "จอภาพที่เลือกกำลังทำงานอยู่แล้ว",
|
||||
"alreadyRunning_other": "จอภาพที่เลือกทั้งหมดกำลังทำงานอยู่แล้ว"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -534,6 +548,10 @@
|
||||
"wsUrl": {
|
||||
"label": "URL ของ WebSocket",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "ปลายทาง Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "ตั้งค่าทั่วไป",
|
||||
@@ -542,11 +560,11 @@
|
||||
"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) และตั้งชื่อที่แสดง",
|
||||
"hardware": "ตรวจสอบการใช้งาน CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐาน"
|
||||
"hardware": "ตรวจสอบการใช้ CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐานของคุณ URL ของปลายทาง Capture ต้องรวมเส้นทางแบบเต็ม /api/v1/metrics (ตัวอย่าง: http://192.168.1.10:5001/api/v1/metrics) สำหรับรหัสลับสำหรับยืนยันตัวตน ให้วางเฉพาะสตริงโทเค็น — อย่ารวมคำว่า 'Bearer'"
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -769,6 +787,7 @@
|
||||
"memory": "การใช้งานหน่วยความจำ",
|
||||
"netBytesRecv": "{{name}} - ไบต์ที่รับ",
|
||||
"netBytesSent": "{{name}} - ไบต์ที่ส่ง",
|
||||
"netNoTraffic": "Capture รายงานว่าไม่มีการรับส่งข้อมูลบนอินเทอร์เฟซใดๆ เลย ซึ่งเป็นเรื่องปกติเมื่อ Capture ทำงานอยู่ในคอนเทนเนอร์ที่มองเห็นเพียงอินเทอร์เฟซ loopback เท่านั้น — รัน Capture บนโฮสต์โดยตรงเพื่อดูกิจกรรมเครือข่ายจริง",
|
||||
"temp": "อุณหภูมิ"
|
||||
}
|
||||
},
|
||||
@@ -1237,10 +1256,11 @@
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "รีเซ็ตระบบ",
|
||||
"description": "ลบมอนิเตอร์ทั้งหมดออกจากระบบ",
|
||||
"description": "ลบมอนิเตอร์ทั้งหมดจากระบบของคุณ",
|
||||
"dialog": {
|
||||
"title": "ลบมอนิเตอร์ทั้งหมด?",
|
||||
"description": "ไม่สามารถย้อนกลับได้"
|
||||
"description": "การกระทำนี้ไม่สามารถย้อนกลับได้",
|
||||
"paragraph": "ประวัติการตรวจสอบทั้งหมด เหตุการณ์ ช่วงเวลาบำรุงรักษาที่กำหนดไว้ และหน้าสถานะที่ใช้มอนิเตอร์เหล่านี้จะถูกลบไปด้วย ส่งออกมอนิเตอร์ก่อนหากต้องการเก็บสำเนาข้อมูลสำรองไว้"
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Üye davet et",
|
||||
"openSite": "Siteyi aç",
|
||||
"pause": "Duraklat",
|
||||
"resume": "Devam et"
|
||||
"resume": "Devam et",
|
||||
"selectAll": "Tüm monitörleri seç",
|
||||
"selectMonitor": "{{name}}'i seç",
|
||||
"bulkSelected_one": "{{count}} monitör seçildi",
|
||||
"bulkSelected_other": "{{count}} monitör seçildi"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktif süresi",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Altyapı",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} monitör duraklatıldı",
|
||||
"paused_other": "{{count}} monitör duraklatıldı",
|
||||
"resumed_one": "{{count}} monitör sürdürüldü",
|
||||
"resumed_other": "{{count}} monitör sürdürüldü",
|
||||
"alreadyPaused_one": "Seçili monitör zaten duraklatıldı",
|
||||
"alreadyPaused_other": "Seçili tüm monitörler zaten duraklatıldı",
|
||||
"alreadyRunning_one": "Seçili monitör zaten çalışıyor",
|
||||
"alreadyRunning_other": "Seçili tüm monitörler zaten çalışıyor"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -534,6 +548,10 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Capture uç noktası",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Genel ayarlar",
|
||||
@@ -542,11 +560,11 @@
|
||||
"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.",
|
||||
"hardware": "Altyapınız için CPU, bellek, disk kullanımı ve sıcaklığı izleyin."
|
||||
"hardware": "Altyapınızın CPU, bellek, disk kullanımını ve sıcaklığını izleyin. Capture uç nokta URL'si tam yolu /api/v1/metrics içermelidir (ör. http://192.168.1.10:5001/api/v1/metrics). Yetkilendirme anahtarına yalnızca token dizesini yapıştırın — 'Bearer' eklemeyin."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -769,6 +787,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"
|
||||
}
|
||||
},
|
||||
@@ -1236,11 +1255,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": {
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Запросити учасника",
|
||||
"openSite": "Відкрити сайт",
|
||||
"pause": "Призупинити",
|
||||
"resume": "Відновити"
|
||||
"resume": "Відновити",
|
||||
"selectAll": "Вибрати всі монітори",
|
||||
"selectMonitor": "Вибрати {{name}}",
|
||||
"bulkSelected_one": "Вибрано {{count}} монітор",
|
||||
"bulkSelected_other": "Вибрано {{count}} моніторів"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Активний протягом",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Інфраструктура",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} монітор призупинено",
|
||||
"paused_other": "{{count}} моніторів призупинено",
|
||||
"resumed_one": "{{count}} монітор відновлено",
|
||||
"resumed_other": "{{count}} моніторів відновлено",
|
||||
"alreadyPaused_one": "Вибраний монітор уже призупинено",
|
||||
"alreadyPaused_other": "Всі вибрані монітори уже призупинено",
|
||||
"alreadyRunning_one": "Вибраний монітор уже запущено",
|
||||
"alreadyRunning_other": "Всі вибрані монітори уже запущено"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -534,6 +548,10 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Точка доступу Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Загальні налаштування",
|
||||
@@ -542,11 +560,11 @@
|
||||
"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) та додайте відображуване ім'я.",
|
||||
"hardware": "Моніторинг CPU, пам'яті, використання диска та температури Вашої інфраструктури."
|
||||
"hardware": "Відстежуйте використання ЦП, пам'яті, диска та температуру вашої інфраструктури. URL точки доступу Capture має містити повний шлях /api/v1/metrics (наприклад, http://192.168.1.10:5001/api/v1/metrics). У секрет авторизації вставте лише рядок токена — не додавайте 'Bearer'."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -769,6 +787,7 @@
|
||||
"memory": "Використання пам'яті",
|
||||
"netBytesRecv": "{{name}} - Отримано байтів",
|
||||
"netBytesSent": "{{name}} - Надіслано байтів",
|
||||
"netNoTraffic": "Capture повідомляє про нульовий трафік на всіх інтерфейсах. Це нормально, коли Capture працює всередині контейнера, який бачить лише loopback-інтерфейси — запустіть Capture безпосередньо на хості, щоб побачити реальну мережеву активність.",
|
||||
"temp": "Темп."
|
||||
}
|
||||
},
|
||||
@@ -1237,10 +1256,11 @@
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "Скидання системи",
|
||||
"description": "Видалити всі монітори з Вашої системи.",
|
||||
"description": "Видалити всі монітори з вашої системи.",
|
||||
"dialog": {
|
||||
"title": "Видалити всі монітори?",
|
||||
"description": "Це не можна скасувати."
|
||||
"description": "Цю дію не можна скасувати.",
|
||||
"paragraph": "Також будуть видалені вся історія перевірок, інциденти, заплановані вікна обслуговування та сторінки статусу, які використовували ці монітори. Спочатку експортуйте монітори, якщо хочете зберегти резервну копію їхніх даних."
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Mời thành viên",
|
||||
"openSite": "Mở trang web",
|
||||
"pause": "Tạm dừng",
|
||||
"resume": "Tiếp tục"
|
||||
"resume": "Tiếp tục",
|
||||
"selectAll": "Chọn tất cả màn hình",
|
||||
"selectMonitor": "Chọn {{name}}",
|
||||
"bulkSelected_one": "Đã chọn {{count}} màn hình",
|
||||
"bulkSelected_other": "Đã chọn {{count}} màn hình"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Hoạt động từ",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Hạ tầng",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "Đã tạm dừng {{count}} màn hình",
|
||||
"paused_other": "Đã tạm dừng {{count}} màn hình",
|
||||
"resumed_one": "Đã tiếp tục {{count}} màn hình",
|
||||
"resumed_other": "Đã tiếp tục {{count}} màn hình",
|
||||
"alreadyPaused_one": "Màn hình đã chọn đã được tạm dừng",
|
||||
"alreadyPaused_other": "Tất cả các màn hình đã chọn đã được tạm dừng",
|
||||
"alreadyRunning_one": "Màn hình đã chọn đã đang chạy",
|
||||
"alreadyRunning_other": "Tất cả các màn hình đã chọn đã đang chạy"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -534,6 +548,10 @@
|
||||
"wsUrl": {
|
||||
"label": "URL WebSocket",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Điểm cuối Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Cài đặt chung",
|
||||
@@ -542,11 +560,11 @@
|
||||
"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ị.",
|
||||
"hardware": "Giám sát CPU, bộ nhớ, dung lượng ổ đĩa và nhiệt độ cho hạ tầng của bạn."
|
||||
"hardware": "Giám sát mức sử dụng CPU, bộ nhớ, ổ đĩa và nhiệt độ của hạ tầng. URL điểm cuối Capture phải bao gồm đường dẫn đầy đủ /api/v1/metrics (ví dụ: http://192.168.1.10:5001/api/v1/metrics). Đối với khóa xác thực, chỉ dán chuỗi token — không bao gồm 'Bearer'."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -769,6 +787,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 độ"
|
||||
}
|
||||
},
|
||||
@@ -1237,10 +1256,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": {
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "邀请成员",
|
||||
"openSite": "打开站点",
|
||||
"pause": "暂停",
|
||||
"resume": "恢复"
|
||||
"resume": "恢复",
|
||||
"selectAll": "选择所有监视器",
|
||||
"selectMonitor": "选择 {{name}}",
|
||||
"bulkSelected_one": "已选择 {{count}} 个监视器",
|
||||
"bulkSelected_other": "已选择 {{count}} 个监视器"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "已活跃",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "基础设施",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "已暂停 {{count}} 个监视器",
|
||||
"paused_other": "已暂停 {{count}} 个监视器",
|
||||
"resumed_one": "已恢复 {{count}} 个监视器",
|
||||
"resumed_other": "已恢复 {{count}} 个监视器",
|
||||
"alreadyPaused_one": "所选监视器已暂停",
|
||||
"alreadyPaused_other": "所有选定的监视器均已暂停",
|
||||
"alreadyRunning_one": "所选监视器已经在运行",
|
||||
"alreadyRunning_other": "所有选定的监视器均已在运行"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -534,6 +548,10 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Capture 端点",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "常规设置",
|
||||
@@ -542,11 +560,11 @@
|
||||
"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),并添加显示名称。",
|
||||
"hardware": "监控基础设施的 CPU、内存、磁盘使用率和温度。"
|
||||
"hardware": "监控基础设施的 CPU、内存、磁盘使用情况和温度。Capture 端点 URL 必须包含完整路径 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授权密钥只需粘贴令牌字符串——请勿包含 'Bearer'。"
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -769,6 +787,7 @@
|
||||
"memory": "内存使用率",
|
||||
"netBytesRecv": "{{name}} - 接收字节",
|
||||
"netBytesSent": "{{name}} - 发送字节",
|
||||
"netNoTraffic": "Capture 报告所有接口的流量均为零。当 Capture 在只能看到回环接口的容器内运行时,这属于正常情况——请直接在主机上运行 Capture 才能看到真实的网络活动。",
|
||||
"temp": "温度"
|
||||
}
|
||||
},
|
||||
@@ -1237,10 +1256,11 @@
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "系统重置",
|
||||
"description": "从系统中移除所有监控。",
|
||||
"description": "从系统中移除所有监视器。",
|
||||
"dialog": {
|
||||
"title": "移除所有监控?",
|
||||
"description": "此操作无法撤销。"
|
||||
"description": "此操作无法撤销。",
|
||||
"paragraph": "所有检查历史、事件、计划维护窗口以及使用这些监控的状态页面也将一并被移除。如需保留数据备份,请先导出监控。"
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "邀請成員",
|
||||
"openSite": "開啟網站",
|
||||
"pause": "暫停",
|
||||
"resume": "恢復"
|
||||
"resume": "恢復",
|
||||
"selectAll": "選擇所有監視器",
|
||||
"selectMonitor": "選擇 {{name}}",
|
||||
"bulkSelected_one": "已選擇 {{count}} 個監視器",
|
||||
"bulkSelected_other": "已選擇 {{count}} 個監視器"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "已啟用",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "基礎架構",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "已暫停 {{count}} 個監視器",
|
||||
"paused_other": "已暫停 {{count}} 個監視器",
|
||||
"resumed_one": "已恢復 {{count}} 個監視器",
|
||||
"resumed_other": "已恢復 {{count}} 個監視器",
|
||||
"alreadyPaused_one": "所選監視器已暫停",
|
||||
"alreadyPaused_other": "所有選定的監視器均已暫停",
|
||||
"alreadyRunning_one": "所選監視器已經在運行",
|
||||
"alreadyRunning_other": "所有選定的監視器均已在運行"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -534,6 +548,10 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Capture 端點",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "一般設定",
|
||||
@@ -542,11 +560,11 @@
|
||||
"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),並新增一個顯示名稱。",
|
||||
"hardware": "監控您基礎架構的 CPU、記憶體、磁碟使用量和溫度。"
|
||||
"hardware": "監控基礎架構的 CPU、記憶體、磁碟使用情況和溫度。Capture 端點 URL 必須包含完整路徑 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授權密鑰僅貼上權杖字串——請勿包含 'Bearer'。"
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -769,6 +787,7 @@
|
||||
"memory": "記憶體使用率",
|
||||
"netBytesRecv": "{{name}} - 接收位元組",
|
||||
"netBytesSent": "{{name}} - 發送位元組",
|
||||
"netNoTraffic": "Capture 回報所有介面的流量皆為零。當 Capture 在只能看到回環介面的容器中執行時,這是正常情況——請直接在主機上執行 Capture 才能看到實際的網路活動。",
|
||||
"temp": "溫度"
|
||||
}
|
||||
},
|
||||
@@ -1236,11 +1255,12 @@
|
||||
"description": "新增範例監控以供示範用途。"
|
||||
},
|
||||
"removeMonitors": {
|
||||
"title": "系統重設",
|
||||
"description": "從系統中移除所有監控。",
|
||||
"title": "系統重置",
|
||||
"description": "從系統中移除所有監視器。",
|
||||
"dialog": {
|
||||
"title": "移除所有監控?",
|
||||
"description": "此操作無法復原。"
|
||||
"description": "此操作無法復原。",
|
||||
"paragraph": "所有檢查歷史、事件、排定的維護時段以及使用這些監控的狀態頁面也將一併被移除。如需保留資料備份,請先匯出監控。"
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
|
||||
@@ -18,8 +18,6 @@ services:
|
||||
image: mongo:6.0
|
||||
restart: always
|
||||
command: ["mongod", "--quiet", "--bind_ip_all"]
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- ./mongo/data:/data/db
|
||||
healthcheck:
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
# Coding conventions
|
||||
|
||||
Style and structural rules the codebase follows. Frontend rules are extracted from review feedback on PR series #3591–#3596; backend rules are extracted from the provider/repository/service layering used across `server/src/service/infrastructure/network/*` and the patterns enforced in code review. These are non-negotiable for new code. They exist to keep the codebase greppable, maintainable, and consistent.
|
||||
|
||||
> If you're touching a file in `client/src` the **frontend** rules apply.
|
||||
> If you're touching a file in `server/src` the **backend** rules apply.
|
||||
> Rule 0 applies everywhere.
|
||||
|
||||
---
|
||||
|
||||
## 0. Look at existing implementations and follow the established pattern
|
||||
|
||||
Before writing new code, find one or two files that already do something similar — a peer provider for a new monitor type, a peer page for a new admin screen, a peer repository method for a new query — and follow the same shape. Match imports, helper-function placement, naming, error handling, mock-construction style, and assertion idioms. If you're tempted to do it differently, the burden is on you to justify the divergence.
|
||||
|
||||
**Why:** consistency is more valuable than locally-optimal cleverness. A reader who has internalized the pattern of one peer file should be able to read the new one with no extra mental load. New shapes also make refactors more expensive — when someone wants to change the convention later, every variant has to be visited and reasoned about separately.
|
||||
|
||||
**How to apply:** before writing a new component, page, provider, repository, or test file:
|
||||
|
||||
1. Grep for the closest existing peers (e.g. `WebSocketProvider.ts` if you're writing a new monitor provider).
|
||||
2. Read at least two of them end-to-end.
|
||||
3. Mirror their structure: same constants at the top (`SERVICE_NAME`, `TIMEOUT_MS`), same import order, same DI shape, same try/catch envelope, same test-file location and helper imports.
|
||||
4. If your new code needs a shape no peer has, name it explicitly in the PR description and link to the peer you diverged from. Don't sneak novelty in.
|
||||
|
||||
**Concrete check before opening a PR:** can a reader open a peer file side-by-side with yours and trace each section in the same order? If not, restructure.
|
||||
|
||||
---
|
||||
|
||||
# Part 1 — Frontend (`client/src`)
|
||||
|
||||
## 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).
|
||||
|
||||
---
|
||||
|
||||
# Part 2 — Backend (`server/src`)
|
||||
|
||||
## 6. Layering: Controller → Service → Repository → Mongoose
|
||||
|
||||
The backend enforces a strict three-layer separation between HTTP handling, business logic, and data access. Every new feature follows this path, no shortcuts.
|
||||
|
||||
```
|
||||
Request → Controller → Service → Repository → MongoDB (Mongoose)
|
||||
```
|
||||
|
||||
**Why:** this is what makes each layer independently testable. Mongoose calls in services force services to mock the database in tests; raw DB queries in controllers couple HTTP shape to schema shape and turn every refactor into a multi-file rewrite.
|
||||
|
||||
**How to apply:** when adding a new feature, the pattern is — **add a repository method** for any new DB query, **call it from a service**, **expose it via a controller**. If a service constructs a `MonitorModel.findOne({...})`, you've broken the rule; the query belongs in the repository.
|
||||
|
||||
- **Controllers** (`server/src/controllers/`) handle HTTP concerns only: parse params, call the service, return via the `responseHandler` middleware. No business logic.
|
||||
- **Services** (`server/src/service/business/`) contain all business logic. They throw `AppError` on validation/auth failures; they do not write directly to MongoDB.
|
||||
- **Repositories** (`server/src/repositories/`) are the sole layer that talks to MongoDB. They expose clean query methods (`findByMonitorId`, `createCheck`, `updateStatusWindowAndChecks`) and return entities — never Mongoose documents — via a `toEntity` private helper.
|
||||
|
||||
---
|
||||
|
||||
## 7. Provider conventions for `service/infrastructure/network/*`
|
||||
|
||||
Every monitor-type provider implements `IStatusProvider<TPayload>` and follows the same skeleton.
|
||||
|
||||
**Why:** the registry in `services.ts` builds providers identically (`new XxxProvider(deps)`) and `NetworkService` invokes them identically (`provider.handle(monitor)`). If your provider's shape diverges, the registry/dispatch becomes asymmetric and `NetworkService` grows special cases.
|
||||
|
||||
**How to apply:** copy the structure of a recent peer (`WebSocketProvider`, `GrpcProvider`, `PortProvider`). The skeleton:
|
||||
|
||||
```ts
|
||||
const SERVICE_NAME = "XxxProvider";
|
||||
const TIMEOUT_MS = 10000;
|
||||
|
||||
type ClientCtor = typeof SomeNodeStdlibClass;
|
||||
|
||||
export class XxxProvider implements IStatusProvider<XxxStatusPayload> {
|
||||
readonly type = "xxx";
|
||||
|
||||
constructor(private Client: ClientCtor) {}
|
||||
|
||||
supports(type: MonitorType): boolean {
|
||||
return type === "xxx";
|
||||
}
|
||||
|
||||
async handle(monitor: Monitor): Promise<MonitorStatusResponse<XxxStatusPayload>> {
|
||||
try {
|
||||
// 1. validate inputs → throw AppError(400) on bad input
|
||||
// 2. set up the client (use this.Client, never imported singleton)
|
||||
// 3. perform the call inside timeRequest(...), with a setTimeout race
|
||||
// in the Promise body to enforce TIMEOUT_MS
|
||||
// 4. return MonitorStatusResponse with payload
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof AppError) throw err;
|
||||
const originalMessage = err instanceof Error ? err.message : String(err);
|
||||
throw new AppError({
|
||||
message: originalMessage || "Error performing xxx check",
|
||||
status: 500,
|
||||
service: SERVICE_NAME,
|
||||
method: "handle",
|
||||
details: { /* monitor-shaped context */ },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hard rules
|
||||
|
||||
- **DI for all external dependencies.** `typeof WebSocket`, `typeof net` — pass the constructor in via the provider's constructor. Never `import { Foo } from "some/library"` and call it inside `handle()`. This is the single thing that makes the provider unit-testable without `jest.unstable_mockModule`.
|
||||
- **`SERVICE_NAME` and `TIMEOUT_MS`** as module-level constants near the top, not magic strings/numbers in the body.
|
||||
- **Use the shared `timeRequest()` helper** from `service/infrastructure/network/utils.ts` to measure latency. Don't roll your own `process.hrtime()`.
|
||||
- **Enforce a hard timeout.** Every peer provider inlines a `setTimeout` race in the Promise body around the underlying client call (see `WebSocketProvider.ts`); sockets, DNS resolvers, and external clients can hang indefinitely without it. There is no shared `withTimeout` helper today — match the inline pattern.
|
||||
- **Outer try/catch** that converts unknown errors to `AppError({ service, method, details })` while letting your own `AppError` pass through.
|
||||
|
||||
---
|
||||
|
||||
## 8. Mongoose schema fields with closed value sets
|
||||
|
||||
If a field's TypeScript type is a closed union (status, type, record type), the Mongoose schema must declare the same set as `enum`.
|
||||
|
||||
**Why:** the validator and the schema are independent gates. A service-layer Zod check can be bypassed (imports, migrations, direct repo writes). A schema `enum` is the last line of defense and surfaces invalid data as a Mongoose validation error rather than silently accepting `"badtype"` and breaking downstream consumers.
|
||||
|
||||
**How to apply:** when you add a string field whose values are constrained by a const tuple in `types/`, reuse the same const for the schema enum:
|
||||
|
||||
```ts
|
||||
// types/monitor.ts (existing)
|
||||
export const MonitorMatchMethods = ["equal", "include", "regex"] as const;
|
||||
|
||||
// db/models/Monitor.ts
|
||||
{
|
||||
matchMethod: {
|
||||
type: String,
|
||||
enum: MonitorMatchMethods,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The same applies to `MonitorTypes`, `MonitorStatuses`, and any new closed-set field.
|
||||
|
||||
---
|
||||
|
||||
## 9. Test conventions for `server/test/unit/providers/network/*`
|
||||
|
||||
Each provider gets a unit test file at `server/test/unit/providers/network/<providerName>.test.ts`. Follow the structure used by `webSocketProvider.test.ts`, `grpcProvider.test.ts`, `portProvider.test.ts`.
|
||||
|
||||
**Why:** consistency lets reviewers diff a new test file against any peer and only see the actual logic differences, not framework/import noise. It also lets the `testStatusProviderContract` helper guarantee every provider satisfies the `IStatusProvider` contract.
|
||||
|
||||
**How to apply:**
|
||||
|
||||
- **Imports**: `from "@jest/globals"` (not the global `jest`), and `.ts` extensions on every relative import.
|
||||
- **Contract test**: call `testStatusProviderContract("XxxProvider", { create, supportedType, unsupportedType, makeMonitor })` before the per-test `describe` block.
|
||||
- **Mock construction**: build the constructor mock as `jest.fn().mockImplementation(() => mock) as unknown as typeof Client` (typed cast, not `as any`).
|
||||
- **`makeMonitor` factory**: takes `Partial<Monitor>` overrides, spreads onto a baseline, returns `as Monitor`.
|
||||
- **Inline `setup()` per test** rather than `beforeEach` shared state. Each test should construct its own mock + provider, so a single test can be read in isolation:
|
||||
|
||||
```ts
|
||||
const setup = () => {
|
||||
const mock = createMockClient();
|
||||
const Ctor = jest.fn().mockImplementation(() => mock) as unknown as typeof Client;
|
||||
const provider = new XxxProvider(Ctor);
|
||||
return { mock, provider };
|
||||
};
|
||||
|
||||
it("does X", async () => {
|
||||
const { mock, provider } = setup();
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
- **Assertion style**: individual `expect(result.field).toBe(...)` lines or `expect(result).toEqual(expect.objectContaining({...}))`. Both are used; pick what reads cleanest for the case.
|
||||
- **Test naming**: lowercase imperative ("returns success when connection opens", "uses the configured timeout", "throws AppError when host is missing").
|
||||
|
||||
---
|
||||
|
||||
## 10. Validation: centralize, don't inline
|
||||
|
||||
Whenever the same string-set or shape constraint appears in two or more validators, lift it into `types/` as a const tuple and `import` it into both validators. Don't inline `z.enum(["A", "AAAA", ...])` in three places.
|
||||
|
||||
**Why:** drift. Three inline copies will eventually disagree — someone adds a new value to one and forgets the other two. The validator silently accepts it on `POST /monitors` and rejects it on `PATCH`, leading to bug reports the author can't reproduce.
|
||||
|
||||
**How to apply:** keep validation source-of-truth in `server/src/types/*.ts` as paired const tuples + derived types (rule 5). The validators in `server/src/validation/*.ts` and the Mongoose schemas in `server/src/db/models/*.ts` import from `types/` and reuse the same tuple.
|
||||
|
||||
---
|
||||
|
||||
## 11. OpenAPI: response schemas live in the validator file
|
||||
|
||||
The OpenAPI `monitorObject` (and friends) are generated from a `monitorResponseSchema` Zod object exported from the validator file, not hand-built in `server/openapi/routes/`. When you add a field to the data model, add it to the response schema in the same edit.
|
||||
|
||||
**Why:** the spec is auto-generated. If the response schema doesn't list a field, SDK consumers don't see it, even though the server returns it. That's a silent contract gap.
|
||||
|
||||
**How to apply:** when you add an entity field, update three files in the same PR:
|
||||
|
||||
1. `types/<entity>.ts` — add the field to the TS interface.
|
||||
2. `db/models/<Entity>.ts` — add the Mongoose schema entry (with `enum` if the value set is closed; rule 8).
|
||||
3. `validation/<entity>Validation.ts` — add the field to the response schema (and create/edit body schemas if it's user-settable).
|
||||
|
||||
The repository's `toEntity` helper also needs to surface the new field; that's part of the change, not a separate one.
|
||||
|
||||
---
|
||||
|
||||
# Pre-PR checklist
|
||||
|
||||
Before opening a PR, grep your diff for the patterns below. Each item describes the **anti-pattern** to find and fix; if your diff has none, that line passes. Frontend section applies to `client/src`; backend to `server/src`.
|
||||
|
||||
## Universal
|
||||
- [ ] I can name the peer file I used as a template, and my file's structure mirrors it (rule 0).
|
||||
- [ ] `npm run build` and `npm run format-check` are clean in both `client/` and `server/`.
|
||||
|
||||
## Frontend (`client/src`)
|
||||
- [ ] No `sx={{ … }}` block containing keys with a native-prop equivalent (rule 1).
|
||||
- [ ] No `color="text.X"`, `bgcolor="background.X"`, or other short-string palette paths (rule 2).
|
||||
- [ ] No raw number or px-string in spacing / margin / padding / fontSize / borderRadius props that should be a token (rule 3).
|
||||
- [ ] No `import { theme } from "@/Utils/Theme/Theme"` inside a component (rule 4).
|
||||
- [ ] No `type X = "a" | "b" | "c"` for a closed set used at runtime (rule 5).
|
||||
|
||||
## Backend (`server/src`)
|
||||
- [ ] No `MonitorModel.findOne` (or similar Mongoose call) outside `repositories/` (rule 6).
|
||||
- [ ] No provider that imports its underlying client at module top instead of receiving it via constructor DI (rule 7).
|
||||
- [ ] No provider missing `SERVICE_NAME` / `TIMEOUT_MS` / `AppError` outer catch / `timeRequest()` / inline timeout (rule 7).
|
||||
- [ ] No closed-set string field on a Mongoose schema without an `enum` (rule 8).
|
||||
- [ ] No provider test that uses `beforeEach` instead of inline `setup()` (rule 9).
|
||||
- [ ] No duplicated `z.enum([...])` literal that should reference a `types/*` const tuple (rule 10).
|
||||
- [ ] No new entity field missing from `monitorResponseSchema` (or peer response schema) for OpenAPI (rule 11).
|
||||
|
||||
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
|
||||
+10530
-4399
File diff suppressed because it is too large.
Load diff
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user