Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5766a55a9 | ||
|
|
c5902b397a | ||
|
|
e52ec2d6ee | ||
|
|
a2c978527f | ||
|
|
224dc60309 | ||
|
|
d8e4c775d5 | ||
|
|
a07d0059b7 | ||
|
|
1a48d303f7 | ||
|
|
5bb9ca970c | ||
|
|
2947b558a2 | ||
|
|
98a5fa9941 | ||
|
|
712350d669 | ||
|
|
1a287be323 | ||
|
|
014750f91e | ||
|
|
574078bdc8 | ||
|
|
7ab80ac3f4 | ||
|
|
a258910ccf | ||
|
|
e15a85bd61 | ||
|
|
ced90211a0 | ||
|
|
4ae8426766 | ||
|
|
32a5ca8799 | ||
|
|
5d77b8804f | ||
|
|
544502c6e5 | ||
|
|
1ef540679b | ||
|
|
18f38818e2 | ||
|
|
02e6333c3c | ||
|
|
cb47439e9f | ||
|
|
4bbfe2e889 | ||
|
|
187a0fc3d4 | ||
|
|
4e4d3c3971 | ||
|
|
6267e08a51 | ||
|
|
f2c23636fe | ||
|
|
11c89d150a | ||
|
|
6b75f9bf7c | ||
|
|
91de9e7fb7 | ||
|
|
21fb320f3a | ||
|
|
3586ac9437 | ||
|
|
b0ed75382e | ||
|
|
b1646cd83b | ||
|
|
7abc74ac98 | ||
|
|
a1b10c7469 | ||
|
|
0d75629dcb | ||
|
|
849ea26cf9 | ||
|
|
4a66cf6e11 | ||
|
|
41c7cfbdaa | ||
|
|
7fb57d8090 | ||
|
|
618258d282 | ||
|
|
e50568dba3 | ||
|
|
5fa894cb79 | ||
|
|
6d4e944a58 | ||
|
|
cfe7d2a359 | ||
|
|
5fb26367ed | ||
|
|
cd65488cbd | ||
|
|
4ea6539a7d | ||
|
|
a75bb5fae6 | ||
|
|
380968472f | ||
|
|
0cb48596a0 | ||
|
|
22d0123966 | ||
|
|
2a45b4d46f | ||
|
|
bdd4634a3b | ||
|
|
a5e66318b5 | ||
|
|
7ebbb05482 | ||
|
|
99c62961cf | ||
|
|
40a9f3f1e1 | ||
|
|
b37e4d9083 | ||
|
|
37dd9fad26 | ||
|
|
0f51d68ff0 | ||
|
|
dfc048522d | ||
|
|
1f9072a905 | ||
|
|
780ed8147b | ||
|
|
b2cfc60387 | ||
|
|
c8f84e9e20 | ||
|
|
9434cd8929 | ||
|
|
a277a89bef | ||
|
|
55184724d0 | ||
|
|
de4c528d55 | ||
|
|
d5e887903e | ||
|
|
6196b81fe4 | ||
|
|
c3598a67fa | ||
|
|
2c896a5506 | ||
|
|
97e23e4b3a | ||
|
|
be39ad0bfe | ||
|
|
7f0e577dd2 | ||
|
|
f308325bd6 | ||
|
|
c5303add56 | ||
|
|
fe6f755451 | ||
|
|
4a2429d316 | ||
|
|
d7f8bcc9e1 | ||
|
|
d51fdbd772 | ||
|
|
199d68e8a2 | ||
|
|
461a5e35dd | ||
|
|
8dd57d45e2 | ||
|
|
01e9655f42 | ||
|
|
d4d2735d23 | ||
|
|
253642dde9 | ||
|
|
9d0dc3f330 | ||
|
|
2f3de9797d | ||
|
|
1b3b8e5312 | ||
|
|
b55560e2f1 | ||
|
|
57ddbc474f | ||
|
|
862ff15eef | ||
|
|
da8b230cec | ||
|
|
13799af721 | ||
|
|
4fcb13694b | ||
|
|
3dc529733a | ||
|
|
d865103fb5 | ||
|
|
4335e6b7b6 | ||
|
|
4f5dba560d | ||
|
|
f52acb3f68 | ||
|
|
f4864b5455 | ||
|
|
f299098548 | ||
|
|
87eab6f996 | ||
|
|
31400a2502 | ||
|
|
fa0c0abcac | ||
|
|
9102335d3e | ||
|
|
011b93ecc1 | ||
|
|
20ce11497e | ||
|
|
50d0721822 | ||
|
|
c66872c7f4 | ||
|
|
b1be38368c | ||
|
|
278f652ea9 | ||
|
|
d3e12722b7 | ||
|
|
8c9ff41074 | ||
|
|
71a1194373 | ||
|
|
580a025353 | ||
|
|
33994cb3df | ||
|
|
44edf320a0 | ||
|
|
fea2ef4dbe | ||
|
|
360b4b8035 | ||
|
|
3857a71e62 | ||
|
|
b68367b04f | ||
|
|
08f104e283 | ||
|
|
46eb0b4063 | ||
|
|
2632f98ad0 | ||
|
|
84539f76cf | ||
|
|
308672d558 | ||
|
|
0f474d82d6 | ||
|
|
650cfe00ce | ||
|
|
69eb9957c6 | ||
|
|
ec01a5dfe4 | ||
|
|
b2ace1bec5 | ||
|
|
5da472da14 | ||
|
|
4bb366f5ab | ||
|
|
cc4b8b38f5 | ||
|
|
f41e0f0619 | ||
|
|
3f10b6e567 | ||
|
|
9ad20daa51 | ||
|
|
86b59da909 | ||
|
|
6a9068a151 | ||
|
|
b31ffa5318 | ||
|
|
71dd998390 | ||
|
|
102a9f1484 | ||
|
|
581ec9382b | ||
|
|
cebe9350f7 | ||
|
|
406518e0a5 | ||
|
|
c458d5fb65 | ||
|
|
ba0d685c6d | ||
|
|
912f1f960d | ||
|
|
1de0edf7e3 | ||
|
|
8c9cb4be77 | ||
|
|
727b904ca9 | ||
|
|
5c0279a6a7 | ||
|
|
9d72d20dd2 | ||
|
|
62d0630702 | ||
|
|
2260b4a4d2 | ||
|
|
cfb50afe42 | ||
|
|
b14b2142e3 | ||
|
|
896d73f12e | ||
|
|
c64c4cc5ff | ||
|
|
7071de294f | ||
|
|
5eb16fdbdb | ||
|
|
131fd1a6b0 | ||
|
|
f9bc02ed1c | ||
|
|
1035e397e0 | ||
|
|
9c788561a7 | ||
|
|
449e96d842 | ||
|
|
c7626c669a | ||
|
|
ede5bc4ecd | ||
|
|
8bb60bfd03 | ||
|
|
f009aa17d9 | ||
|
|
ef7ac8bef8 | ||
|
|
b75f895a1f | ||
|
|
53f5b61e8e | ||
|
|
64a2c69540 | ||
|
|
c4ec14db5d | ||
|
|
4560820649 | ||
|
|
3051bc7edc | ||
|
|
22232489a8 | ||
|
|
04277a6eb4 | ||
|
|
a8dc867e78 | ||
|
|
533783ba3e | ||
|
|
fd7b3e5f20 | ||
|
|
ae4b851620 | ||
|
|
e83f97a05a | ||
|
|
070682af74 | ||
|
|
8609e4bca0 | ||
|
|
2661d7a384 | ||
|
|
5b9d554f46 | ||
|
|
7fb8bab4be | ||
|
|
75d29124b6 | ||
|
|
1f0ea6cedc | ||
|
|
07a38b9c2c | ||
|
|
67806c94b1 | ||
|
|
26c0628f34 | ||
|
|
4bbc84ac48 | ||
|
|
2eeb088b61 | ||
|
|
7862b3c6f9 | ||
|
|
02b0daa37d | ||
|
|
1492569b73 | ||
|
|
a545b81569 | ||
|
|
ce9dce48b3 | ||
|
|
7fcdcc0568 | ||
|
|
9eea53aa5f | ||
|
|
bd7b6038f5 | ||
|
|
e2362ca809 | ||
|
|
9a541dac21 | ||
|
|
71fed33ecd | ||
|
|
074b94b061 | ||
|
|
c1af8995e4 | ||
|
|
431cada08e | ||
|
|
e012910332 | ||
|
|
7dbc7b3064 | ||
|
|
1b97539eae | ||
|
|
6e0afe223e | ||
|
|
37e1005479 | ||
|
|
795e1caca4 | ||
|
|
a5bb186814 | ||
|
|
bee3d4221e | ||
|
|
f7e1793bac | ||
|
|
9a8203b0d8 | ||
|
|
24d4595bb1 | ||
|
|
6587ddf389 | ||
|
|
9b939f2d94 | ||
|
|
530eeb0110 | ||
|
|
da0d2a47b4 | ||
|
|
700baf2061 | ||
|
|
de8eb53dd4 | ||
|
|
dbe0e2f4e3 | ||
|
|
3ddc6ef1cb | ||
|
|
ac94ae0fdd | ||
|
|
acbf26b03d | ||
|
|
f931954997 | ||
|
|
2cfc2915ce | ||
|
|
d965a82cc9 | ||
|
|
62fb17f64c | ||
|
|
8582958ad5 | ||
|
|
1372e66749 | ||
|
|
bdad949170 | ||
|
|
e69a2f05af | ||
|
|
9c796edbde | ||
|
|
84a1b994a1 | ||
|
|
db4689207a | ||
|
|
af465766c8 | ||
|
|
6ea4ca1aa9 | ||
|
|
c9b99eabb4 | ||
|
|
3e06cd39b1 | ||
|
|
efcc7829d4 | ||
|
|
77974682fb | ||
|
|
a1c54997bb | ||
|
|
5657d39589 | ||
|
|
cdd4953e03 | ||
|
|
786c803847 | ||
|
|
c13f26a4c3 | ||
|
|
56153b38a5 | ||
|
|
f85ed902e4 | ||
|
|
8ccc7cbe9a | ||
|
|
d347e29a28 | ||
|
|
f10fb452ba | ||
|
|
fc9d2fe69d | ||
|
|
ca896fa22f | ||
|
|
a7ee90e1ee | ||
|
|
06190a885a | ||
|
|
5f07860217 | ||
|
|
073635d786 | ||
|
|
af1d964e44 | ||
|
|
3b6b7a1e5d | ||
|
|
bc44a3baeb | ||
|
|
4e4bc5f319 | ||
|
|
2fec09df62 | ||
|
|
13e49c518d | ||
|
|
a1f9c0304f | ||
|
|
d158bcdce1 | ||
|
|
c45a21b4c8 | ||
|
|
a0738dff63 | ||
|
|
3cc4db9af5 | ||
|
|
94ecbfe365 | ||
|
|
5343241036 | ||
|
|
261fdd08f1 | ||
|
|
5470f44ebb | ||
|
|
b24e42e516 | ||
|
|
09d20c1bc7 | ||
|
|
d59bdf6c0c | ||
|
|
9d0f0116f3 | ||
|
|
3e56dc63a9 | ||
|
|
30881a0af1 | ||
|
|
ebbf1a8b1e | ||
|
|
9a6602b9e1 | ||
|
|
46211ac61d | ||
|
|
791a0c8483 | ||
|
|
e964234f54 | ||
|
|
e60bfd18ff | ||
|
|
560d64a482 | ||
|
|
c8826d90d5 | ||
|
|
cacedfd7d8 | ||
|
|
4ec03dfd04 | ||
|
|
78d3b23e93 | ||
|
|
1a1dc7d807 | ||
|
|
03174d98dd | ||
|
|
686ded2137 | ||
|
|
9c8bf4bb05 | ||
|
|
8f6c909acd | ||
|
|
1ca38a31c9 | ||
|
|
9ffcbd1242 | ||
|
|
840e12eb9a | ||
|
|
96b2266a9d | ||
|
|
80073af80a | ||
|
|
0c9334071f | ||
|
|
e444d5fbe6 | ||
|
|
e9dca23664 | ||
|
|
7fb0e9dc70 | ||
|
|
de148f9773 |
No files matched your search
+1
-1
@@ -1 +1 @@
|
|||||||
* @ajhollid @mohicody @Br0wnHammer @Owaiseimdad @karenvicent @shanikauwu1
|
|
||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
working-directory: client
|
working-directory: client
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
working-directory: client
|
working-directory: client
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ VITE_APP_LOG_LEVEL="debug"
|
|||||||
|
|
||||||
### Monorepo Structure
|
### Monorepo Structure
|
||||||
- `/client` - React 18 + TypeScript + Vite + MUI frontend
|
- `/client` - React 18 + TypeScript + Vite + MUI frontend
|
||||||
- `/server` - Node.js 20+ + Express + TypeScript backend
|
- `/server` - Node.js 24+ + Express + TypeScript backend
|
||||||
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
|
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
|
||||||
|
|
||||||
### Backend Layers
|
### Backend Layers
|
||||||
@@ -109,7 +109,7 @@ client/src/
|
|||||||
- **Data Fetching**: SWR + Axios
|
- **Data Fetching**: SWR + Axios
|
||||||
- **Database**: MongoDB with Mongoose ODM
|
- **Database**: MongoDB with Mongoose ODM
|
||||||
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
|
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
|
||||||
- **i18n**: i18next + react-i18next (translations via PoEditor)
|
- **i18n**: i18next + react-i18next (non-English locales are machine translated, not managed through a translation service)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,7 @@
|
|||||||
|
|
||||||
[](https://www.pikapods.com/pods?run=checkmate)
|
[](https://www.pikapods.com/pods?run=checkmate)
|
||||||
|
|
||||||
|
<img width="1461" height="853" alt="image" src="https://github.com/user-attachments/assets/d5ee1a4e-9db9-47cc-ad47-e1ba88a9d796" />
|
||||||
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
|
|
||||||
</p>
|
</p>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
@@ -93,7 +92,7 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
|
|||||||
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
|
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
|
||||||
|
|
||||||
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
|
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
|
||||||
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
|
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [Easypanel](https://easypanel.io/templates/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate). Note that the Helm chart has not yet been migrated to the all-in-one image: it still deploys the legacy `checkmate-client`, `checkmate-backend` and `checkmate-mongo` images, pinned at v3.8.1.
|
||||||
|
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
@@ -105,7 +104,15 @@ The image is configured entirely through environment variables on the server con
|
|||||||
| `DB_CONNECTION_STRING` | Yes | MongoDB connection string, e.g. `mongodb://mongodb:27017/uptime_db` |
|
| `DB_CONNECTION_STRING` | Yes | MongoDB connection string, e.g. `mongodb://mongodb:27017/uptime_db` |
|
||||||
| `JWT_SECRET` | Yes | Secret used to sign auth tokens; generate one with `openssl rand -hex 32` |
|
| `JWT_SECRET` | Yes | Secret used to sign auth tokens; generate one with `openssl rand -hex 32` |
|
||||||
| `CLIENT_HOST` | Yes | The URL users reach the app at, e.g. `https://checkmate.example.com`; used for CORS and for links in notifications and emails |
|
| `CLIENT_HOST` | Yes | The URL users reach the app at, e.g. `https://checkmate.example.com`; used for CORS and for links in notifications and emails |
|
||||||
|
| `ENCRYPTION_KEY` | No | Encrypts stored Docker TLS client keys at rest; generate one with `openssl rand -base64 32`. Comma-separated list: the first key encrypts, every key decrypts. Must be identical on the API and every worker. To rotate without downtime, deploy `OLD_KEY,NEW_KEY` everywhere, then `NEW_KEY,OLD_KEY` everywhere, wait for the worker to re-encrypt every row, then drop `OLD_KEY`. |
|
||||||
|
| `PORT` | No | Port the API and web client are served on (default `52345`) |
|
||||||
|
| `HEALTH_PORT` | No | Port for the `/livez`, `/readyz` and `/metrics` endpoints, served by any process that runs the job worker (default `52346`) |
|
||||||
|
| `NODE_ENV` | No | `development`, `production` or `test` (default `development`). `development` disables the general API rate limiter; set `production` on real deployments |
|
||||||
| `LOG_LEVEL` | No | Server log level: `error`, `warn`, `info`, or `debug` (default `debug`) |
|
| `LOG_LEVEL` | No | Server log level: `error`, `warn`, `info`, or `debug` (default `debug`) |
|
||||||
|
| `TOKEN_TTL` | No | Lifetime of issued auth tokens, e.g. `12h` or `7d` (default `99d`) |
|
||||||
|
| `QUEUE_MODE` | No | `primary` (default) runs the API, the web client and the job scheduler; `worker` runs a job-processing worker only, with no API |
|
||||||
|
| `QUEUE_PRIMARY_PROCESSES` | No | `true` (default) or `false`. Whether a `primary` node also processes monitoring jobs itself; set `false` when dedicated `worker` nodes handle all checks. Ignored in `worker` mode |
|
||||||
|
| `STATUS_PAGE_THEMES_ENABLED` | No | `true` (default) or `false`. When `false`, status pages ignore theme settings and always render the default theme |
|
||||||
|
|
||||||
The web client needs no configuration by default: it calls the API on the same origin it was served from (`/api/v1`). For setups where the defaults don't apply — for example, the API is reached through a different origin than the page — the server renders overrides into the client at runtime via these optional variables:
|
The web client needs no configuration by default: it calls the API on the same origin it was served from (`/api/v1`). For setups where the defaults don't apply — for example, the API is reached through a different origin than the page — the server renders overrides into the client at runtime via these optional variables:
|
||||||
|
|
||||||
@@ -119,12 +126,122 @@ The web client needs no configuration by default: it calls the API on the same o
|
|||||||
|
|
||||||
See full installation instructions in the [Checkmate documentation portal](https://checkmate.so/docs).
|
See full installation instructions in the [Checkmate documentation portal](https://checkmate.so/docs).
|
||||||
|
|
||||||
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
|
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) (legacy images, pinned at v3.8.1), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
|
||||||
|
|
||||||
### Using a Custom CA
|
### Using a Custom CA
|
||||||
|
|
||||||
If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](./docs/custom-ca-trust.md) for Docker configuration options.
|
If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](./docs/custom-ca-trust.md) for Docker configuration options.
|
||||||
|
|
||||||
|
### Docker monitors
|
||||||
|
|
||||||
|
A Docker monitor reports on every container running on a host. It can connect to the Docker daemon directly or read container metrics from a Capture agent. Direct monitors use the daemon's ping response for availability and record container state, health, CPU and memory usage, restart count, published ports and mounts. Enabling **Collect container logs** additionally stores the latest 200 log lines per container on every direct check; logs are kept for 7 days.
|
||||||
|
|
||||||
|
The **Docker host** field accepts three forms:
|
||||||
|
|
||||||
|
| Host | Example | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Local socket | `unix:///var/run/docker.sock` | Also accepts a bare absolute path such as `/var/run/docker.sock`. Use this for the daemon on the same machine Checkmate runs on. |
|
||||||
|
| Remote daemon | `tcp://docker.example.com:2376` | Always uses mutual TLS; the port defaults to `2376`. Unencrypted daemons on `2375` are not supported. |
|
||||||
|
| Capture agent | `https://capture.example.com/api/v1/metrics/docker` | Enter the full endpoint ending in `/metrics/docker` and its authorization secret. Checkmate requests `all=true` so stopped containers are included. |
|
||||||
|
|
||||||
|
Capture-backed monitors normalize Capture's container state, health, CPU, memory, start time and exposed ports into the same Docker pages. Capture does not currently provide restart counts, mounts, published host-port bindings or container logs, so those values are shown as unavailable. Capture also reports a container whose health check is still starting as healthy, so the `starting` health state only appears on direct monitors, and stopped containers carry no CPU or memory metrics.
|
||||||
|
|
||||||
|
**Monitoring the local socket.** The reference Compose file does not mount the socket, so add it and grant the container the host's `docker` group. The image runs as an unprivileged user and cannot read the socket otherwise. Find the group id with `stat -c %g /var/run/docker.sock`, then:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
checkmate:
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
group_add:
|
||||||
|
- "989" # the gid printed by stat
|
||||||
|
```
|
||||||
|
|
||||||
|
**Monitoring a remote daemon.** Set the host to `tcp://host:port` and fill in the **TLS credentials** section of the monitor form with the same PEM files you would pass to `docker --tlsverify`:
|
||||||
|
|
||||||
|
- **CA certificate**: the CA that signed the daemon's server certificate. Required unless **Ignore TLS/SSL errors** is on, which skips verifying the daemon's identity.
|
||||||
|
- **Client certificate**: the certificate the daemon uses to authenticate Checkmate.
|
||||||
|
- **Client key**: the matching private key, unencrypted (no passphrase). Checkmate checks that it matches the certificate, encrypts it with `ENCRYPTION_KEY`, and never shows it again. Leave the field blank when editing to keep the stored key.
|
||||||
|
|
||||||
|
TLS Docker monitors require `ENCRYPTION_KEY` to be set on the server (see [Configuration](#configuration)). Saving one without it fails with an error telling you so. If the key is ever removed or rotated incorrectly, affected checks fail with a decryption error until it is restored.
|
||||||
|
|
||||||
|
If the daemon's certificate is signed by a private CA that you also want the rest of Checkmate to trust, see the [Custom CA Trust Guide](./docs/custom-ca-trust.md); for Docker monitors alone, the **CA certificate** field is enough.
|
||||||
|
|
||||||
|
### Securing the Docker daemon with TLS
|
||||||
|
|
||||||
|
The Docker daemon does not enable TLS by default. Follow these steps on the Docker host to generate a CA, a server certificate, and a client certificate for Checkmate. This is the procedure from [Docker's own guide](https://docs.docker.com/engine/security/protect-access/), condensed. Replace `docker.example.com` and `203.0.113.10` with your daemon's DNS name and IP.
|
||||||
|
|
||||||
|
**1. Create a CA.** The CA key gets a passphrase; keep it offline once the certificates are issued.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl genrsa -aes256 -out ca-key.pem 4096
|
||||||
|
openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -subj "/CN=docker-ca" -out ca.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create the server certificate.** The subject alternative names must cover every name or address Checkmate will use to reach the daemon.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl genrsa -out server-key.pem 4096
|
||||||
|
openssl req -subj "/CN=docker.example.com" -sha256 -new -key server-key.pem -out server.csr
|
||||||
|
cat > server-ext.cnf <<EOF
|
||||||
|
subjectAltName = DNS:docker.example.com,IP:203.0.113.10
|
||||||
|
extendedKeyUsage = serverAuth
|
||||||
|
EOF
|
||||||
|
openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
|
||||||
|
-out server-cert.pem -extfile server-ext.cnf
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Create the client certificate for Checkmate.** Do not add a passphrase to this key; Checkmate cannot use encrypted private keys.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl genrsa -out key.pem 4096
|
||||||
|
openssl req -subj "/CN=checkmate" -new -key key.pem -out client.csr
|
||||||
|
echo "extendedKeyUsage = clientAuth" > client-ext.cnf
|
||||||
|
openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
|
||||||
|
-out cert.pem -extfile client-ext.cnf
|
||||||
|
rm client.csr server.csr server-ext.cnf client-ext.cnf
|
||||||
|
chmod 0400 ca-key.pem key.pem server-key.pem
|
||||||
|
chmod 0444 ca.pem server-cert.pem cert.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Point the daemon at the certificates.** Move `ca.pem`, `server-cert.pem` and `server-key.pem` to `/etc/docker/certs/` and configure `/etc/docker/daemon.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
|
||||||
|
"tls": true,
|
||||||
|
"tlsverify": true,
|
||||||
|
"tlscacert": "/etc/docker/certs/ca.pem",
|
||||||
|
"tlscert": "/etc/docker/certs/server-cert.pem",
|
||||||
|
"tlskey": "/etc/docker/certs/server-key.pem"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
On distributions where the systemd unit already passes `-H fd://` (Debian, Ubuntu and derivatives), the daemon refuses to start with `hosts` set in both places. Remove the flag from the unit with an override, then restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl edit docker.service
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=/usr/bin/dockerd
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload && sudo systemctl restart docker
|
||||||
|
```
|
||||||
|
|
||||||
|
Open port `2376` on the host firewall only to the machine running Checkmate.
|
||||||
|
|
||||||
|
**5. Verify from the Checkmate host**, then paste `ca.pem`, `cert.pem` and `key.pem` into the monitor form:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker --tlsverify --tlscacert=ca.pem --tlscert=cert.pem --tlskey=key.pem \
|
||||||
|
-H=docker.example.com:2376 version
|
||||||
|
```
|
||||||
|
|
||||||
For more documentation, see the [docs directory](./docs/).
|
For more documentation, see the [docs directory](./docs/).
|
||||||
|
|
||||||
<a id="performance"></a>
|
<a id="performance"></a>
|
||||||
@@ -134,7 +251,7 @@ Thanks to extensive optimizations, Checkmate operates with an exceptionally smal
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
You can see the memory footprint of MongoDB and Redis on the same server (398Mb and 15Mb) for the same amount of servers:
|
You can see the memory footprint of MongoDB on the same server (398Mb) for the same amount of servers:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -152,16 +269,16 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
|
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
|
||||||
- Several monitoring options: Uptime, Docker, Ping, SSL, Port, Game server
|
- Several monitoring options: HTTP (with SSL certificate expiry), Ping, Port, DNS, Docker, gRPC, WebSocket, Game server
|
||||||
- Page speed monitoring
|
- Page speed monitoring
|
||||||
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
|
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
|
||||||
- Selective disk monitoring with mountpoint selection
|
- Selective disk monitoring with mountpoint selection
|
||||||
- Incidents at a glance
|
- Incidents at a glance
|
||||||
- Status pages with 4 beautiful themes
|
- Status pages with 5 beautiful themes
|
||||||
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
|
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, ntfy, SignalGrid, Twilio (SMS) notifications
|
||||||
- Scheduled maintenance
|
- Scheduled maintenance
|
||||||
- JSON query monitoring
|
- JSON query monitoring
|
||||||
- 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
|
- Multi-language support for Arabic, Catalan, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Italian, Japanese, Polish, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
|
||||||
|
|
||||||
|
|
||||||
## Monitor Lifecycle
|
## Monitor Lifecycle
|
||||||
@@ -212,9 +329,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
|||||||
<a id="contributing"></a>
|
<a id="contributing"></a>
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
|
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Malena](https://github.com/malenacaroline) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
|
||||||
|
|
||||||
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe.
|
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K stars and attracted 150+ contributors from around the globe.
|
||||||
|
|
||||||
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
|
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
|
||||||
|
|
||||||
|
|||||||
Generated
+12
-2
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"version": "3.11.0",
|
"version": "3.12.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"version": "3.11.0",
|
"version": "3.12.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@csstools/css-tokenizer": "^3.0.4",
|
"@csstools/css-tokenizer": "^3.0.4",
|
||||||
"@emotion/react": "^11.13.3",
|
"@emotion/react": "^11.13.3",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
"@hello-pangea/dnd": "^18.0.0",
|
"@hello-pangea/dnd": "^18.0.0",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@mui/lab": "7.0.0",
|
"@mui/lab": "7.0.0",
|
||||||
@@ -950,6 +951,15 @@
|
|||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource-variable/geist": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hapi/hoek": {
|
"node_modules/@hapi/hoek": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
|
|||||||
+8
-2
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.11.0",
|
"version": "3.12.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24",
|
||||||
|
"npm": ">=11"
|
||||||
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
||||||
@@ -12,7 +16,8 @@
|
|||||||
"lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format-check": "prettier --check ."
|
"format-check": "prettier --check .",
|
||||||
|
"compile": "tsc -b"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"**/*": "prettier --write --ignore-unknown"
|
"**/*": "prettier --write --ignore-unknown"
|
||||||
@@ -21,6 +26,7 @@
|
|||||||
"@csstools/css-tokenizer": "^3.0.4",
|
"@csstools/css-tokenizer": "^3.0.4",
|
||||||
"@emotion/react": "^11.13.3",
|
"@emotion/react": "^11.13.3",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
"@hello-pangea/dnd": "^18.0.0",
|
"@hello-pangea/dnd": "^18.0.0",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@mui/lab": "7.0.0",
|
"@mui/lab": "7.0.0",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Menu from "@mui/material/Menu";
|
|||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
export type ActionMenuItem = {
|
export type ActionMenuItem = {
|
||||||
id: number | string;
|
id: number | string;
|
||||||
@@ -12,6 +13,7 @@ export type ActionMenuItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
||||||
|
const theme = useTheme();
|
||||||
const [anchorEl, setAnchorEl] = useState<null | any>(null);
|
const [anchorEl, setAnchorEl] = useState<null | any>(null);
|
||||||
const open = Boolean(anchorEl);
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
@@ -27,7 +29,14 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<IconButton onClick={handleClick}>
|
<IconButton
|
||||||
|
onClick={handleClick}
|
||||||
|
sx={{
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: theme.palette.action.controlHover,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Settings
|
<Settings
|
||||||
size={20}
|
size={20}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import { useTheme } from "@mui/material/styles";
|
|||||||
import CircularProgress from "@mui/material/CircularProgress";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMediaQuery } from "@mui/material";
|
import { useMediaQuery } from "@mui/material";
|
||||||
|
import type { DateRange } from "@/Types/Query";
|
||||||
|
|
||||||
interface MonitorTimeFrameHeaderProps {
|
interface MonitorTimeFrameHeaderProps {
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
hasDateRange?: boolean;
|
hasDateRange?: boolean;
|
||||||
dateRange: string;
|
dateRange: DateRange;
|
||||||
setDateRange: (dateRange: string) => void;
|
setDateRange: (dateRange: DateRange) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HeaderTimeRange = ({
|
export const HeaderTimeRange = ({
|
||||||
@@ -25,7 +26,7 @@ export const HeaderTimeRange = ({
|
|||||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
const handleChange = (
|
const handleChange = (
|
||||||
_event: React.MouseEvent<HTMLElement>,
|
_event: React.MouseEvent<HTMLElement>,
|
||||||
newValue: string | null
|
newValue: DateRange | null
|
||||||
) => {
|
) => {
|
||||||
if (newValue !== null) {
|
if (newValue !== null) {
|
||||||
setDateRange(newValue);
|
setDateRange(newValue);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import Box, { type BoxProps } from "@mui/material/Box";
|
import Box, { type BoxProps } from "@mui/material/Box";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { forwardRef } from "react";
|
||||||
|
|
||||||
export const BaseBox = ({ sx, ...rest }: BoxProps) => {
|
export const BaseBox = forwardRef<HTMLDivElement, BoxProps>(({ sx, ...rest }, ref) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
ref={ref}
|
||||||
{...rest}
|
{...rest}
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: theme.palette.background.paper,
|
backgroundColor: theme.palette.background.paper,
|
||||||
@@ -16,4 +18,6 @@ export const BaseBox = ({ sx, ...rest }: BoxProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
BaseBox.displayName = "BaseBox";
|
||||||
@@ -10,8 +10,10 @@ import { useTheme } from "@mui/material/styles";
|
|||||||
type BaseChartProps = React.PropsWithChildren<{
|
type BaseChartProps = React.PropsWithChildren<{
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
|
rightTitle?: string;
|
||||||
width?: number | string;
|
width?: number | string;
|
||||||
maxWidth?: number | string;
|
maxWidth?: number | string;
|
||||||
|
flexBasis?: number | string;
|
||||||
padding?: number | string | ResponsiveStyleValue<number | string>;
|
padding?: number | string | ResponsiveStyleValue<number | string>;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}>;
|
}>;
|
||||||
@@ -19,8 +21,10 @@ type BaseChartProps = React.PropsWithChildren<{
|
|||||||
export const BaseChart = ({
|
export const BaseChart = ({
|
||||||
children,
|
children,
|
||||||
title,
|
title,
|
||||||
|
rightTitle,
|
||||||
width = "100%",
|
width = "100%",
|
||||||
maxWidth = "100%",
|
maxWidth = "100%",
|
||||||
|
flexBasis = "0%",
|
||||||
padding,
|
padding,
|
||||||
onClick,
|
onClick,
|
||||||
}: BaseChartProps) => {
|
}: BaseChartProps) => {
|
||||||
@@ -32,6 +36,7 @@ export const BaseChart = ({
|
|||||||
padding: padding ?? theme.spacing(LAYOUT.MD),
|
padding: padding ?? theme.spacing(LAYOUT.MD),
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
flexBasis,
|
||||||
width: width,
|
width: width,
|
||||||
maxWidth: maxWidth,
|
maxWidth: maxWidth,
|
||||||
...(onClick && {
|
...(onClick && {
|
||||||
@@ -44,13 +49,27 @@ export const BaseChart = ({
|
|||||||
gap={theme.spacing(LAYOUT.MD)}
|
gap={theme.spacing(LAYOUT.MD)}
|
||||||
flex={1}
|
flex={1}
|
||||||
>
|
>
|
||||||
<Typography
|
<Stack
|
||||||
variant="eyebrow"
|
direction="row"
|
||||||
color="text.secondary"
|
justifyContent={"space-between"}
|
||||||
sx={{ overflowWrap: "anywhere" }}
|
|
||||||
>
|
>
|
||||||
{title}
|
<Typography
|
||||||
</Typography>
|
variant="eyebrow"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ overflowWrap: "anywhere" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{rightTitle && (
|
||||||
|
<Typography
|
||||||
|
variant="eyebrow"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ overflowWrap: "anywhere" }}
|
||||||
|
>
|
||||||
|
{rightTitle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
<Box flex={1}>{children}</Box>
|
<Box flex={1}>{children}</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</BaseBox>
|
</BaseBox>
|
||||||
|
|||||||
@@ -83,12 +83,17 @@ export const Breadcrumb = ({
|
|||||||
const basePage = segments[0] || t("common.breadcrumbs.home");
|
const basePage = segments[0] || t("common.breadcrumbs.home");
|
||||||
|
|
||||||
const secondSegment = segments[1];
|
const secondSegment = segments[1];
|
||||||
|
const thirdSegment = segments[2];
|
||||||
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
|
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
|
||||||
const isDetailsPage = secondSegment && isId(secondSegment);
|
const isDetailsPage = secondSegment && isId(secondSegment);
|
||||||
const hasSubPage = isActionPage || isDetailsPage;
|
// page/section/:id routes such as docker/host/:monitorId
|
||||||
|
const isSectionDetailsPage = Boolean(
|
||||||
|
secondSegment && !isId(secondSegment) && thirdSegment && isId(thirdSegment)
|
||||||
|
);
|
||||||
|
const hasSubPage = isActionPage || isDetailsPage || isSectionDetailsPage;
|
||||||
|
|
||||||
const getSubPageLabel = (): string => {
|
const getSubPageLabel = (): string => {
|
||||||
if (isActionPage) {
|
if (isActionPage || isSectionDetailsPage) {
|
||||||
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
|
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
|
||||||
}
|
}
|
||||||
return t("common.breadcrumbs.details");
|
return t("common.breadcrumbs.details");
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
|
|||||||
() => ({
|
() => ({
|
||||||
circumference: 2 * Math.PI * radius,
|
circumference: 2 * Math.PI * radius,
|
||||||
totalSize: radius * 2 + strokeWidth * 2,
|
totalSize: radius * 2 + strokeWidth * 2,
|
||||||
strokeLength: (progress / 100) * (2 * Math.PI * radius),
|
strokeLength: (progressWithinRange / 100) * (2 * Math.PI * radius),
|
||||||
}),
|
}),
|
||||||
[radius, strokeWidth, progress]
|
[radius, strokeWidth, progressWithinRange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [offset, setOffset] = useState(circumference);
|
const [offset, setOffset] = useState(circumference);
|
||||||
@@ -52,7 +52,7 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
|
|||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [progress, circumference, strokeLength]);
|
}, [circumference, strokeLength]);
|
||||||
|
|
||||||
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
|
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
|
||||||
|
|
||||||
@@ -123,6 +123,7 @@ export const DetailGauge = ({
|
|||||||
lowerLabel,
|
lowerLabel,
|
||||||
lowerValue,
|
lowerValue,
|
||||||
maxWidth = 225,
|
maxWidth = 225,
|
||||||
|
flexBasis = "0%",
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
@@ -131,6 +132,7 @@ export const DetailGauge = ({
|
|||||||
lowerLabel?: string;
|
lowerLabel?: string;
|
||||||
lowerValue?: string | number;
|
lowerValue?: string | number;
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
|
flexBasis?: number | string;
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
@@ -138,6 +140,7 @@ export const DetailGauge = ({
|
|||||||
icon={null}
|
icon={null}
|
||||||
title={title}
|
title={title}
|
||||||
maxWidth={maxWidth}
|
maxWidth={maxWidth}
|
||||||
|
flexBasis={flexBasis}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
alignItems={"center"}
|
alignItems={"center"}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface IconProps {
|
|||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => {
|
const Icon = ({ icon: Icon, size = 16, strokeWidth = 1.5, color }: IconProps) => {
|
||||||
return (
|
return (
|
||||||
<Icon
|
<Icon
|
||||||
size={size}
|
size={size}
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
|||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { formatDuration } from "@/Utils/TimeUtils";
|
import { formatDuration } from "@/Utils/TimeUtils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
|
||||||
if (!monitor) {
|
if (!monitor) {
|
||||||
@@ -52,7 +55,12 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
|||||||
<>
|
<>
|
||||||
<Dot />
|
<Dot />
|
||||||
<Typography>
|
<Typography>
|
||||||
Checking every {formatDuration(monitor?.interval, true)}
|
{t("pages.common.monitors.statBoxes.checkingEvery", {
|
||||||
|
interval: formatDuration(monitor.interval, {
|
||||||
|
long: true,
|
||||||
|
locale: i18n.resolvedLanguage,
|
||||||
|
}),
|
||||||
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ export const OfflineBanner = ({ visible }: OfflineBannerProps) => {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: theme.zIndex.snackbar,
|
zIndex: theme.zIndex.snackbar,
|
||||||
backgroundColor: theme.palette.error.main,
|
backgroundColor: theme.palette.error.dark,
|
||||||
color: theme.palette.error.contrastText,
|
color: "#FFFFFF",
|
||||||
px: theme.spacing(8),
|
px: theme.spacing(8),
|
||||||
py: theme.spacing(4),
|
py: theme.spacing(4),
|
||||||
transition: "top 1s ease-in-out",
|
transition: "top 1s ease-in-out",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Link as RouterLink } from "react-router-dom";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
|
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
@@ -109,7 +110,7 @@ export const PageHeader = ({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
lineHeight: 1.55,
|
lineHeight: 1.55,
|
||||||
maxWidth: 640,
|
maxWidth: "108ch",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{description}
|
{description}
|
||||||
@@ -142,7 +143,7 @@ export const PageHeader = ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 32,
|
height: INPUT_BASE_HEIGHT,
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
borderRight:
|
borderRight:
|
||||||
idx < linkItems.length - 1
|
idx < linkItems.length - 1
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Box, Stack, useTheme } from "@mui/material";
|
import { Box, Stack, useTheme } from "@mui/material";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
interface SkeletonCardProps {
|
interface SkeletonCardProps {
|
||||||
width?: number | string | { xs?: number | string; md?: number | string };
|
width?: number | string | { xs?: number | string; md?: number | string };
|
||||||
@@ -44,7 +45,7 @@ export const SkeletonCard = ({
|
|||||||
|
|
||||||
const blockStyle = {
|
const blockStyle = {
|
||||||
width: 55,
|
width: 55,
|
||||||
height: 34,
|
height: INPUT_BASE_HEIGHT,
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
background: theme.palette.secondary.main,
|
background: theme.palette.secondary.main,
|
||||||
border: `1px solid ${theme.palette.divider}`,
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
|
|||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { lighten } from "@mui/material/styles";
|
import { darken, lighten } from "@mui/material/styles";
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
||||||
import { BaseBox, TooltipWithInfo } from "@/Components/design-elements";
|
import { BaseBox, TooltipWithInfo } from "@/Components/design-elements";
|
||||||
@@ -11,17 +11,18 @@ import type { SxProps } from "@mui/material";
|
|||||||
type GradientBox = React.PropsWithChildren<{
|
type GradientBox = React.PropsWithChildren<{
|
||||||
palette?: PaletteKey;
|
palette?: PaletteKey;
|
||||||
sx?: SxProps;
|
sx?: SxProps;
|
||||||
|
interactive?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const GradientBox = ({ children, palette, sx }: GradientBox) => {
|
export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
const isLight = theme.palette.mode === "light";
|
const isLight = theme.palette.mode === "light";
|
||||||
const paper = theme.palette.background.paper;
|
const paper = theme.palette.background.paper;
|
||||||
const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
|
const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08);
|
||||||
const paperEnd = paper;
|
const paperEnd = paper;
|
||||||
const bg = palette
|
const bg = palette
|
||||||
? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)`
|
? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)`
|
||||||
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
|
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,6 +31,12 @@ export const GradientBox = ({ children, palette, sx }: GradientBox) => {
|
|||||||
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
|
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
|
||||||
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
|
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
|
||||||
background: bg,
|
background: bg,
|
||||||
|
...(interactive && {
|
||||||
|
cursor: "pointer",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundImage: `linear-gradient(${theme.palette.action.rowHover}, ${theme.palette.action.rowHover}), ${bg}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
...sx,
|
...sx,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -62,10 +69,8 @@ export const StatBox = ({
|
|||||||
return (
|
return (
|
||||||
<GradientBox
|
<GradientBox
|
||||||
palette={palette}
|
palette={palette}
|
||||||
sx={{
|
interactive={Boolean(onClick)}
|
||||||
...(sx as object),
|
sx={{ ...(sx as object) }}
|
||||||
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Stack onClick={onClick}>
|
<Stack onClick={onClick}>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { BaseBox } from "@/Components/design-elements";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { SxProps } from "@mui/material";
|
import type { SxProps } from "@mui/material";
|
||||||
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { alpha, useTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
type StatusBoxProps = React.PropsWithChildren<{
|
type StatusBoxProps = React.PropsWithChildren<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -34,8 +34,8 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
|
|||||||
sx={{
|
sx={{
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
backgroundImage: `
|
backgroundImage: `
|
||||||
linear-gradient(${theme.palette.divider} 1px, transparent 1px),
|
linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px),
|
||||||
linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px)
|
linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px)
|
||||||
`,
|
`,
|
||||||
backgroundSize: "24px 24px",
|
backgroundSize: "24px 24px",
|
||||||
maskImage:
|
maskImage:
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import { BaseBox } from "@/Components/design-elements";
|
import { BaseBox } from "@/Components/design-elements";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
|
||||||
import type { MonitorStatus } from "@/Types/Monitor";
|
import type { MonitorStatus } from "@/Types/Monitor";
|
||||||
import type { SxProps } from "@mui/material/styles";
|
import type { SxProps } from "@mui/material/styles";
|
||||||
import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
|
import {
|
||||||
|
getDockerStatePalette,
|
||||||
|
getStatusPalette,
|
||||||
|
getValuePalette,
|
||||||
|
} from "@/Utils/MonitorUtils";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { DockerContainerState } from "@/Types/Check";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
export const ValueTypes = ["positive", "negative", "neutral"] as const;
|
export const ValueTypes = ["positive", "negative", "neutral"] as const;
|
||||||
export type ValueType = (typeof ValueTypes)[number];
|
export type ValueType = (typeof ValueTypes)[number];
|
||||||
@@ -110,3 +117,36 @@ export const ColoredLabel = ({ text, color }: { text: string; color: string }) =
|
|||||||
</BaseBox>
|
</BaseBox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DockerStateLabel = ({
|
||||||
|
state,
|
||||||
|
name,
|
||||||
|
image,
|
||||||
|
}: {
|
||||||
|
state: DockerContainerState;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
}) => {
|
||||||
|
const palette = getDockerStatePalette(state);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
gap={SPACING.LG}
|
||||||
|
alignItems={"center"}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
width={7}
|
||||||
|
height={7}
|
||||||
|
bgcolor={theme.palette[palette].light}
|
||||||
|
borderRadius="50%"
|
||||||
|
marginRight="5px"
|
||||||
|
/>
|
||||||
|
<Stack>
|
||||||
|
<Typography>{name}</Typography>
|
||||||
|
<Typography variant="body2">{image}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -52,6 +52,7 @@ type DataTableProps<T extends { id?: string | number; _id?: string | number }> =
|
|||||||
emptyViewText?: string;
|
emptyViewText?: string;
|
||||||
emptyViewPositive?: boolean;
|
emptyViewPositive?: boolean;
|
||||||
getRowSx?: (row: T) => SxProps<Theme>;
|
getRowSx?: (row: T) => SxProps<Theme>;
|
||||||
|
isRowSelected?: (row: T) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DataTable<
|
export function DataTable<
|
||||||
@@ -70,6 +71,7 @@ export function DataTable<
|
|||||||
emptyViewText,
|
emptyViewText,
|
||||||
emptyViewPositive,
|
emptyViewPositive,
|
||||||
getRowSx,
|
getRowSx,
|
||||||
|
isRowSelected,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [expanded, setExpanded] = useState<(string | number) | null>(null);
|
const [expanded, setExpanded] = useState<(string | number) | null>(null);
|
||||||
@@ -80,6 +82,22 @@ export function DataTable<
|
|||||||
|
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
|
||||||
|
const hasTextSelectionWithin = (element: Element) => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
selection.anchorNode !== null &&
|
||||||
|
element.contains(
|
||||||
|
selection.anchorNode.nodeType === Node.TEXT_NODE
|
||||||
|
? selection.anchorNode.parentNode
|
||||||
|
: selection.anchorNode
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const isInteractive = Boolean(onRowClick) || expandableRows;
|
||||||
|
|
||||||
if (data.length === 0 || headers.length === 0) {
|
if (data.length === 0 || headers.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyView
|
<EmptyView
|
||||||
@@ -99,7 +117,10 @@ export function DataTable<
|
|||||||
keys.push(key);
|
keys.push(key);
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
onClick={() => (onRowClick ? onRowClick(row) : null)}
|
onClick={(e) => {
|
||||||
|
if (hasTextSelectionWithin(e.currentTarget)) return;
|
||||||
|
if (onRowClick) onRowClick(row);
|
||||||
|
}}
|
||||||
spacing={theme.spacing(LAYOUT.XS)}
|
spacing={theme.spacing(LAYOUT.XS)}
|
||||||
sx={{
|
sx={{
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
@@ -108,6 +129,11 @@ export function DataTable<
|
|||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
padding: theme.spacing(LAYOUT.XS),
|
padding: theme.spacing(LAYOUT.XS),
|
||||||
cursor: onRowClick ? "pointer" : "default",
|
cursor: onRowClick ? "pointer" : "default",
|
||||||
|
...(onRowClick && {
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: theme.palette.action.rowHover,
|
||||||
|
},
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
key={key}
|
key={key}
|
||||||
>
|
>
|
||||||
@@ -191,7 +217,7 @@ export function DataTable<
|
|||||||
"& :is(th)": {
|
"& :is(th)": {
|
||||||
backgroundColor: theme.palette.background.paper,
|
backgroundColor: theme.palette.background.paper,
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
fontWeight: 500,
|
fontWeight: 600,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.08em",
|
letterSpacing: "0.08em",
|
||||||
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
|
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
|
||||||
@@ -227,15 +253,29 @@ export function DataTable<
|
|||||||
{data.map((row) => {
|
{data.map((row) => {
|
||||||
const key = row.id || row._id || Math.random();
|
const key = row.id || row._id || Math.random();
|
||||||
const isExpanded = expanded === key;
|
const isExpanded = expanded === key;
|
||||||
|
const selected = isRowSelected?.(row);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={key}>
|
<Fragment key={key}>
|
||||||
<TableRow
|
<TableRow
|
||||||
sx={{
|
sx={{
|
||||||
cursor: onRowClick ? "pointer" : "default",
|
cursor: isInteractive ? "pointer" : "default",
|
||||||
|
...(isInteractive && {
|
||||||
|
"&:hover .MuiTableCell-root": {
|
||||||
|
backgroundColor: selected
|
||||||
|
? theme.palette.action.selectedHover
|
||||||
|
: theme.palette.action.rowHover,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(selected && {
|
||||||
|
"& .MuiTableCell-root": {
|
||||||
|
backgroundColor: theme.palette.action.selected,
|
||||||
|
},
|
||||||
|
}),
|
||||||
...(getRowSx?.(row) as object),
|
...(getRowSx?.(row) as object),
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
if (hasTextSelectionWithin(e.currentTarget)) return;
|
||||||
if (expandableRows) handleExpand(row);
|
if (expandableRows) handleExpand(row);
|
||||||
else if (onRowClick) onRowClick(row);
|
else if (onRowClick) onRowClick(row);
|
||||||
}}
|
}}
|
||||||
@@ -292,6 +332,12 @@ interface TablePaginationActionsProps {
|
|||||||
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
|
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const controlHoverSx = (theme: Theme): SxProps<Theme> => ({
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: theme.palette.action.controlHover,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function TablePaginationActions(props: TablePaginationActionsProps) {
|
function TablePaginationActions(props: TablePaginationActionsProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { count, page, rowsPerPage, onPageChange } = props;
|
const { count, page, rowsPerPage, onPageChange } = props;
|
||||||
@@ -321,6 +367,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
|||||||
onClick={handleFirstPageButtonClick}
|
onClick={handleFirstPageButtonClick}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
aria-label="first page"
|
aria-label="first page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronsRight
|
<ChevronsRight
|
||||||
@@ -338,6 +385,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
|||||||
onClick={handleBackButtonClick}
|
onClick={handleBackButtonClick}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
aria-label="previous page"
|
aria-label="previous page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
@@ -355,6 +403,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
|||||||
onClick={handleNextButtonClick}
|
onClick={handleNextButtonClick}
|
||||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||||
aria-label="next page"
|
aria-label="next page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronLeft
|
<ChevronLeft
|
||||||
@@ -372,6 +421,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
|||||||
onClick={handleLastPageButtonClick}
|
onClick={handleLastPageButtonClick}
|
||||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||||
aria-label="last page"
|
aria-label="last page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronsLeft
|
<ChevronsLeft
|
||||||
@@ -419,6 +469,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
|||||||
onClick={handleFirstPageButtonClick}
|
onClick={handleFirstPageButtonClick}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
aria-label="first page"
|
aria-label="first page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronsRight
|
<ChevronsRight
|
||||||
@@ -436,6 +487,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
|||||||
onClick={handleBackButtonClick}
|
onClick={handleBackButtonClick}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
aria-label="previous page"
|
aria-label="previous page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
@@ -453,6 +505,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
|||||||
onClick={handleNextButtonClick}
|
onClick={handleNextButtonClick}
|
||||||
disabled={hasMore === false}
|
disabled={hasMore === false}
|
||||||
aria-label="next page"
|
aria-label="next page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronLeft
|
<ChevronLeft
|
||||||
@@ -470,6 +523,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
|||||||
<IconButton
|
<IconButton
|
||||||
disabled={hasMore === false}
|
disabled={hasMore === false}
|
||||||
aria-label="next page"
|
aria-label="next page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
<Ellipsis
|
<Ellipsis
|
||||||
size={20}
|
size={20}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import MuiTabs from "@mui/material/Tabs";
|
import MuiTabs from "@mui/material/Tabs";
|
||||||
import type { TabsProps } from "@mui/material/Tabs";
|
import type { TabsProps } from "@mui/material/Tabs";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
interface CustomTabsProps extends TabsProps {}
|
interface CustomTabsProps extends TabsProps {}
|
||||||
|
|
||||||
export const Tabs = (props: CustomTabsProps) => {
|
export const Tabs = (props: CustomTabsProps) => {
|
||||||
@@ -8,7 +10,7 @@ export const Tabs = (props: CustomTabsProps) => {
|
|||||||
return (
|
return (
|
||||||
<MuiTabs
|
<MuiTabs
|
||||||
sx={{
|
sx={{
|
||||||
minHeight: 34,
|
minHeight: INPUT_BASE_HEIGHT,
|
||||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||||
"& .MuiTabs-indicator": {
|
"& .MuiTabs-indicator": {
|
||||||
backgroundColor: theme.palette.primary.main,
|
backgroundColor: theme.palette.primary.main,
|
||||||
@@ -38,9 +40,9 @@ export const Tab = (props: CustomTabProps) => {
|
|||||||
iconPosition="start"
|
iconPosition="start"
|
||||||
sx={{
|
sx={{
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
fontSize: 14,
|
fontSize: typographyLevels.m,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
minHeight: 34,
|
minHeight: INPUT_BASE_HEIGHT,
|
||||||
padding: theme.spacing(1, 0),
|
padding: theme.spacing(1, 0),
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
minWidth: "auto",
|
minWidth: "auto",
|
||||||
@@ -48,7 +50,6 @@ export const Tab = (props: CustomTabProps) => {
|
|||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
"&.Mui-selected": {
|
"&.Mui-selected": {
|
||||||
color: theme.palette.primary.main,
|
color: theme.palette.primary.main,
|
||||||
fontWeight: 600,
|
|
||||||
},
|
},
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import type { ButtonProps } from "@mui/material/Button";
|
import type { ButtonProps } from "@mui/material/Button";
|
||||||
import { useTheme, darken } from "@mui/material/styles";
|
import { useTheme, darken } from "@mui/material/styles";
|
||||||
import { HOVER } from "@/Utils/Theme/constants";
|
import { INPUT_BASE_HEIGHT, HOVER } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
const PALETTE_COLORS = [
|
const PALETTE_COLORS = [
|
||||||
"primary",
|
"primary",
|
||||||
@@ -43,7 +43,8 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
sx={{
|
sx={{
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
height: 34,
|
height: INPUT_BASE_HEIGHT,
|
||||||
|
lineHeight: 1.2,
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
|||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { FieldLabel } from "@/Components/inputs/FieldLabel";
|
import { FieldLabel } from "@/Components/inputs/FieldLabel";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
interface ColorPickerProps extends MuiColorInputProps {
|
interface ColorPickerProps extends MuiColorInputProps {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
@@ -18,7 +19,7 @@ export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps)
|
|||||||
sx={{
|
sx={{
|
||||||
"& .MuiOutlinedInput-root": {
|
"& .MuiOutlinedInput-root": {
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
height: 34,
|
height: INPUT_BASE_HEIGHT,
|
||||||
fontSize: typographyLevels.base,
|
fontSize: typographyLevels.base,
|
||||||
},
|
},
|
||||||
"& .MuiOutlinedInput-notchedOutline": {
|
"& .MuiOutlinedInput-notchedOutline": {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { FieldLabel } from "./FieldLabel";
|
|||||||
import { Calendar } from "lucide-react";
|
import { Calendar } from "lucide-react";
|
||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
|
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
@@ -55,7 +56,7 @@ export const DatePickerComponent = ({
|
|||||||
flexDirection: "row-reverse",
|
flexDirection: "row-reverse",
|
||||||
},
|
},
|
||||||
"& input": {
|
"& input": {
|
||||||
minHeight: 34,
|
minHeight: INPUT_BASE_HEIGHT,
|
||||||
p: 0,
|
p: 0,
|
||||||
pl: theme.spacing(3),
|
pl: theme.spacing(3),
|
||||||
pr: theme.spacing(5),
|
pr: theme.spacing(5),
|
||||||
@@ -66,7 +67,9 @@ export const DatePickerComponent = ({
|
|||||||
},
|
},
|
||||||
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
||||||
{
|
{
|
||||||
borderColor: error ? theme.palette.error.main : theme.palette.divider,
|
borderColor: error
|
||||||
|
? theme.palette.error.main
|
||||||
|
: theme.palette.text.disabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Stack from "@mui/material/Stack";
|
|||||||
import { FieldLabel } from "./FieldLabel";
|
import { FieldLabel } from "./FieldLabel";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
|
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
@@ -71,7 +72,7 @@ const SelectInputInner = <T,>(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
sx={{
|
sx={{
|
||||||
height: "34px",
|
height: `${INPUT_BASE_HEIGHT}px`,
|
||||||
"& .MuiSelect-select": {
|
"& .MuiSelect-select": {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -83,10 +84,6 @@ const SelectInputInner = <T,>(
|
|||||||
},
|
},
|
||||||
"& .MuiOutlinedInput-notchedOutline": {
|
"& .MuiOutlinedInput-notchedOutline": {
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
|
||||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
},
|
||||||
...props.sx,
|
...props.sx,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
|||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { FieldLabel } from "./FieldLabel";
|
import { FieldLabel } from "./FieldLabel";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { INPUT_BASE_HEIGHT, LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
interface TextInputProps extends Omit<TextFieldProps, "label"> {
|
interface TextInputProps extends Omit<TextFieldProps, "label"> {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
@@ -22,16 +22,10 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
"& .MuiOutlinedInput-root": {
|
"& .MuiOutlinedInput-root": {
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
height: props.multiline ? "auto" : 34,
|
height: props.multiline ? "auto" : INPUT_BASE_HEIGHT,
|
||||||
fontSize: typographyLevels.base,
|
fontSize: typographyLevels.base,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
"& .MuiOutlinedInput-notchedOutline": {
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
|
||||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
|
||||||
"& .MuiFormHelperText-root": {
|
"& .MuiFormHelperText-root": {
|
||||||
marginLeft: 0,
|
marginLeft: 0,
|
||||||
marginRight: 0,
|
marginRight: 0,
|
||||||
@@ -39,7 +33,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
|||||||
},
|
},
|
||||||
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
|
"& 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`,
|
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.paper} inset !important`,
|
||||||
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
|
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
|
||||||
caretColor: theme.palette.text.primary,
|
caretColor: theme.palette.text.primary,
|
||||||
transition: "background-color 5000s ease-in-out 0s",
|
transition: "background-color 5000s ease-in-out 0s",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Typography from "@mui/material/Typography";
|
|||||||
import { FieldLabel } from "./FieldLabel";
|
import { FieldLabel } from "./FieldLabel";
|
||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
|
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
@@ -33,7 +34,7 @@ export const TimePickerComponent = ({
|
|||||||
sx: {
|
sx: {
|
||||||
width: "fit-content",
|
width: "fit-content",
|
||||||
"& input": {
|
"& input": {
|
||||||
minHeight: 34,
|
minHeight: INPUT_BASE_HEIGHT,
|
||||||
p: 0,
|
p: 0,
|
||||||
px: theme.spacing(5),
|
px: theme.spacing(5),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ToggleButtonProps } from "@mui/material/ToggleButton";
|
|||||||
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
|
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { alpha } from "@mui/material/styles";
|
import { alpha } from "@mui/material/styles";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -20,7 +21,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
|||||||
sx={{
|
sx={{
|
||||||
px: 8,
|
px: 8,
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
height: 34,
|
height: INPUT_BASE_HEIGHT,
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
@@ -34,7 +35,6 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
|||||||
"&.Mui-selected": {
|
"&.Mui-selected": {
|
||||||
backgroundColor: selectedBg,
|
backgroundColor: selectedBg,
|
||||||
color: theme.palette.text.primary,
|
color: theme.palette.text.primary,
|
||||||
fontWeight: 500,
|
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor: selectedHoverBg,
|
backgroundColor: selectedHoverBg,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
type FieldValues,
|
type FieldValues,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
import { Autocomplete } from "@/Components/inputs";
|
import { Autocomplete } from "@/Components/inputs";
|
||||||
|
import type { AutocompleteProps } from "@mui/material/Autocomplete";
|
||||||
|
|
||||||
export interface AutocompleteOption {
|
export interface AutocompleteOption {
|
||||||
id: string;
|
id: string | number;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@ interface FormAutocompleteProps<T extends FieldValues, O extends AutocompleteOpt
|
|||||||
options: O[];
|
options: O[];
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
filterOptions?: AutocompleteProps<O, false, false, false>["filterOptions"];
|
||||||
|
getOptionDisabled?: AutocompleteProps<O, false, false, false>["getOptionDisabled"];
|
||||||
|
disableClearable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FormAutocompleteField = <
|
export const FormAutocompleteField = <
|
||||||
@@ -26,6 +30,9 @@ export const FormAutocompleteField = <
|
|||||||
options,
|
options,
|
||||||
fieldLabel,
|
fieldLabel,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
filterOptions,
|
||||||
|
getOptionDisabled,
|
||||||
|
disableClearable,
|
||||||
}: FormAutocompleteProps<T, O>) => {
|
}: FormAutocompleteProps<T, O>) => {
|
||||||
const { control } = useFormContext<T>();
|
const { control } = useFormContext<T>();
|
||||||
return (
|
return (
|
||||||
@@ -39,10 +46,14 @@ export const FormAutocompleteField = <
|
|||||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||||
value={options.find((o) => o.id === field.value) ?? null}
|
value={options.find((o) => o.id === field.value) ?? null}
|
||||||
onChange={(_: unknown, newValue: O | null) => {
|
onChange={(_: unknown, newValue: O | null) => {
|
||||||
|
if (newValue === null && disableClearable) return;
|
||||||
field.onChange(newValue?.id ?? "");
|
field.onChange(newValue?.id ?? "");
|
||||||
}}
|
}}
|
||||||
fieldLabel={fieldLabel}
|
fieldLabel={fieldLabel}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
filterOptions={filterOptions}
|
||||||
|
getOptionDisabled={getOptionDisabled}
|
||||||
|
disableClearable={disableClearable}
|
||||||
error={!!fieldState.error}
|
error={!!fieldState.error}
|
||||||
helperText={fieldState.error?.message ?? ""}
|
helperText={fieldState.error?.message ?? ""}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ import type { Tag } from "@/Types/Tag";
|
|||||||
import { Typography, useTheme } from "@mui/material";
|
import { Typography, useTheme } from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
|
const types = ["http", "ping", "port", "game", "grpc", "websocket"];
|
||||||
const typeDisplayNames: Record<string, string> = {
|
const typeDisplayNames: Record<string, string> = {
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
ping: "Ping",
|
ping: "Ping",
|
||||||
port: "Port",
|
port: "Port",
|
||||||
docker: "Docker",
|
|
||||||
game: "Game",
|
game: "Game",
|
||||||
grpc: "gRPC",
|
grpc: "gRPC",
|
||||||
websocket: "WebSocket",
|
websocket: "WebSocket",
|
||||||
|
|||||||
@@ -60,20 +60,22 @@ export const HeaderMonitorControls = ({
|
|||||||
direction={{ xs: "column", md: "row" }}
|
direction={{ xs: "column", md: "row" }}
|
||||||
gap={theme.spacing(2)}
|
gap={theme.spacing(2)}
|
||||||
>
|
>
|
||||||
<Button
|
{isAdmin && (
|
||||||
variant="contained"
|
<Button
|
||||||
color="secondary"
|
variant="contained"
|
||||||
loading={isPosting}
|
color="secondary"
|
||||||
startIcon={<Icon icon={Mail} />}
|
loading={isPosting}
|
||||||
onClick={async () => {
|
startIcon={<Icon icon={Mail} />}
|
||||||
await post(`/notifications/test/all`, { monitorId: monitor.id });
|
onClick={async () => {
|
||||||
}}
|
await post(`/notifications/test/all`, { monitorId: monitor.id });
|
||||||
sx={{
|
}}
|
||||||
whiteSpace: "nowrap",
|
sx={{
|
||||||
}}
|
whiteSpace: "nowrap",
|
||||||
>
|
}}
|
||||||
{t("common.buttons.testNotifications")}
|
>
|
||||||
</Button>
|
{t("common.buttons.testNotifications")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { HeaderCreate } from "@/Components/common";
|
||||||
|
import { ColoredLabel, MonitorBasePageWithStates } from "@/Components/design-elements";
|
||||||
|
import { BulkActionsBar } from "@/Components/monitors/BulkActionsBar";
|
||||||
|
import { ControlsFilter } from "@/Components/monitors/ControlsFilter";
|
||||||
|
import { HeaderMonitorsSummary } from "@/Components/monitors/HeaderMonitorsSummary";
|
||||||
|
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||||
|
import type { MonitorListController } from "@/Hooks/useMonitorListController";
|
||||||
|
import { useMediaQuery, useTheme } from "@mui/material";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { TextField, Button, Dialog } from "@/Components/inputs";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Pause, Play } from "lucide-react";
|
||||||
|
|
||||||
|
interface MonitorListPageProps {
|
||||||
|
headerKey: string;
|
||||||
|
page: string;
|
||||||
|
actionLink: string;
|
||||||
|
controller: MonitorListController;
|
||||||
|
bulkActions?: boolean;
|
||||||
|
showTypeFilter?: boolean;
|
||||||
|
summaryProps?: { showBreached?: boolean };
|
||||||
|
priorityFallback?: ReactNode;
|
||||||
|
extraLoading?: boolean;
|
||||||
|
extraError?: unknown;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base monitor list page, all monitors pages are nearly identical, but with different tables.
|
||||||
|
|
||||||
|
export const MonitorListPage = ({
|
||||||
|
headerKey,
|
||||||
|
page,
|
||||||
|
actionLink,
|
||||||
|
controller,
|
||||||
|
bulkActions,
|
||||||
|
showTypeFilter,
|
||||||
|
summaryProps,
|
||||||
|
priorityFallback,
|
||||||
|
extraLoading,
|
||||||
|
extraError,
|
||||||
|
children,
|
||||||
|
}: MonitorListPageProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
const isLoading = controller.isLoading || Boolean(extraLoading);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MonitorBasePageWithStates
|
||||||
|
headerKey={headerKey}
|
||||||
|
loading={isLoading}
|
||||||
|
error={controller.error || extraError}
|
||||||
|
totalCount={controller.effectiveTotalCount}
|
||||||
|
page={page}
|
||||||
|
actionLink={actionLink}
|
||||||
|
priorityFallback={priorityFallback}
|
||||||
|
>
|
||||||
|
<HeaderCreate
|
||||||
|
path={actionLink}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
/>
|
||||||
|
<HeaderMonitorsSummary
|
||||||
|
summary={controller.summary}
|
||||||
|
{...summaryProps}
|
||||||
|
/>
|
||||||
|
<Stack
|
||||||
|
direction={isSmall ? "column" : "row"}
|
||||||
|
justifyContent={isSmall ? "flex-start" : "space-between"}
|
||||||
|
gap={theme.spacing(4)}
|
||||||
|
>
|
||||||
|
<ControlsFilter
|
||||||
|
{...(showTypeFilter === false
|
||||||
|
? { showTypes: false }
|
||||||
|
: {
|
||||||
|
selectedTypes: controller.selectedTypes,
|
||||||
|
setSelectedTypes: controller.setSelectedTypes,
|
||||||
|
})}
|
||||||
|
selectedStatus={controller.selectedStatus}
|
||||||
|
setSelectedStatus={controller.setSelectedStatus}
|
||||||
|
selectedState={controller.selectedState}
|
||||||
|
setSelectedState={controller.setSelectedState}
|
||||||
|
tagOptions={controller.tags ?? []}
|
||||||
|
selectedTags={controller.selectedTags}
|
||||||
|
setSelectedTags={controller.setSelectedTags}
|
||||||
|
onClearFilters={controller.handleClearFilters}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
placeholder={t("pages.uptime.filters.search.placeholder")}
|
||||||
|
value={controller.search}
|
||||||
|
onChange={(event) => {
|
||||||
|
controller.setSearch(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
{controller.selectedTags.length > 0 && (
|
||||||
|
<Stack
|
||||||
|
direction={isSmall ? "column" : "row"}
|
||||||
|
alignItems={isSmall ? "flex-start" : "center"}
|
||||||
|
flexWrap="wrap"
|
||||||
|
gap={theme.spacing(SPACING.XL)}
|
||||||
|
>
|
||||||
|
<Typography color={theme.palette.text.secondary}>
|
||||||
|
{t("pages.uptime.filters.activeTags")}
|
||||||
|
</Typography>
|
||||||
|
{controller.selectedTags.map((tagId) => {
|
||||||
|
const tag = controller.tags?.find((t) => t.id === tagId);
|
||||||
|
if (!tag) return null;
|
||||||
|
return (
|
||||||
|
<ColoredLabel
|
||||||
|
key={tag.id}
|
||||||
|
text={tag.name}
|
||||||
|
color={tag.color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
{bulkActions && !isLoading && (
|
||||||
|
<BulkActionsBar
|
||||||
|
selectedCount={controller.selectedRows.length}
|
||||||
|
onCancel={controller.handleCancelSelection}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<Play size={16} />}
|
||||||
|
onClick={controller.handleBulkResume}
|
||||||
|
>
|
||||||
|
{t("common.buttons.resume")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<Pause size={16} />}
|
||||||
|
onClick={controller.handleBulkPause}
|
||||||
|
>
|
||||||
|
{t("common.buttons.pause")}
|
||||||
|
</Button>
|
||||||
|
</BulkActionsBar>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
<Dialog
|
||||||
|
open={controller.isDialogOpen}
|
||||||
|
title={t("common.dialogs.delete.title")}
|
||||||
|
content={t("common.dialogs.delete.description")}
|
||||||
|
onConfirm={controller.handleConfirmDelete}
|
||||||
|
onCancel={controller.handleCancelDelete}
|
||||||
|
loading={controller.isDeleting}
|
||||||
|
/>
|
||||||
|
</MonitorBasePageWithStates>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -21,7 +21,7 @@ export const MonitorStatBoxes = ({
|
|||||||
domainExpiry,
|
domainExpiry,
|
||||||
}: MonitorStatBoxesProps) => {
|
}: MonitorStatBoxesProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
if (!monitorStats || !monitor) {
|
if (!monitorStats || !monitor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -29,13 +29,16 @@ export const MonitorStatBoxes = ({
|
|||||||
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
|
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
|
||||||
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
|
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
|
||||||
|
|
||||||
// Determine time since last check
|
|
||||||
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
||||||
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
|
|
||||||
|
|
||||||
const streakTime = formatDuration(timeSinceLastFailure);
|
const streakTime = formatDuration(timeSinceLastFailure);
|
||||||
|
const lastCheckTime = timeOfLastCheck
|
||||||
const lastCheckTime = formatDuration(timeSinceLastCheck);
|
? formatDuration(Date.now() - timeOfLastCheck, {
|
||||||
|
long: true,
|
||||||
|
relative: true,
|
||||||
|
locale: i18n.resolvedLanguage,
|
||||||
|
})
|
||||||
|
: t("common.labels.na");
|
||||||
const isActive =
|
const isActive =
|
||||||
monitor?.status === "up" ||
|
monitor?.status === "up" ||
|
||||||
monitor?.status === "paused" ||
|
monitor?.status === "paused" ||
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
export const computeYAxisCap = (values: number[]): number | undefined => {
|
|
||||||
const sorted = values.slice().sort((a, b) => a - b);
|
|
||||||
if (sorted.length < 2) return undefined;
|
|
||||||
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
|
|
||||||
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PHASE_KEYS = [
|
|
||||||
"avgDns",
|
|
||||||
"avgTcp",
|
|
||||||
"avgTls",
|
|
||||||
"avgRequest",
|
|
||||||
"avgFirstByte",
|
|
||||||
"avgDownload",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const PHASE_COLOR_KEYS = {
|
|
||||||
avgDns: "dns",
|
|
||||||
avgTcp: "tcp",
|
|
||||||
avgTls: "tls",
|
|
||||||
avgRequest: "request",
|
|
||||||
avgFirstByte: "firstByte",
|
|
||||||
avgDownload: "download",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// i18n keys, resolved with t() at render
|
|
||||||
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
|
|
||||||
avgDns: "common.charts.phases.dns",
|
|
||||||
avgTcp: "common.charts.phases.tcp",
|
|
||||||
avgTls: "common.charts.phases.tls",
|
|
||||||
avgRequest: "common.charts.phases.request",
|
|
||||||
avgFirstByte: "common.charts.phases.firstByte",
|
|
||||||
avgDownload: "common.charts.phases.download",
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
export const computeYAxisCap = (values: number[]): number | undefined => {
|
||||||
|
const sorted = values.slice().sort((a, b) => a - b);
|
||||||
|
if (sorted.length < 2) return undefined;
|
||||||
|
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
|
||||||
|
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PHASE_KEYS = [
|
||||||
|
"avgDns",
|
||||||
|
"avgTcp",
|
||||||
|
"avgTls",
|
||||||
|
"avgRequest",
|
||||||
|
"avgFirstByte",
|
||||||
|
"avgDownload",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const PHASE_COLOR_KEYS = {
|
||||||
|
avgDns: "dns",
|
||||||
|
avgTcp: "tcp",
|
||||||
|
avgTls: "tls",
|
||||||
|
avgRequest: "request",
|
||||||
|
avgFirstByte: "firstByte",
|
||||||
|
avgDownload: "download",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// i18n keys, resolved with t() at render
|
||||||
|
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
|
||||||
|
avgDns: "common.charts.phases.dns",
|
||||||
|
avgTcp: "common.charts.phases.tcp",
|
||||||
|
avgTls: "common.charts.phases.tls",
|
||||||
|
avgRequest: "common.charts.phases.request",
|
||||||
|
avgFirstByte: "common.charts.phases.firstByte",
|
||||||
|
avgDownload: "common.charts.phases.download",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AREA_COLORS = [
|
||||||
|
// Blues
|
||||||
|
"#3182bd", // Deep blue
|
||||||
|
"#6baed6", // Medium blue
|
||||||
|
"#9ecae1", // Light blue
|
||||||
|
|
||||||
|
// Greens
|
||||||
|
"#74c476", // Soft green
|
||||||
|
"#a1d99b", // Light green
|
||||||
|
"#c7e9c0", // Pale green
|
||||||
|
|
||||||
|
// Oranges
|
||||||
|
"#fdae6b", // Warm orange
|
||||||
|
"#fdd0a2", // Light orange
|
||||||
|
"#feedde", // Pale orange
|
||||||
|
|
||||||
|
// Purples
|
||||||
|
"#9467bd", // Lavender
|
||||||
|
"#a55194", // Deep magenta
|
||||||
|
"#c994c7", // Soft magenta
|
||||||
|
|
||||||
|
// Reds
|
||||||
|
"#ff9896", // Soft red
|
||||||
|
"#de2d26", // Deep red
|
||||||
|
"#fc9272", // Medium red
|
||||||
|
|
||||||
|
// Cyans/Teals
|
||||||
|
"#17becf", // Cyan
|
||||||
|
"#7fcdbb", // Teal
|
||||||
|
"#a1dab4", // Light teal
|
||||||
|
|
||||||
|
// Yellows
|
||||||
|
"#fec44f", // Mustard
|
||||||
|
"#fee391", // Light yellow
|
||||||
|
"#ffffd4", // Pale yellow
|
||||||
|
|
||||||
|
// Additional colors
|
||||||
|
"#e377c2", // Soft pink
|
||||||
|
"#bcbd22", // Olive
|
||||||
|
"#2ca02c", // Vibrant green
|
||||||
|
];
|
||||||
|
|
||||||
|
export const createGradient = ({
|
||||||
|
id,
|
||||||
|
startColor,
|
||||||
|
endColor,
|
||||||
|
startOpacity = 0.8,
|
||||||
|
endOpacity = 0,
|
||||||
|
direction = "vertical",
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
startColor: string;
|
||||||
|
endColor: string;
|
||||||
|
startOpacity?: number;
|
||||||
|
endOpacity?: number;
|
||||||
|
direction?: "vertical" | "horizontal";
|
||||||
|
}) => (
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id={id}
|
||||||
|
x1={direction === "vertical" ? "0" : "0"}
|
||||||
|
y1={direction === "vertical" ? "0" : "0"}
|
||||||
|
x2={direction === "vertical" ? "0" : "1"}
|
||||||
|
y2={direction === "vertical" ? "1" : "0"}
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stopColor={startColor}
|
||||||
|
stopOpacity={startOpacity}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stopColor={endColor}
|
||||||
|
stopOpacity={endOpacity}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
);
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { BaseChart, BaseBox } from "@/Components/design-elements";
|
||||||
|
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { DockerContainerStats } from "@/Types/Monitor";
|
||||||
|
import type { TooltipProps } from "recharts";
|
||||||
|
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||||
|
import type { Theme } from "@mui/material/styles";
|
||||||
|
import type { RootState } from "@/Types/state";
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import { useTheme } from "@mui/material";
|
||||||
|
import { Fragment, useId } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
|
||||||
|
import { tooltipDateFormatLookup, formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
|
import type { DateRange } from "@/Types/Query";
|
||||||
|
|
||||||
|
type DockerHistogramToolTipProps = TooltipProps<ValueType, NameType> & {
|
||||||
|
dataKey: string;
|
||||||
|
range: string;
|
||||||
|
theme: Theme;
|
||||||
|
uiTimezone: string;
|
||||||
|
valueFormatter: (value: number) => string;
|
||||||
|
labelKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DockerHistogramToolTip = ({
|
||||||
|
dataKey,
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
range,
|
||||||
|
theme,
|
||||||
|
uiTimezone,
|
||||||
|
valueFormatter,
|
||||||
|
labelKey,
|
||||||
|
}: DockerHistogramToolTipProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!payload) return null;
|
||||||
|
if (!active) return null;
|
||||||
|
const format = tooltipDateFormatLookup(range);
|
||||||
|
const ts = payload[0].payload?._id ?? null;
|
||||||
|
const dataPoint = payload[0].payload?.[dataKey] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
||||||
|
<Typography>{formatDateWithTz(ts, format, uiTimezone)}</Typography>
|
||||||
|
<Typography>{t(labelKey, { value: valueFormatter(dataPoint) })}</Typography>
|
||||||
|
</BaseBox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
interface HistogramDockerContainerProps {
|
||||||
|
title: string;
|
||||||
|
rightTitle: string;
|
||||||
|
stats: DockerContainerStats;
|
||||||
|
dataKey: string;
|
||||||
|
strokeColor: string;
|
||||||
|
gradientStartColor: string;
|
||||||
|
range: DateRange;
|
||||||
|
formatter: (value: number) => string;
|
||||||
|
labelKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HistogramDockerContainer = ({
|
||||||
|
title,
|
||||||
|
rightTitle,
|
||||||
|
stats,
|
||||||
|
dataKey,
|
||||||
|
strokeColor,
|
||||||
|
gradientStartColor,
|
||||||
|
range,
|
||||||
|
formatter,
|
||||||
|
labelKey,
|
||||||
|
}: HistogramDockerContainerProps) => {
|
||||||
|
const uniqueId = useId();
|
||||||
|
const theme = useTheme();
|
||||||
|
const gradientId = `gradient-${uniqueId}`;
|
||||||
|
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||||
|
return (
|
||||||
|
<BaseChart
|
||||||
|
icon="null"
|
||||||
|
title={title}
|
||||||
|
rightTitle={rightTitle}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer
|
||||||
|
width="100%"
|
||||||
|
height={200}
|
||||||
|
>
|
||||||
|
<AreaChart data={stats.aggregate}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeWidth={1}
|
||||||
|
stroke={theme.palette.divider}
|
||||||
|
strokeOpacity={1}
|
||||||
|
fill="transparent"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<Fragment>
|
||||||
|
{createGradient({
|
||||||
|
id: gradientId,
|
||||||
|
startColor: gradientStartColor,
|
||||||
|
endColor: "transparent",
|
||||||
|
direction: "vertical",
|
||||||
|
})}
|
||||||
|
<Tooltip
|
||||||
|
content={(props) => (
|
||||||
|
<DockerHistogramToolTip
|
||||||
|
{...props}
|
||||||
|
range={range}
|
||||||
|
theme={theme}
|
||||||
|
uiTimezone={uiTimezone}
|
||||||
|
dataKey={dataKey}
|
||||||
|
valueFormatter={formatter}
|
||||||
|
labelKey={labelKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
dataKey={dataKey}
|
||||||
|
type="monotone"
|
||||||
|
stroke={strokeColor}
|
||||||
|
fill={`url(#${gradientId})`}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</BaseChart>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,85 +12,7 @@ import { XTick } from "@/Components/monitors/charts/XTick";
|
|||||||
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import type { HardwareCheckStats } from "@/Types/Monitor";
|
import type { HardwareCheckStats } from "@/Types/Monitor";
|
||||||
|
import { AREA_COLORS, createGradient } from "@/Components/monitors/charts/ChartUtils";
|
||||||
const AREA_COLORS = [
|
|
||||||
// Blues
|
|
||||||
"#3182bd", // Deep blue
|
|
||||||
"#6baed6", // Medium blue
|
|
||||||
"#9ecae1", // Light blue
|
|
||||||
|
|
||||||
// Greens
|
|
||||||
"#74c476", // Soft green
|
|
||||||
"#a1d99b", // Light green
|
|
||||||
"#c7e9c0", // Pale green
|
|
||||||
|
|
||||||
// Oranges
|
|
||||||
"#fdae6b", // Warm orange
|
|
||||||
"#fdd0a2", // Light orange
|
|
||||||
"#feedde", // Pale orange
|
|
||||||
|
|
||||||
// Purples
|
|
||||||
"#9467bd", // Lavender
|
|
||||||
"#a55194", // Deep magenta
|
|
||||||
"#c994c7", // Soft magenta
|
|
||||||
|
|
||||||
// Reds
|
|
||||||
"#ff9896", // Soft red
|
|
||||||
"#de2d26", // Deep red
|
|
||||||
"#fc9272", // Medium red
|
|
||||||
|
|
||||||
// Cyans/Teals
|
|
||||||
"#17becf", // Cyan
|
|
||||||
"#7fcdbb", // Teal
|
|
||||||
"#a1dab4", // Light teal
|
|
||||||
|
|
||||||
// Yellows
|
|
||||||
"#fec44f", // Mustard
|
|
||||||
"#fee391", // Light yellow
|
|
||||||
"#ffffd4", // Pale yellow
|
|
||||||
|
|
||||||
// Additional colors
|
|
||||||
"#e377c2", // Soft pink
|
|
||||||
"#bcbd22", // Olive
|
|
||||||
"#2ca02c", // Vibrant green
|
|
||||||
];
|
|
||||||
|
|
||||||
const createGradient = ({
|
|
||||||
id,
|
|
||||||
startColor,
|
|
||||||
endColor,
|
|
||||||
startOpacity = 0.8,
|
|
||||||
endOpacity = 0,
|
|
||||||
direction = "vertical",
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
startColor: string;
|
|
||||||
endColor: string;
|
|
||||||
startOpacity?: number;
|
|
||||||
endOpacity?: number;
|
|
||||||
direction?: "vertical" | "horizontal";
|
|
||||||
}) => (
|
|
||||||
<defs>
|
|
||||||
<linearGradient
|
|
||||||
id={id}
|
|
||||||
x1={direction === "vertical" ? "0" : "0"}
|
|
||||||
y1={direction === "vertical" ? "0" : "0"}
|
|
||||||
x2={direction === "vertical" ? "0" : "1"}
|
|
||||||
y2={direction === "vertical" ? "1" : "0"}
|
|
||||||
>
|
|
||||||
<stop
|
|
||||||
offset="0%"
|
|
||||||
stopColor={startColor}
|
|
||||||
stopOpacity={startOpacity}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="100%"
|
|
||||||
stopColor={endColor}
|
|
||||||
stopOpacity={endOpacity}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const HistogramInfrastructure = ({
|
export const HistogramInfrastructure = ({
|
||||||
dateRange,
|
dateRange,
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
|
|||||||
export * from "./charts/HistogramInfrastructure";
|
export * from "./charts/HistogramInfrastructure";
|
||||||
export * from "./HeaderMonitorsSummary";
|
export * from "./HeaderMonitorsSummary";
|
||||||
export * from "./BulkActionsBar";
|
export * from "./BulkActionsBar";
|
||||||
|
export * from "./MonitorListPage";
|
||||||
@@ -19,68 +19,100 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Tag,
|
Tag,
|
||||||
Waypoints,
|
Waypoints,
|
||||||
|
Container,
|
||||||
|
// LayoutDashboard,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
export const getMenu = (t: Function) => {
|
export const getMenu = (t: Function) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: t("components.sidebar.menu.uptime"),
|
group: t("components.sidebar.menu.groups.monitoring"),
|
||||||
path: "uptime",
|
items: [
|
||||||
icon: <Icon icon={Globe} />,
|
// {
|
||||||
|
// name: t("components.sidebar.menu.dashboard"),
|
||||||
|
// path: "dashboard",
|
||||||
|
// icon: <Icon icon={LayoutDashboard} />,
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
name: t("components.sidebar.menu.uptime"),
|
||||||
|
path: "uptime",
|
||||||
|
icon: <Icon icon={Globe} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t("components.sidebar.menu.docker"),
|
||||||
|
path: "docker",
|
||||||
|
icon: <Icon icon={Container} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t("components.sidebar.menu.infrastructure"),
|
||||||
|
path: "infrastructure",
|
||||||
|
icon: <Icon icon={Link} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t("components.sidebar.menu.pagespeed"),
|
||||||
|
path: "pagespeed",
|
||||||
|
icon: <Icon icon={Gauge} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t("components.sidebar.menu.pagespeed"),
|
group: t("components.sidebar.menu.groups.alerting"),
|
||||||
path: "pagespeed",
|
items: [
|
||||||
icon: <Icon icon={Gauge} />,
|
{
|
||||||
|
name: t("components.sidebar.menu.incidents"),
|
||||||
|
path: "incidents",
|
||||||
|
icon: <Icon icon={AlertTriangle} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t("components.sidebar.menu.notifications"),
|
||||||
|
path: "notifications",
|
||||||
|
icon: <Icon icon={Bell} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t("components.sidebar.menu.maintenance"),
|
||||||
|
path: "maintenance",
|
||||||
|
icon: <Icon icon={Wrench} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t("components.sidebar.menu.infrastructure"),
|
group: t("components.sidebar.menu.groups.reporting"),
|
||||||
path: "infrastructure",
|
items: [
|
||||||
icon: <Icon icon={Link} />,
|
{
|
||||||
|
name: t("components.sidebar.menu.statusPages"),
|
||||||
|
path: "status",
|
||||||
|
icon: <Icon icon={Wifi} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t("components.sidebar.menu.checks"),
|
||||||
|
path: "checks",
|
||||||
|
icon: <Icon icon={FileText} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t("components.sidebar.menu.logs"),
|
||||||
|
path: "logs",
|
||||||
|
icon: <Icon icon={ScrollText} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t("components.sidebar.menu.notifications"),
|
group: t("components.sidebar.menu.groups.configuration"),
|
||||||
path: "notifications",
|
items: [
|
||||||
icon: <Icon icon={Bell} />,
|
{
|
||||||
},
|
name: t("components.sidebar.menu.tags"),
|
||||||
{
|
path: "tags",
|
||||||
name: t("components.sidebar.menu.tags"),
|
icon: <Icon icon={Tag} />,
|
||||||
path: "tags",
|
},
|
||||||
icon: <Icon icon={Tag} />,
|
{
|
||||||
},
|
name: t("components.sidebar.menu.proxies"),
|
||||||
{
|
path: "proxies",
|
||||||
name: t("components.sidebar.menu.proxies"),
|
icon: <Icon icon={Waypoints} />,
|
||||||
path: "proxies",
|
},
|
||||||
icon: <Icon icon={Waypoints} />,
|
{
|
||||||
},
|
name: t("components.sidebar.menu.settings"),
|
||||||
{
|
path: "settings",
|
||||||
name: t("components.sidebar.menu.checks"),
|
icon: <Icon icon={Settings} />,
|
||||||
path: "checks",
|
},
|
||||||
icon: <Icon icon={FileText} />,
|
],
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("components.sidebar.menu.incidents"),
|
|
||||||
path: "incidents",
|
|
||||||
icon: <Icon icon={AlertTriangle} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("components.sidebar.menu.statusPages"),
|
|
||||||
path: "status",
|
|
||||||
icon: <Icon icon={Wifi} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("components.sidebar.menu.maintenance"),
|
|
||||||
path: "maintenance",
|
|
||||||
icon: <Icon icon={Wrench} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("components.sidebar.menu.logs"),
|
|
||||||
path: "logs",
|
|
||||||
icon: <Icon icon={ScrollText} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("components.sidebar.menu.settings"),
|
|
||||||
icon: <Icon icon={Settings} />,
|
|
||||||
path: "settings",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,11 +25,10 @@ export const NavItem = ({
|
|||||||
const iconStroke = selected
|
const iconStroke = selected
|
||||||
? theme.palette.sidebar.accent
|
? theme.palette.sidebar.accent
|
||||||
: theme.palette.text.secondary;
|
: theme.palette.text.secondary;
|
||||||
|
|
||||||
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
|
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
|
||||||
const buttonBgHoverColor = selected
|
const buttonBgHoverColor = selected
|
||||||
? theme.palette.action.selected
|
? theme.palette.action.selectedHover
|
||||||
: theme.palette.action.hover;
|
: theme.palette.action.rowHover;
|
||||||
const fontWeight = selected ? 600 : 400;
|
const fontWeight = selected ? 600 : 400;
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import Backdrop from "@mui/material/Backdrop";
|
import Backdrop from "@mui/material/Backdrop";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import List from "@mui/material/List";
|
import List from "@mui/material/List";
|
||||||
import Divider from "@mui/material/Divider";
|
import Divider from "@mui/material/Divider";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
import { useSidebar } from "@/Hooks/useSidebar.js";
|
import { useSidebar } from "@/Hooks/useSidebar.js";
|
||||||
import { Logo } from "@/Components/sidebar/Logo";
|
import { Logo } from "@/Components/sidebar/Logo";
|
||||||
import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu";
|
import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu";
|
||||||
@@ -29,7 +32,7 @@ export const Sidebar = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { width, transition, collapsed } = useSidebar();
|
const { width, transition, transitionTiming, collapsed } = useSidebar();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
const menu = getMenu(t);
|
const menu = getMenu(t);
|
||||||
@@ -70,11 +73,11 @@ export const Sidebar = () => {
|
|||||||
gap={theme.spacing(LAYOUT.SM)}
|
gap={theme.spacing(LAYOUT.SM)}
|
||||||
borderRight={`1px solid ${theme.palette.divider}`}
|
borderRight={`1px solid ${theme.palette.divider}`}
|
||||||
width={width}
|
width={width}
|
||||||
|
bgcolor={theme.palette.background.paper}
|
||||||
sx={{
|
sx={{
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
transition: transition,
|
transition: transition,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
backdropFilter: "blur(8px)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<List
|
<List
|
||||||
@@ -89,17 +92,52 @@ export const Sidebar = () => {
|
|||||||
pt={theme.spacing(LAYOUT.MD)}
|
pt={theme.spacing(LAYOUT.MD)}
|
||||||
pb={theme.spacing(LAYOUT.MD)}
|
pb={theme.spacing(LAYOUT.MD)}
|
||||||
/>
|
/>
|
||||||
{menu.map((item) => {
|
{menu.map((group) => (
|
||||||
const selected = location.pathname.startsWith(`/${item.path}`);
|
<Box key={group.group}>
|
||||||
return (
|
<Box
|
||||||
<NavItem
|
position="relative"
|
||||||
key={item.path}
|
overflow="hidden"
|
||||||
item={item}
|
>
|
||||||
selected={selected}
|
<Typography
|
||||||
onClick={() => handleNavClick(item.path)}
|
variant="eyebrow"
|
||||||
/>
|
component="div"
|
||||||
);
|
color={theme.palette.text.disabled}
|
||||||
})}
|
fontSize={typographyLevels.s}
|
||||||
|
paddingLeft={theme.spacing(5)}
|
||||||
|
paddingTop={theme.spacing(LAYOUT.SM)}
|
||||||
|
paddingBottom={theme.spacing(LAYOUT.XXS)}
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
sx={{
|
||||||
|
opacity: collapsed ? 0 : 1,
|
||||||
|
transition: `opacity ${transitionTiming}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.group}
|
||||||
|
</Typography>
|
||||||
|
<Divider
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
opacity: collapsed ? 1 : 0,
|
||||||
|
transition: `opacity ${transitionTiming}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const selected = location.pathname.startsWith(`/${item.path}`);
|
||||||
|
return (
|
||||||
|
<NavItem
|
||||||
|
key={item.path}
|
||||||
|
item={item}
|
||||||
|
selected={selected}
|
||||||
|
onClick={() => handleNavClick(item.path)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
</List>
|
</List>
|
||||||
<StarPrompt />
|
<StarPrompt />
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
|
import {
|
||||||
|
dashboardCardKeys,
|
||||||
|
type DashboardCardKey,
|
||||||
|
type DashboardSortOrder,
|
||||||
|
} from "@/Types/Dashboard";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
type ThemeMode = "light" | "dark";
|
type ThemeMode = "light" | "dark";
|
||||||
type ChartType = "histogram" | "line";
|
type ChartType = "histogram" | "line";
|
||||||
type TableName = "monitors" | "team" | "maintenance" | "infrastructure" | "logs";
|
|
||||||
|
type TableName =
|
||||||
|
| "dashboard"
|
||||||
|
| "monitors"
|
||||||
|
| "team"
|
||||||
|
| "maintenance"
|
||||||
|
| "infrastructure"
|
||||||
|
| "logs"
|
||||||
|
| "pagespeed"
|
||||||
|
| "docker";
|
||||||
|
|
||||||
interface TableState {
|
interface TableState {
|
||||||
rowsPerPage: number;
|
rowsPerPage: number;
|
||||||
|
sortField?: string;
|
||||||
|
sortOrder?: SortOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarState {
|
interface SidebarState {
|
||||||
@@ -13,10 +30,13 @@ interface SidebarState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
|
dashboard: TableState;
|
||||||
monitors: TableState;
|
monitors: TableState;
|
||||||
|
pagespeed: TableState;
|
||||||
team: TableState;
|
team: TableState;
|
||||||
maintenance: TableState;
|
maintenance: TableState;
|
||||||
infrastructure: TableState;
|
infrastructure: TableState;
|
||||||
|
docker: TableState;
|
||||||
logs: TableState;
|
logs: TableState;
|
||||||
sidebar: SidebarState;
|
sidebar: SidebarState;
|
||||||
mode: ThemeMode;
|
mode: ThemeMode;
|
||||||
@@ -26,6 +46,8 @@ interface UIState {
|
|||||||
language: string;
|
language: string;
|
||||||
starPromptOpen: boolean;
|
starPromptOpen: boolean;
|
||||||
chartType: ChartType;
|
chartType: ChartType;
|
||||||
|
dashboardVisibleCards: DashboardCardKey[];
|
||||||
|
dashboardUptimeSortOrder: DashboardSortOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
|
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
|
||||||
@@ -34,9 +56,15 @@ const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark
|
|||||||
: "light";
|
: "light";
|
||||||
|
|
||||||
const initialState: UIState = {
|
const initialState: UIState = {
|
||||||
|
dashboard: {
|
||||||
|
rowsPerPage: 100,
|
||||||
|
},
|
||||||
monitors: {
|
monitors: {
|
||||||
rowsPerPage: 10,
|
rowsPerPage: 10,
|
||||||
},
|
},
|
||||||
|
pagespeed: {
|
||||||
|
rowsPerPage: 10,
|
||||||
|
},
|
||||||
team: {
|
team: {
|
||||||
rowsPerPage: 5,
|
rowsPerPage: 5,
|
||||||
},
|
},
|
||||||
@@ -46,6 +74,9 @@ const initialState: UIState = {
|
|||||||
infrastructure: {
|
infrastructure: {
|
||||||
rowsPerPage: 5,
|
rowsPerPage: 5,
|
||||||
},
|
},
|
||||||
|
docker: {
|
||||||
|
rowsPerPage: 5,
|
||||||
|
},
|
||||||
logs: {
|
logs: {
|
||||||
rowsPerPage: 15,
|
rowsPerPage: 15,
|
||||||
},
|
},
|
||||||
@@ -59,6 +90,8 @@ const initialState: UIState = {
|
|||||||
language: "en",
|
language: "en",
|
||||||
starPromptOpen: true,
|
starPromptOpen: true,
|
||||||
chartType: "histogram",
|
chartType: "histogram",
|
||||||
|
dashboardVisibleCards: [...dashboardCardKeys],
|
||||||
|
dashboardUptimeSortOrder: "ascending",
|
||||||
};
|
};
|
||||||
|
|
||||||
const uiSlice = createSlice({
|
const uiSlice = createSlice({
|
||||||
@@ -75,6 +108,14 @@ const uiSlice = createSlice({
|
|||||||
const { table, value } = action.payload;
|
const { table, value } = action.payload;
|
||||||
state[table].rowsPerPage = value;
|
state[table].rowsPerPage = value;
|
||||||
},
|
},
|
||||||
|
setTableSort: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ table: TableName; field: string; order: SortOrder }>
|
||||||
|
) => {
|
||||||
|
const { table, field, order } = action.payload;
|
||||||
|
state[table].sortField = field;
|
||||||
|
state[table].sortOrder = order;
|
||||||
|
},
|
||||||
toggleSidebar: (state) => {
|
toggleSidebar: (state) => {
|
||||||
state.sidebar.collapsed = !state.sidebar.collapsed;
|
state.sidebar.collapsed = !state.sidebar.collapsed;
|
||||||
},
|
},
|
||||||
@@ -102,6 +143,12 @@ const uiSlice = createSlice({
|
|||||||
setChartType: (state, action: PayloadAction<ChartType>) => {
|
setChartType: (state, action: PayloadAction<ChartType>) => {
|
||||||
state.chartType = action.payload;
|
state.chartType = action.payload;
|
||||||
},
|
},
|
||||||
|
setDashboardVisibleCards: (state, action: PayloadAction<DashboardCardKey[]>) => {
|
||||||
|
state.dashboardVisibleCards = action.payload;
|
||||||
|
},
|
||||||
|
setDashboardUptimeSortOrder: (state, action: PayloadAction<DashboardSortOrder>) => {
|
||||||
|
state.dashboardUptimeSortOrder = action.payload;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,6 +156,7 @@ export type { UIState, ThemeMode, ChartType, TableName };
|
|||||||
export default uiSlice.reducer;
|
export default uiSlice.reducer;
|
||||||
export const {
|
export const {
|
||||||
setRowsPerPage,
|
setRowsPerPage,
|
||||||
|
setTableSort,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
setCollapsed,
|
setCollapsed,
|
||||||
setMode,
|
setMode,
|
||||||
@@ -118,4 +166,6 @@ export const {
|
|||||||
setLanguage,
|
setLanguage,
|
||||||
setStarPromptOpen,
|
setStarPromptOpen,
|
||||||
setChartType,
|
setChartType,
|
||||||
|
setDashboardVisibleCards,
|
||||||
|
setDashboardUptimeSortOrder,
|
||||||
} = uiSlice.actions;
|
} = uiSlice.actions;
|
||||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
|||||||
import { useToast } from "@/Hooks/UseToast";
|
import { useToast } from "@/Hooks/UseToast";
|
||||||
import { logger } from "@/Utils/logger";
|
import { logger } from "@/Utils/logger";
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
msg: string;
|
msg: string;
|
||||||
data: T;
|
data: T;
|
||||||
|
|||||||
@@ -1,27 +1,30 @@
|
|||||||
import { toast, type ToastOptions } from "react-toastify";
|
import { toast, type ToastOptions } from "react-toastify";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
export const useToast = () => {
|
export const useToast = () => {
|
||||||
const showToast = (message: string, options?: ToastOptions) => {
|
return useMemo(() => {
|
||||||
toast.dismiss();
|
const showToast = (message: string, options?: ToastOptions) => {
|
||||||
const baseStyle: React.CSSProperties = {
|
toast.dismiss();
|
||||||
whiteSpace: "nowrap",
|
const baseStyle: React.CSSProperties = {
|
||||||
overflow: "hidden",
|
whiteSpace: "nowrap",
|
||||||
textOverflow: "ellipsis",
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
};
|
||||||
|
toast(message, {
|
||||||
|
...options,
|
||||||
|
style: { ...baseStyle, ...(options?.style || {}) },
|
||||||
|
});
|
||||||
};
|
};
|
||||||
toast(message, {
|
|
||||||
...options,
|
|
||||||
style: { ...baseStyle, ...(options?.style || {}) },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toastSuccess = (msg: string, opts?: ToastOptions) =>
|
const toastSuccess = (msg: string, opts?: ToastOptions) =>
|
||||||
showToast(msg, { ...opts, type: "success" });
|
showToast(msg, { ...opts, type: "success" });
|
||||||
const toastError = (msg: string, opts?: ToastOptions) =>
|
const toastError = (msg: string, opts?: ToastOptions) =>
|
||||||
showToast(msg, { ...opts, type: "error" });
|
showToast(msg, { ...opts, type: "error" });
|
||||||
const toastInfo = (msg: string, opts?: ToastOptions) =>
|
const toastInfo = (msg: string, opts?: ToastOptions) =>
|
||||||
showToast(msg, { ...opts, type: "info" });
|
showToast(msg, { ...opts, type: "info" });
|
||||||
const toastWarning = (msg: string, opts?: ToastOptions) =>
|
const toastWarning = (msg: string, opts?: ToastOptions) =>
|
||||||
showToast(msg, { ...opts, type: "warning" });
|
showToast(msg, { ...opts, type: "warning" });
|
||||||
|
|
||||||
return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
|
return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
|
||||||
|
}, []);
|
||||||
};
|
};
|
||||||
@@ -77,6 +77,16 @@ export const getMonitorDefaults = (
|
|||||||
...base,
|
...base,
|
||||||
type: "docker",
|
type: "docker",
|
||||||
url: data?.url || "",
|
url: data?.url || "",
|
||||||
|
secret: data?.secret || "",
|
||||||
|
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||||
|
dockerTlsCa: data?.dockerTlsCa || "",
|
||||||
|
dockerTlsCert: data?.dockerTlsCert || "",
|
||||||
|
// The key is write-only; the API never returns it.
|
||||||
|
dockerTlsKey: "",
|
||||||
|
dockerTlsKeySet: data?.dockerTlsKeySet ?? false,
|
||||||
|
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
|
||||||
|
dockerAlertOnStopped: data?.dockerAlertOnStopped ?? false,
|
||||||
|
dockerAlertOnUnhealthy: data?.dockerAlertOnUnhealthy ?? false,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case "game":
|
case "game":
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { setRowsPerPage, setTableSort, type TableName } from "@/Features/UI/uiSlice";
|
||||||
|
import { useDelete, useGet } from "@/Hooks/UseApi";
|
||||||
|
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||||
|
import useDebounce from "@/Hooks/useDebounce";
|
||||||
|
import type { RootState } from "@/store";
|
||||||
|
import {
|
||||||
|
type MonitorsWithChecksResponse,
|
||||||
|
SelectableMonitorTypes,
|
||||||
|
type Monitor,
|
||||||
|
type MonitorType,
|
||||||
|
} from "@/Types/Monitor";
|
||||||
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
|
interface MonitorListConfig {
|
||||||
|
types: MonitorType[] | "selectable";
|
||||||
|
checksLimit: number;
|
||||||
|
refreshInterval: number;
|
||||||
|
rowsPerPageTable: TableName;
|
||||||
|
rowsPerPageDefault: number;
|
||||||
|
initialSortField?: string; // default ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMonitorListController = (config: MonitorListConfig) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const tableState = useSelector(
|
||||||
|
(state: RootState) => state.ui?.[config.rowsPerPageTable]
|
||||||
|
);
|
||||||
|
const rowsPerPage = tableState?.rowsPerPage ?? config.rowsPerPageDefault;
|
||||||
|
const sortField = tableState?.sortField ?? config.initialSortField ?? "";
|
||||||
|
const sortOrder = tableState?.sortOrder ?? "asc";
|
||||||
|
|
||||||
|
// Filter states
|
||||||
|
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<string>("");
|
||||||
|
const [selectedState, setSelectedState] = useState<string>("");
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const [page, setPage] = useState<number>(0);
|
||||||
|
const [search, setSearch] = useState<string>("");
|
||||||
|
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||||
|
const debouncedSearch = useDebounce<string>(search, 300);
|
||||||
|
|
||||||
|
// Convert filter selections to API filter values
|
||||||
|
// Status: pass "up"/"down" directly to the API
|
||||||
|
// State: "active" -> true, "paused" -> false
|
||||||
|
const toFilterStatus = useMemo(() => {
|
||||||
|
if (selectedStatus === "up") return "up";
|
||||||
|
if (selectedStatus === "down") return "down";
|
||||||
|
return undefined;
|
||||||
|
}, [selectedStatus]);
|
||||||
|
|
||||||
|
const toFilterActive = useMemo(() => {
|
||||||
|
if (selectedState === "active") return "true";
|
||||||
|
if (selectedState === "paused") return "false";
|
||||||
|
return undefined;
|
||||||
|
}, [selectedState]);
|
||||||
|
|
||||||
|
// Determine field and filter for the API request
|
||||||
|
// Priority: status > isActive > search > sort
|
||||||
|
const filterLookup = new Map<string | undefined, string>([
|
||||||
|
[toFilterStatus, "status"],
|
||||||
|
[toFilterActive, "isActive"],
|
||||||
|
]);
|
||||||
|
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
|
||||||
|
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
|
||||||
|
const filter = activeFilter?.[0] || debouncedSearch || undefined;
|
||||||
|
|
||||||
|
const effectiveTypes = useMemo(() => {
|
||||||
|
return config.types === "selectable"
|
||||||
|
? selectedTypes.length > 0
|
||||||
|
? selectedTypes
|
||||||
|
: [...SelectableMonitorTypes]
|
||||||
|
: config.types;
|
||||||
|
}, [config.types, selectedTypes]);
|
||||||
|
|
||||||
|
// Fetching
|
||||||
|
const monitorsWithChecksUrl = useMemo(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
effectiveTypes.forEach((type) => params.append("type", type));
|
||||||
|
selectedTags.forEach((tag) => params.append("tags", tag));
|
||||||
|
params.append("limit", String(config.checksLimit));
|
||||||
|
if (page !== undefined) params.append("page", String(page));
|
||||||
|
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
|
||||||
|
if (filter) params.append("filter", filter);
|
||||||
|
if (field) params.append("field", field);
|
||||||
|
if (sortOrder) params.append("order", sortOrder);
|
||||||
|
return `/monitors/team/with-checks?${params.toString()}`;
|
||||||
|
}, [
|
||||||
|
effectiveTypes,
|
||||||
|
selectedTags,
|
||||||
|
page,
|
||||||
|
rowsPerPage,
|
||||||
|
filter,
|
||||||
|
field,
|
||||||
|
sortOrder,
|
||||||
|
config.checksLimit,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: monitorsWithChecksData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useGet<MonitorsWithChecksResponse>(
|
||||||
|
monitorsWithChecksUrl,
|
||||||
|
{},
|
||||||
|
{ refreshInterval: config.refreshInterval, keepPreviousData: true }
|
||||||
|
);
|
||||||
|
const { data: tags } = useGet<Tag[]>("/tags/team");
|
||||||
|
|
||||||
|
const bulk = useBulkMonitorActions(refetch, page);
|
||||||
|
|
||||||
|
// Delete ops
|
||||||
|
const { deleteFn, loading: isDeleting } = useDelete();
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!selectedMonitor) return;
|
||||||
|
await deleteFn(`/monitors/${selectedMonitor.id}`);
|
||||||
|
setSelectedMonitor(null);
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
const handleCancelDelete = () => setSelectedMonitor(null);
|
||||||
|
|
||||||
|
// Handle filters
|
||||||
|
const handleClearFilters = useCallback(() => {
|
||||||
|
setSelectedTypes([]);
|
||||||
|
setSelectedStatus("");
|
||||||
|
setSelectedState("");
|
||||||
|
setSelectedTags([]);
|
||||||
|
setSearch("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSetRowsPerPage = (value: number) => {
|
||||||
|
dispatch(setRowsPerPage({ value, table: config.rowsPerPageTable }));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSort = (field: string, order: SortOrder) => {
|
||||||
|
dispatch(setTableSort({ field, order, table: config.rowsPerPageTable }));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for active filters
|
||||||
|
const hasActiveFilters = Boolean(
|
||||||
|
selectedTypes.length > 0 ||
|
||||||
|
selectedStatus ||
|
||||||
|
selectedState ||
|
||||||
|
selectedTags.length > 0 ||
|
||||||
|
search
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show empty state only when there are truly no monitors (not just filtered out)
|
||||||
|
// If filters are active and count is 0, pass 1 to prevent empty state fallback
|
||||||
|
|
||||||
|
const { monitors, summary, count } = monitorsWithChecksData ?? {
|
||||||
|
monitors: null,
|
||||||
|
summary: null,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const effectiveTotalCount =
|
||||||
|
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
|
||||||
|
? 1
|
||||||
|
: (summary?.totalMonitors ?? 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Data
|
||||||
|
monitors,
|
||||||
|
summary,
|
||||||
|
count,
|
||||||
|
tags,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
effectiveTotalCount,
|
||||||
|
hasActiveFilters,
|
||||||
|
|
||||||
|
// Filter state and setters
|
||||||
|
selectedTypes,
|
||||||
|
setSelectedTypes,
|
||||||
|
selectedStatus,
|
||||||
|
setSelectedStatus,
|
||||||
|
selectedState,
|
||||||
|
setSelectedState,
|
||||||
|
selectedTags,
|
||||||
|
setSelectedTags,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
handleClearFilters,
|
||||||
|
|
||||||
|
// Paging and Sorting
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
rowsPerPage,
|
||||||
|
handleSetRowsPerPage,
|
||||||
|
sortField,
|
||||||
|
sortOrder,
|
||||||
|
setSort,
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
selectedMonitor,
|
||||||
|
setSelectedMonitor,
|
||||||
|
isDialogOpen: Boolean(selectedMonitor),
|
||||||
|
isDeleting,
|
||||||
|
handleConfirmDelete,
|
||||||
|
handleCancelDelete,
|
||||||
|
|
||||||
|
// Rendered only when config.bulkActions
|
||||||
|
...bulk,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MonitorListController = ReturnType<typeof useMonitorListController>;
|
||||||
@@ -6,7 +6,8 @@ const SIDEBAR_WIDTH_VAR = 250;
|
|||||||
const SIDEBAR_COLLAPSED_WIDTH_VAR = 64;
|
const SIDEBAR_COLLAPSED_WIDTH_VAR = 64;
|
||||||
|
|
||||||
// Transition timing for sidebar width changes
|
// Transition timing for sidebar width changes
|
||||||
const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
|
const SIDEBAR_TRANSITION_TIMING = "650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
|
||||||
|
const SIDEBAR_TRANSITION = `width ${SIDEBAR_TRANSITION_TIMING}`;
|
||||||
|
|
||||||
export const useSidebar = () => {
|
export const useSidebar = () => {
|
||||||
const collapsed = useSelector(
|
const collapsed = useSelector(
|
||||||
@@ -19,6 +20,7 @@ export const useSidebar = () => {
|
|||||||
expandedWidth: SIDEBAR_WIDTH_VAR,
|
expandedWidth: SIDEBAR_WIDTH_VAR,
|
||||||
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR,
|
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR,
|
||||||
transition: SIDEBAR_TRANSITION,
|
transition: SIDEBAR_TRANSITION,
|
||||||
|
transitionTiming: SIDEBAR_TRANSITION_TIMING,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,12 @@ export const HeaderTeamControls = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
startIcon={<Icon icon={Mail} />}
|
startIcon={
|
||||||
|
<Icon
|
||||||
|
icon={Mail}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
}
|
||||||
onClick={onInviteClick}
|
onClick={onInviteClick}
|
||||||
>
|
>
|
||||||
{t("common.buttons.inviteMember")}
|
{t("common.buttons.inviteMember")}
|
||||||
@@ -69,7 +74,12 @@ export const HeaderTeamControls = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
startIcon={<Icon icon={UserPlus} />}
|
startIcon={
|
||||||
|
<Icon
|
||||||
|
icon={UserPlus}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
}
|
||||||
onClick={onAddMemberClick}
|
onClick={onAddMemberClick}
|
||||||
>
|
>
|
||||||
{t("common.buttons.addMember")}
|
{t("common.buttons.addMember")}
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ import { useParams } from "react-router-dom";
|
|||||||
import { useGet } from "@/Hooks/UseApi";
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import type { ChecksSummary, ChecksResponse } from "@/Types/Check";
|
import type { ChecksSummary, ChecksResponse } from "@/Types/Check";
|
||||||
|
import type { DateRange } from "@/Types/Query";
|
||||||
|
|
||||||
const Checks = () => {
|
const Checks = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { monitorId } = useParams<{ monitorId?: string }>();
|
const { monitorId } = useParams<{ monitorId?: string }>();
|
||||||
|
|
||||||
const [selectedMonitor, setSelectedMonitor] = useState<string>(monitorId || "0");
|
const [selectedMonitor, setSelectedMonitor] = useState<string>(monitorId || "0");
|
||||||
const [dateRange, setDateRange] = useState<string>("recent");
|
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("down");
|
const [statusFilter, setStatusFilter] = useState<string>("down");
|
||||||
const [page, setPage] = useState<number>(0);
|
const [page, setPage] = useState<number>(0);
|
||||||
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
|
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
PageSpeedStrategies,
|
PageSpeedStrategies,
|
||||||
DnsRecordTypes,
|
DnsRecordTypes,
|
||||||
MonitorIntervalOptions,
|
MonitorIntervalOptions,
|
||||||
|
MIN_MONITOR_INTERVAL_MS,
|
||||||
DefaultMonitorMatchMethod,
|
DefaultMonitorMatchMethod,
|
||||||
MonitorMatchMethods,
|
MonitorMatchMethods,
|
||||||
GeoCheckIntervalOptions,
|
GeoCheckIntervalOptions,
|
||||||
@@ -50,6 +51,8 @@ import type { AppSettingsResponse } from "@/Types/Settings";
|
|||||||
import {
|
import {
|
||||||
stepFieldsFor,
|
stepFieldsFor,
|
||||||
monitorStepCount,
|
monitorStepCount,
|
||||||
|
isCaptureDockerUrl,
|
||||||
|
isDockerTlsUrl,
|
||||||
type MonitorFormData,
|
type MonitorFormData,
|
||||||
} from "@/Validation/monitor";
|
} from "@/Validation/monitor";
|
||||||
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
|
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
|
||||||
@@ -57,15 +60,62 @@ import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|||||||
import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField";
|
import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField";
|
||||||
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
|
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
|
||||||
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
|
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
|
||||||
|
import {
|
||||||
|
FormAutocompleteField,
|
||||||
|
type AutocompleteOption,
|
||||||
|
} from "@/Components/inputs/forms/FormAutoCompleteField";
|
||||||
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
|
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
|
||||||
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
|
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
|
||||||
import type { ProxyResponse } from "@/Types/Proxy";
|
import type { ProxyResponse } from "@/Types/Proxy";
|
||||||
|
import { formatDuration } from "@/Utils/TimeUtils";
|
||||||
|
|
||||||
const httpMethodOptions = HttpMethods.map((method) => ({
|
const httpMethodOptions = HttpMethods.map((method) => ({
|
||||||
value: method,
|
value: method,
|
||||||
label: method,
|
label: method,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const PEM_FIELD_ROWS = 6;
|
||||||
|
|
||||||
|
// Hides the pasted key while the field is not focused. Ignored by browsers
|
||||||
|
// without -webkit-text-security, which then show the key as plain text.
|
||||||
|
const maskedInputSx = {
|
||||||
|
"&:not(.Mui-focused) textarea": { WebkitTextSecurity: "disc" },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IntervalOption extends AutocompleteOption {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTERVAL_UNIT_MULTIPLIERS = [1000, 60000, 3600000, 86400000] as const;
|
||||||
|
|
||||||
|
const toIntervalOption = (
|
||||||
|
intervalMs: number,
|
||||||
|
locale: string | undefined
|
||||||
|
): IntervalOption => ({
|
||||||
|
id: intervalMs,
|
||||||
|
name: formatDuration(intervalMs, { long: true, locale }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterIntervalOptions = (
|
||||||
|
options: IntervalOption[],
|
||||||
|
inputValue: string,
|
||||||
|
locale: string | undefined
|
||||||
|
): IntervalOption[] => {
|
||||||
|
const input = inputValue.trim();
|
||||||
|
if (input === "" || options.some((option) => option.name === input)) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = Number(input);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return INTERVAL_UNIT_MULTIPLIERS.map((multiplier) => amount * multiplier)
|
||||||
|
.filter(Number.isSafeInteger)
|
||||||
|
.map((ms) => toIntervalOption(ms, locale));
|
||||||
|
};
|
||||||
|
|
||||||
interface GeneralSettingsConfig {
|
interface GeneralSettingsConfig {
|
||||||
urlLabel: string;
|
urlLabel: string;
|
||||||
urlPlaceholder: string;
|
urlPlaceholder: string;
|
||||||
@@ -118,8 +168,8 @@ const getGeneralSettingsConfig = (
|
|||||||
showDnsRecordType: false,
|
showDnsRecordType: false,
|
||||||
},
|
},
|
||||||
docker: {
|
docker: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.dockerHost.label"),
|
||||||
urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"),
|
urlPlaceholder: t("pages.createMonitor.form.general.option.dockerHost.placeholder"),
|
||||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||||
showUrl: true,
|
showUrl: true,
|
||||||
showProxy: false,
|
showProxy: false,
|
||||||
@@ -243,7 +293,7 @@ const getGeneralSettingsConfig = (
|
|||||||
|
|
||||||
const CreateMonitorPage = () => {
|
const CreateMonitorPage = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { monitorId } = useParams();
|
const { monitorId } = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -255,6 +305,7 @@ const CreateMonitorPage = () => {
|
|||||||
const firstSegment = pathSegments[0];
|
const firstSegment = pathSegments[0];
|
||||||
if (firstSegment === "pagespeed") return "pagespeed";
|
if (firstSegment === "pagespeed") return "pagespeed";
|
||||||
if (firstSegment === "infrastructure") return "hardware";
|
if (firstSegment === "infrastructure") return "hardware";
|
||||||
|
if (firstSegment === "docker") return "docker";
|
||||||
return "uptime";
|
return "uptime";
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
@@ -264,7 +315,9 @@ const CreateMonitorPage = () => {
|
|||||||
? "pagespeed"
|
? "pagespeed"
|
||||||
: pageType === "hardware"
|
: pageType === "hardware"
|
||||||
? "hardware"
|
? "hardware"
|
||||||
: "http";
|
: pageType === "docker"
|
||||||
|
? "docker"
|
||||||
|
: "http";
|
||||||
|
|
||||||
const { data: existingMonitor, refetch: refetchMonitor } = useGet<Monitor>(
|
const { data: existingMonitor, refetch: refetchMonitor } = useGet<Monitor>(
|
||||||
isEditMode ? `/monitors/${monitorId}` : null
|
isEditMode ? `/monitors/${monitorId}` : null
|
||||||
@@ -297,10 +350,13 @@ const CreateMonitorPage = () => {
|
|||||||
const showStep = (step: number) => isEditMode || currentStep === step;
|
const showStep = (step: number) => isEditMode || currentStep === step;
|
||||||
|
|
||||||
const watchedType = watch("type") as MonitorType;
|
const watchedType = watch("type") as MonitorType;
|
||||||
|
const watchedUrl = watch("url") as string;
|
||||||
|
const isCaptureDocker = watchedType === "docker" && isCaptureDockerUrl(watchedUrl);
|
||||||
const watchedMethod = watch("method") as HttpMethod | undefined;
|
const watchedMethod = watch("method") as HttpMethod | undefined;
|
||||||
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
|
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
|
||||||
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
|
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
|
||||||
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
|
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
|
||||||
|
const watchedInterval = watch("interval");
|
||||||
|
|
||||||
// Steps without an advanced section drop it, so the last step is the form's.
|
// Steps without an advanced section drop it, so the last step is the form's.
|
||||||
const totalSteps = monitorStepCount(watchedType);
|
const totalSteps = monitorStepCount(watchedType);
|
||||||
@@ -326,11 +382,20 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
}, [watchedType, showTypeSelector, reset, clearErrors]);
|
}, [watchedType, showTypeSelector, reset, clearErrors]);
|
||||||
|
|
||||||
|
// The logs switch is hidden for Capture hosts, so a value left over from a socket host must not be submitted.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCaptureDocker) {
|
||||||
|
setValue("dockerLogsEnabled", false);
|
||||||
|
}
|
||||||
|
}, [isCaptureDocker, setValue]);
|
||||||
|
|
||||||
const generalSettingsConfig = useMemo(
|
const generalSettingsConfig = useMemo(
|
||||||
() => getGeneralSettingsConfig(watchedType, t),
|
() => getGeneralSettingsConfig(watchedType, t),
|
||||||
[watchedType, t]
|
[watchedType, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pemInputSx = { fontFamily: theme.typography.fontFamilyMonospace };
|
||||||
|
|
||||||
const { post, loading: isCreating } = usePost<MonitorFormData, Monitor>();
|
const { post, loading: isCreating } = usePost<MonitorFormData, Monitor>();
|
||||||
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
|
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
|
||||||
const isSubmitting = isCreating || isUpdating;
|
const isSubmitting = isCreating || isUpdating;
|
||||||
@@ -373,6 +438,8 @@ const CreateMonitorPage = () => {
|
|||||||
navigate("/pagespeed");
|
navigate("/pagespeed");
|
||||||
} else if (pageType === "hardware") {
|
} else if (pageType === "hardware") {
|
||||||
navigate("/infrastructure");
|
navigate("/infrastructure");
|
||||||
|
} else if (pageType === "docker") {
|
||||||
|
navigate("/docker");
|
||||||
} else {
|
} else {
|
||||||
navigate("/uptime");
|
navigate("/uptime");
|
||||||
}
|
}
|
||||||
@@ -454,16 +521,18 @@ const CreateMonitorPage = () => {
|
|||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const intervalOptions = useMemo(
|
const intervalOptions = useMemo(() => {
|
||||||
() =>
|
const options = MonitorIntervalOptions.map(({ value }) =>
|
||||||
MonitorIntervalOptions.map((option) => ({
|
toIntervalOption(value, i18n.resolvedLanguage)
|
||||||
value: option.value,
|
);
|
||||||
label: t(
|
if (
|
||||||
`pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}`
|
typeof watchedInterval === "number" &&
|
||||||
),
|
!options.some((option) => option.id === watchedInterval)
|
||||||
})),
|
) {
|
||||||
[t]
|
options.push(toIntervalOption(watchedInterval, i18n.resolvedLanguage));
|
||||||
);
|
}
|
||||||
|
return options;
|
||||||
|
}, [watchedInterval, i18n.resolvedLanguage]);
|
||||||
|
|
||||||
const matchMethodOptions = useMemo(
|
const matchMethodOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -548,7 +617,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(0) && (
|
{showStep(0) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.general.title")}
|
title={t("pages.createMonitor.form.general.title")}
|
||||||
@@ -649,8 +717,8 @@ const CreateMonitorPage = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Secret field - only for hardware type */}
|
{/* Capture API secret - used by hardware and Capture-backed Docker monitors */}
|
||||||
{generalSettingsConfig.showSecret && (
|
{(generalSettingsConfig.showSecret || isCaptureDocker) && (
|
||||||
<FormTextField
|
<FormTextField
|
||||||
name="secret"
|
name="secret"
|
||||||
fieldLabel={t("pages.createMonitor.form.general.option.secret.label")}
|
fieldLabel={t("pages.createMonitor.form.general.option.secret.label")}
|
||||||
@@ -659,6 +727,12 @@ const CreateMonitorPage = () => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isCaptureDocker && (
|
||||||
|
<FormSwitchField
|
||||||
|
name="ignoreTlsErrors"
|
||||||
|
label={t("pages.createMonitor.form.ignoreTls.option.tls.label")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{generalSettingsConfig.showDnsServer && (
|
{generalSettingsConfig.showDnsServer && (
|
||||||
<FormTextField
|
<FormTextField
|
||||||
name="dnsServer"
|
name="dnsServer"
|
||||||
@@ -683,23 +757,83 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.createMonitor.form.dockerTls.title")}
|
||||||
|
subtitle={t("pages.createMonitor.form.dockerTls.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||||
|
<FormTextField
|
||||||
|
name="dockerTlsCa"
|
||||||
|
multiline
|
||||||
|
rows={PEM_FIELD_ROWS}
|
||||||
|
fieldLabel={t("pages.createMonitor.form.dockerTls.option.ca.label")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.createMonitor.form.dockerTls.option.ca.placeholder"
|
||||||
|
)}
|
||||||
|
slotProps={{ input: { sx: pemInputSx } }}
|
||||||
|
/>
|
||||||
|
<FormTextField
|
||||||
|
name="dockerTlsCert"
|
||||||
|
multiline
|
||||||
|
rows={PEM_FIELD_ROWS}
|
||||||
|
fieldLabel={t("pages.createMonitor.form.dockerTls.option.cert.label")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.createMonitor.form.dockerTls.option.cert.placeholder"
|
||||||
|
)}
|
||||||
|
slotProps={{ input: { sx: pemInputSx } }}
|
||||||
|
/>
|
||||||
|
<FormTextField
|
||||||
|
name="dockerTlsKey"
|
||||||
|
multiline
|
||||||
|
rows={PEM_FIELD_ROWS}
|
||||||
|
fieldLabel={t("pages.createMonitor.form.dockerTls.option.key.label")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.createMonitor.form.dockerTls.option.key.placeholder"
|
||||||
|
)}
|
||||||
|
helperText={
|
||||||
|
existingMonitor?.dockerTlsKeySet
|
||||||
|
? t("pages.createMonitor.form.dockerTls.option.key.stored")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
autoComplete="off"
|
||||||
|
slotProps={{ input: { sx: { ...pemInputSx, ...maskedInputSx } } }}
|
||||||
|
/>
|
||||||
|
<FormSwitchField
|
||||||
|
name="ignoreTlsErrors"
|
||||||
|
label={t("pages.createMonitor.form.dockerTls.option.ignoreTls.label")}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
sx={{ opacity: 0.8 }}
|
||||||
|
>
|
||||||
|
{t("pages.createMonitor.form.dockerTls.option.ignoreTls.description")}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.frequency.title")}
|
title={t("pages.createMonitor.form.frequency.title")}
|
||||||
subtitle={t("pages.createMonitor.form.frequency.description")}
|
subtitle={t("pages.createMonitor.form.frequency.description")}
|
||||||
rightContent={
|
rightContent={
|
||||||
<FormSelectField
|
<FormAutocompleteField
|
||||||
name="interval"
|
name="interval"
|
||||||
fieldLabel={t(
|
fieldLabel={t(
|
||||||
"pages.createMonitor.form.frequency.option.frequency.label"
|
"pages.createMonitor.form.frequency.option.frequency.label"
|
||||||
)}
|
)}
|
||||||
options={intervalOptions}
|
options={intervalOptions}
|
||||||
|
filterOptions={(options, { inputValue }) =>
|
||||||
|
filterIntervalOptions(options, inputValue, i18n.resolvedLanguage)
|
||||||
|
}
|
||||||
|
getOptionDisabled={(option) => option.id < MIN_MONITOR_INTERVAL_MS}
|
||||||
|
disableClearable
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alert Thresholds - only for hardware type */}
|
{/* Alert Thresholds - only for hardware type */}
|
||||||
{watchedType === "hardware" && showStep(1) && (
|
{watchedType === "hardware" && showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
@@ -755,7 +889,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.incidents.title")}
|
title={t("pages.createMonitor.form.incidents.title")}
|
||||||
@@ -782,7 +915,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.notifications.title")}
|
title={t("pages.createMonitor.form.notifications.title")}
|
||||||
@@ -795,7 +927,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.tags.title")}
|
title={t("pages.createMonitor.form.tags.title")}
|
||||||
@@ -822,7 +953,40 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showStep(1) && watchedType === "docker" && !isCaptureDocker && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.createMonitor.form.dockerLogs.title")}
|
||||||
|
subtitle={t("pages.createMonitor.form.dockerLogs.description")}
|
||||||
|
rightContent={
|
||||||
|
<FormSwitchField
|
||||||
|
name="dockerLogsEnabled"
|
||||||
|
label={t("pages.createMonitor.form.dockerLogs.option.enabled.label")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showStep(1) && watchedType === "docker" && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.createMonitor.form.dockerAlerts.title")}
|
||||||
|
subtitle={t("pages.createMonitor.form.dockerAlerts.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||||
|
<FormSwitchField
|
||||||
|
name="dockerAlertOnStopped"
|
||||||
|
label={t(
|
||||||
|
"pages.createMonitor.form.dockerAlerts.option.onStopped.label"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormSwitchField
|
||||||
|
name="dockerAlertOnUnhealthy"
|
||||||
|
label={t(
|
||||||
|
"pages.createMonitor.form.dockerAlerts.option.onUnhealthy.label"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showStep(2) &&
|
{showStep(2) &&
|
||||||
(watchedType === "http" ||
|
(watchedType === "http" ||
|
||||||
watchedType === "grpc" ||
|
watchedType === "grpc" ||
|
||||||
@@ -838,7 +1002,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(2) && watchedType === "http" && (
|
{showStep(2) && watchedType === "http" && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.advanced.title")}
|
title={t("pages.createMonitor.form.advanced.title")}
|
||||||
@@ -938,7 +1101,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(2) && supportsGeoCheck(watchedType) && (
|
{showStep(2) && supportsGeoCheck(watchedType) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.geoChecks.title")}
|
title={t("pages.createMonitor.form.geoChecks.title")}
|
||||||
@@ -971,7 +1133,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
justifyContent={!isEditMode ? "space-between" : "flex-end"}
|
justifyContent={!isEditMode ? "space-between" : "flex-end"}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
|
||||||
|
interface DashboardCardProps {
|
||||||
|
title: string;
|
||||||
|
topRight?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardCard = ({ title, topRight, children }: DashboardCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bgcolor={theme.palette.background.paper}
|
||||||
|
border={1}
|
||||||
|
borderColor={theme.palette.divider}
|
||||||
|
borderRadius={`${theme.shape.borderRadius}px`}
|
||||||
|
padding={theme.spacing(LAYOUT.MD)}
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
gap={theme.spacing(LAYOUT.SM)}
|
||||||
|
height="100%"
|
||||||
|
boxSizing="border-box"
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
fontWeight={500}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{topRight && (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.s}
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
>
|
||||||
|
{topRight}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Checkbox, Dialog } from "@/Components/inputs";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
|
||||||
|
|
||||||
|
interface EditCardsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
visibleCards: Set<DashboardCardKey>;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (visibleCards: Set<DashboardCardKey>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditCardsModal = ({
|
||||||
|
open,
|
||||||
|
visibleCards,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: EditCardsModalProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [cardsSelection, setCardsSelection] = useState<Set<DashboardCardKey>>(
|
||||||
|
new Set(visibleCards)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setCardsSelection(new Set(visibleCards));
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (key: DashboardCardKey) => {
|
||||||
|
setCardsSelection((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedCardKeys = useMemo(
|
||||||
|
() =>
|
||||||
|
[...dashboardCardKeys].sort((a, b) =>
|
||||||
|
t(`pages.dashboard.cards.${a}`).localeCompare(t(`pages.dashboard.cards.${b}`))
|
||||||
|
),
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title={t("pages.dashboard.editCardsModal.title")}
|
||||||
|
content={t("pages.dashboard.editCardsModal.description")}
|
||||||
|
onCancel={handleClose}
|
||||||
|
onConfirm={() => onSave(cardsSelection)}
|
||||||
|
confirmText={t("pages.dashboard.editCardsModal.done")}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
||||||
|
<Typography
|
||||||
|
variant="eyebrow"
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.editCardsModal.groups.uptime")}
|
||||||
|
</Typography>
|
||||||
|
{sortedCardKeys.map((key: DashboardCardKey) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={key}
|
||||||
|
label={t(`pages.dashboard.cards.${key}`)}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={cardsSelection.has(key)}
|
||||||
|
disabled={cardsSelection.has(key) && cardsSelection.size === 1}
|
||||||
|
onChange={() => handleToggle(key)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
fillRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProgressBar = ({ fillRatio }: ProgressBarProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const clamped = Math.min(1, Math.max(0, fillRatio));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
height={theme.spacing(LAYOUT.XS)}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
bgcolor={theme.palette.action.disabledBackground}
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
height="100%"
|
||||||
|
width={`${clamped * 100}%`}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
bgcolor={theme.palette.primary.main}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { ProgressBar } from "@/Pages/Dashboard/components/ProgressBar";
|
||||||
|
|
||||||
|
interface BarRowProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
fillRatio: number;
|
||||||
|
valueColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BarRow = ({ label, value, fillRatio, valueColor }: BarRowProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={theme.spacing(SPACING.LG)}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={valueColor ?? theme.palette.primary.main}
|
||||||
|
fontWeight={500}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<ProgressBar fillRatio={fillRatio} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
||||||
|
import { Dot } from "@/Components/design-elements";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
|
||||||
|
interface CurrentlyDownCardProps {
|
||||||
|
monitors: Monitor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrentlyDownCard = ({ monitors }: CurrentlyDownCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const downMonitors = monitors.filter((monitor) => monitor.status === "down");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.currentlyDown")}
|
||||||
|
topRight={downMonitors.length > 0 ? downMonitors.length : undefined}
|
||||||
|
>
|
||||||
|
{downMonitors.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.currentlyDown.empty")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{downMonitors.map((monitor) => (
|
||||||
|
<Stack
|
||||||
|
key={monitor.id}
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
|
>
|
||||||
|
<Dot color={theme.palette.error.main} />
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
overflow="hidden"
|
||||||
|
textOverflow="ellipsis"
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
>
|
||||||
|
{monitor.name}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
||||||
|
import { ProgressBar } from "@/Pages/Dashboard/components/ProgressBar";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { MonitorsSummary } from "@/Types/Monitor";
|
||||||
|
|
||||||
|
interface MonitorStatusCardProps {
|
||||||
|
summary: MonitorsSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MonitorStatusCard = ({ summary }: MonitorStatusCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const up = summary?.upMonitors ?? 0;
|
||||||
|
const total = summary?.totalMonitors ?? 0;
|
||||||
|
const ratio = total > 0 ? up / total : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard title={t("pages.dashboard.cards.monitorStatus")}>
|
||||||
|
{total === 0 ? (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.noData")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(SPACING.SM)}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="baseline"
|
||||||
|
gap={theme.spacing(SPACING.XS)}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.xxl}
|
||||||
|
fontWeight={500}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
>
|
||||||
|
{up}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.monitorStatus.ofUp", { total })}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<ProgressBar fillRatio={ratio} />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
||||||
|
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import type { MonitorTypeCount } from "@/Types/Monitor";
|
||||||
|
import { getMonitorTypeLabel } from "@/Types/Monitor";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
|
||||||
|
interface MonitorsByTypeCardProps {
|
||||||
|
monitorsByType: MonitorTypeCount[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MonitorsByTypeCard = ({ monitorsByType }: MonitorsByTypeCardProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const maxCount = monitorsByType[0]?.count ?? 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard title={t("pages.dashboard.cards.monitorsByType")}>
|
||||||
|
{monitorsByType.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.noData")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{monitorsByType.map(({ type, count }) => (
|
||||||
|
<BarRow
|
||||||
|
key={type}
|
||||||
|
label={getMonitorTypeLabel(type, t)}
|
||||||
|
value={String(count)}
|
||||||
|
fillRatio={count / maxCount}
|
||||||
|
valueColor={theme.palette.text.primary}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
|
import { Icon } from "@/Components/design-elements";
|
||||||
|
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
||||||
|
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
|
||||||
|
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
||||||
|
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { setDashboardUptimeSortOrder } from "@/Features/UI/uiSlice";
|
||||||
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
import type { AppDispatch, RootState } from "@/store";
|
||||||
|
|
||||||
|
const TOP_N = 5;
|
||||||
|
|
||||||
|
interface UptimeCardProps {
|
||||||
|
monitors: Monitor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UptimeCard = ({ monitors }: UptimeCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
const sortOrder = useSelector((state: RootState) => state.ui.dashboardUptimeSortOrder);
|
||||||
|
|
||||||
|
const direction = sortOrder === "ascending" ? 1 : -1;
|
||||||
|
const ranked = monitors
|
||||||
|
.filter((monitor) => monitor.status !== "paused")
|
||||||
|
.filter(
|
||||||
|
(monitor): monitor is Monitor & { uptimePercentage: number } =>
|
||||||
|
typeof monitor.uptimePercentage === "number"
|
||||||
|
)
|
||||||
|
.sort((a, b) => direction * (a.uptimePercentage - b.uptimePercentage))
|
||||||
|
.slice(0, TOP_N);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.uptime")}
|
||||||
|
topRight={
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={t("pages.dashboard.uptimeCard.toggleSortOrder")}
|
||||||
|
onClick={() =>
|
||||||
|
dispatch(
|
||||||
|
setDashboardUptimeSortOrder(
|
||||||
|
sortOrder === "ascending" ? "descending" : "ascending"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={sortOrder === "ascending" ? ArrowUp : ArrowDown}
|
||||||
|
size={16}
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ranked.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.noData")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{ranked.map((monitor) => {
|
||||||
|
const paletteKey = getUptimePercentageColor(monitor.uptimePercentage);
|
||||||
|
const color = theme.palette[paletteKey].main;
|
||||||
|
return (
|
||||||
|
<BarRow
|
||||||
|
key={monitor.id}
|
||||||
|
label={monitor.name}
|
||||||
|
value={formatPercentage(monitor.uptimePercentage)}
|
||||||
|
fillRatio={monitor.uptimePercentage}
|
||||||
|
valueColor={color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Fragment, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
|
||||||
|
import { BasePage } from "@/Components/design-elements/BasePage";
|
||||||
|
import { Button } from "@/Components/inputs";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { useMonitorListController } from "@/Hooks/useMonitorListController";
|
||||||
|
import { MonitorTypes, type MonitorTypeCount } from "@/Types/Monitor";
|
||||||
|
import { setDashboardVisibleCards } from "@/Features/UI/uiSlice";
|
||||||
|
import type { RootState, AppDispatch } from "@/store";
|
||||||
|
|
||||||
|
import { MonitorStatusCard } from "@/Pages/Dashboard/components/cards/MonitorStatusCard";
|
||||||
|
import { CurrentlyDownCard } from "@/Pages/Dashboard/components/cards/CurrentlyDownCard";
|
||||||
|
import { UptimeCard } from "@/Pages/Dashboard/components/cards/UptimeCard";
|
||||||
|
import { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard";
|
||||||
|
import { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal";
|
||||||
|
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const visibleCardsList = useSelector(
|
||||||
|
(state: RootState) => state.ui.dashboardVisibleCards
|
||||||
|
);
|
||||||
|
const visibleCards = useMemo(() => new Set(visibleCardsList), [visibleCardsList]);
|
||||||
|
|
||||||
|
const { monitors, summary } = useMonitorListController({
|
||||||
|
types: [...MonitorTypes],
|
||||||
|
checksLimit: 1,
|
||||||
|
refreshInterval: 30000,
|
||||||
|
rowsPerPageTable: "dashboard",
|
||||||
|
rowsPerPageDefault: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const monitorsByType = useMemo<MonitorTypeCount[]>(() => {
|
||||||
|
const countMap = new Map<MonitorTypeCount["type"], number>();
|
||||||
|
(monitors ?? []).forEach((m) =>
|
||||||
|
countMap.set(m.type, (countMap.get(m.type) ?? 0) + 1)
|
||||||
|
);
|
||||||
|
return [...countMap.entries()]
|
||||||
|
.map(([type, count]) => ({ type, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
}, [monitors]);
|
||||||
|
|
||||||
|
const cardComponents: Record<DashboardCardKey, ReactNode> = {
|
||||||
|
monitorStatus: <MonitorStatusCard summary={summary} />,
|
||||||
|
currentlyDown: <CurrentlyDownCard monitors={monitors ?? []} />,
|
||||||
|
uptime: <UptimeCard monitors={monitors ?? []} />,
|
||||||
|
monitorsByType: <MonitorsByTypeCard monitorsByType={monitorsByType} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BasePage headerKey="dashboard">
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="flex-end"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.editCards")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
display="grid"
|
||||||
|
gridTemplateColumns={{ xs: "1fr", md: "1fr 1fr" }}
|
||||||
|
gap={theme.spacing(LAYOUT.MD)}
|
||||||
|
>
|
||||||
|
{dashboardCardKeys
|
||||||
|
.filter((key) => visibleCards.has(key))
|
||||||
|
.map((key) => (
|
||||||
|
<Fragment key={key}>{cardComponents[key]}</Fragment>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<EditCardsModal
|
||||||
|
open={editOpen}
|
||||||
|
visibleCards={visibleCards}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
onSave={(next) => {
|
||||||
|
dispatch(setDashboardVisibleCards([...next]));
|
||||||
|
setEditOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</BasePage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { Dot } from "@/Components/design-elements";
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTheme } from "@mui/material";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { MS_PER_SECOND, MS_PER_MINUTE } from "@/Utils/TimeUtils";
|
||||||
|
|
||||||
|
interface HeaderLogsProps {
|
||||||
|
lines: number;
|
||||||
|
loading: boolean;
|
||||||
|
lastUpdatedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HeaderLogs = ({ lines, loading, lastUpdatedAt }: HeaderLogsProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastUpdatedAt === null) return;
|
||||||
|
setNow(Date.now());
|
||||||
|
const id = window.setInterval(() => setNow(Date.now()), MS_PER_SECOND);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [lastUpdatedAt]);
|
||||||
|
|
||||||
|
const renderStatus = () => {
|
||||||
|
if (loading) return t("pages.docker.container.logs.loading");
|
||||||
|
if (lastUpdatedAt === null) return null;
|
||||||
|
const ageMs = Math.max(0, now - lastUpdatedAt);
|
||||||
|
return ageMs < MS_PER_MINUTE
|
||||||
|
? t("pages.docker.container.logs.updatedSecondsAgo", {
|
||||||
|
count: Math.floor(ageMs / MS_PER_SECOND),
|
||||||
|
})
|
||||||
|
: t("pages.docker.container.logs.updatedMinutesAgo", {
|
||||||
|
count: Math.floor(ageMs / MS_PER_MINUTE),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={LAYOUT.MD}
|
||||||
|
>
|
||||||
|
<Typography>
|
||||||
|
{t("pages.docker.container.logs.recentLines", { count: lines })}
|
||||||
|
</Typography>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={SPACING.SM}
|
||||||
|
>
|
||||||
|
{lastUpdatedAt !== null && !loading && (
|
||||||
|
<Dot
|
||||||
|
color={theme.palette.success.main}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Typography color={theme.palette.text.secondary}>{renderStatus()}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import Grid from "@mui/material/Grid";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Divider from "@mui/material/Divider";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import { useTheme } from "@mui/material";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import type { DockerLogRow } from "@/Utils/MonitorUtils";
|
||||||
|
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
|
import type { RootState } from "@/store";
|
||||||
|
|
||||||
|
export const RowLog = ({ row }: { row: DockerLogRow }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
spacing={{ xs: 0, md: LAYOUT.MD }}
|
||||||
|
py={{ xs: LAYOUT.LG, md: 0 }}
|
||||||
|
>
|
||||||
|
<Grid size={{ xs: 12, md: "auto" }}>
|
||||||
|
<Typography
|
||||||
|
fontFamily="monospace"
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
>
|
||||||
|
{formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: "grow" }}>
|
||||||
|
<Typography
|
||||||
|
fontFamily={"monospace"}
|
||||||
|
whiteSpace={{ xs: "pre-wrap", md: "nowrap" }}
|
||||||
|
sx={{
|
||||||
|
overflowWrap: { xs: "anywhere", md: "normal" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component={"span"}
|
||||||
|
color={
|
||||||
|
row.stream === "stderr"
|
||||||
|
? theme.palette.error.main
|
||||||
|
: theme.palette.success.main
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.stream}
|
||||||
|
</Box>
|
||||||
|
: {row.text}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
{row.gapBefore ? (
|
||||||
|
<Stack
|
||||||
|
width="100%"
|
||||||
|
direction="row"
|
||||||
|
alignItems={"center"}
|
||||||
|
py={SPACING.MD}
|
||||||
|
>
|
||||||
|
<Typography pr={SPACING.MD}>
|
||||||
|
{t("pages.docker.container.logs.skipped")}
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ flex: 1 }} />
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Divider sx={{ display: { xs: "block", md: "none" } }} />
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import { StatBox } from "@/Components/design-elements";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import { useTheme } from "@mui/material";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { DockerContainerInfo } from "@/Types/Check";
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { formatDuration } from "@/Utils/TimeUtils";
|
||||||
|
import { PLACEHOLDER } from "@/Utils/FormatUtils";
|
||||||
|
import { getDockerPalette } from "@/Utils/MonitorUtils";
|
||||||
|
|
||||||
|
export const DockerContainerStatusBoxes = ({
|
||||||
|
container,
|
||||||
|
}: {
|
||||||
|
container: DockerContainerInfo | undefined;
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
if (!container) return null;
|
||||||
|
|
||||||
|
const { state, restartCount, health } = container;
|
||||||
|
const runningFor = container.startedAt
|
||||||
|
? Date.now() - new Date(container.startedAt).getTime()
|
||||||
|
: undefined;
|
||||||
|
const palette = getDockerPalette(state);
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
gap={theme.spacing(8)}
|
||||||
|
flexWrap={"wrap"}
|
||||||
|
>
|
||||||
|
<StatBox
|
||||||
|
title={t("common.labels.state")}
|
||||||
|
subtitle={state}
|
||||||
|
palette={palette}
|
||||||
|
/>
|
||||||
|
<StatBox
|
||||||
|
title={t("common.labels.uptime")}
|
||||||
|
subtitle={runningFor === undefined ? PLACEHOLDER : formatDuration(runningFor)}
|
||||||
|
/>
|
||||||
|
<StatBox
|
||||||
|
title={t("common.labels.restarts")}
|
||||||
|
subtitle={restartCount === undefined ? PLACEHOLDER : String(restartCount)}
|
||||||
|
/>
|
||||||
|
<StatBox
|
||||||
|
title={t("common.labels.health")}
|
||||||
|
subtitle={health}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// Components
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { BaseBox, EmptyState } from "@/Components/design-elements";
|
||||||
|
import { Button } from "@/Components/inputs";
|
||||||
|
import { HeaderLogs } from "@/Pages/Docker/ContainerDetails/Components/HeaderLogs";
|
||||||
|
import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { DockerContainerLogsResponse } from "@/Types/Monitor";
|
||||||
|
import type { DockerLog } from "@/Types/Check";
|
||||||
|
import type { ApiResponse } from "@/Hooks/UseApi";
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
} from "react";
|
||||||
|
import { useTheme } from "@mui/material";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useToast } from "@/Hooks/UseToast";
|
||||||
|
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { get } from "@/Utils/ApiClient";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { flattenDockerLogs } from "@/Utils/MonitorUtils";
|
||||||
|
|
||||||
|
interface TabLogsProps {
|
||||||
|
monitorId?: string;
|
||||||
|
containerName?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCROLL_PIN_THRESHOLD_PX = 8;
|
||||||
|
const TAIL_POLL_MS = 10_000;
|
||||||
|
|
||||||
|
const isScrolledToBottom = (el: HTMLElement) =>
|
||||||
|
el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_PIN_THRESHOLD_PX;
|
||||||
|
|
||||||
|
const fetchLogs = async (url: string, params: { before?: string; after?: string }) => {
|
||||||
|
const res = await get<ApiResponse<DockerContainerLogsResponse>>(url, { params });
|
||||||
|
return res.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { toastError } = useToast();
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
|
// logs is newest-first, matching the API and what flattenDockerLogs expects.
|
||||||
|
const [logs, setLogs] = useState<DockerLog[]>([]);
|
||||||
|
const [olderExhausted, setOlderExhausted] = useState(false);
|
||||||
|
const [loadingInitial, setLoadingInitial] = useState(false);
|
||||||
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||||
|
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const logsUrl =
|
||||||
|
isAdmin && enabled && monitorId && containerName
|
||||||
|
? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const newestCursor = logs[0]?.checkedAt;
|
||||||
|
const oldestCursor = logs[logs.length - 1]?.checkedAt;
|
||||||
|
|
||||||
|
// Initial page. Resets everything when the container changes.
|
||||||
|
useEffect(() => {
|
||||||
|
setLogs([]);
|
||||||
|
setOlderExhausted(false);
|
||||||
|
if (!logsUrl) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadingInitial(true);
|
||||||
|
fetchLogs(logsUrl, {})
|
||||||
|
.then((page) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLogs(page.logs);
|
||||||
|
setOlderExhausted(page.nextCursor === null);
|
||||||
|
setLastUpdatedAt(Date.now());
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
toastError(t("pages.docker.container.logs.loadError"));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoadingInitial(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [logsUrl, t, toastError]);
|
||||||
|
|
||||||
|
const newestCursorRef = useRef(newestCursor);
|
||||||
|
newestCursorRef.current = newestCursor;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!logsUrl) return;
|
||||||
|
let inFlight = false;
|
||||||
|
const tick = async () => {
|
||||||
|
if (inFlight || document.hidden) return;
|
||||||
|
inFlight = true;
|
||||||
|
try {
|
||||||
|
const cursor = newestCursorRef.current;
|
||||||
|
const page = await fetchLogs(logsUrl, cursor ? { after: cursor } : {});
|
||||||
|
if (page.logs.length > 0) setLogs((prev) => [...page.logs, ...prev]);
|
||||||
|
setLastUpdatedAt(Date.now());
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
inFlight = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const id = window.setInterval(tick, TAIL_POLL_MS);
|
||||||
|
document.addEventListener("visibilitychange", tick);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(id);
|
||||||
|
document.removeEventListener("visibilitychange", tick);
|
||||||
|
};
|
||||||
|
}, [logsUrl]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => flattenDockerLogs(logs), [logs]);
|
||||||
|
|
||||||
|
const logBoxRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pinnedToBottom = useRef(true);
|
||||||
|
const prependAnchor = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
|
||||||
|
|
||||||
|
// Always pin to bottom when container/monitor changes
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
pinnedToBottom.current = true;
|
||||||
|
}, [monitorId, containerName]);
|
||||||
|
|
||||||
|
// Rows changed: restore position after a prepend, otherwise honour the pin
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = logBoxRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const anchor = prependAnchor.current;
|
||||||
|
if (anchor) {
|
||||||
|
prependAnchor.current = null;
|
||||||
|
el.scrollTop = el.scrollHeight - anchor.scrollHeight + anchor.scrollTop;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pinnedToBottom.current) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
pinnedToBottom.current = isScrolledToBottom(event.currentTarget);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadOlder = useCallback(async () => {
|
||||||
|
if (!logsUrl || !oldestCursor || loadingOlder) return;
|
||||||
|
setLoadingOlder(true);
|
||||||
|
try {
|
||||||
|
const page = await fetchLogs(logsUrl, { before: oldestCursor });
|
||||||
|
const el = logBoxRef.current;
|
||||||
|
if (el)
|
||||||
|
prependAnchor.current = {
|
||||||
|
scrollHeight: el.scrollHeight,
|
||||||
|
scrollTop: el.scrollTop,
|
||||||
|
};
|
||||||
|
setLogs((prev) => [...prev, ...page.logs]);
|
||||||
|
setOlderExhausted(page.nextCursor === null);
|
||||||
|
} catch {
|
||||||
|
toastError(t("pages.docker.container.logs.loadOlderError"));
|
||||||
|
} finally {
|
||||||
|
setLoadingOlder(false);
|
||||||
|
}
|
||||||
|
}, [logsUrl, oldestCursor, loadingOlder, t, toastError]);
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={t("pages.docker.container.logs.forbidden.title")}
|
||||||
|
description={t("pages.docker.container.logs.forbidden.description")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={t("pages.docker.container.logs.disabled.title")}
|
||||||
|
description={t("pages.docker.container.logs.disabled.description")}
|
||||||
|
actionText={t("pages.docker.container.logs.disabled.action")}
|
||||||
|
actionTo={`/docker/configure/${monitorId}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastUpdatedAt !== null && rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={t("pages.docker.container.logs.empty.title")}
|
||||||
|
description={t("pages.docker.container.logs.empty.description")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={LAYOUT.MD}>
|
||||||
|
<HeaderLogs
|
||||||
|
lines={rows.length}
|
||||||
|
loading={loadingInitial}
|
||||||
|
lastUpdatedAt={lastUpdatedAt}
|
||||||
|
/>
|
||||||
|
<BaseBox
|
||||||
|
ref={logBoxRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
minHeight={240}
|
||||||
|
maxHeight="50vh"
|
||||||
|
overflow="auto"
|
||||||
|
padding={LAYOUT.MD}
|
||||||
|
sx={{ overflowAnchor: "none" }}
|
||||||
|
>
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="center"
|
||||||
|
pb={LAYOUT.MD}
|
||||||
|
>
|
||||||
|
{olderExhausted ? (
|
||||||
|
<Typography color={theme.palette.text.secondary}>
|
||||||
|
{t("pages.docker.container.logs.noOlder")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={loadingOlder}
|
||||||
|
onClick={loadOlder}
|
||||||
|
>
|
||||||
|
{loadingOlder
|
||||||
|
? t("pages.docker.container.logs.loadingOlder")
|
||||||
|
: t("pages.docker.container.logs.loadOlder")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
{rows.map((row) => {
|
||||||
|
return (
|
||||||
|
<RowLog
|
||||||
|
key={row.key}
|
||||||
|
row={row}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</BaseBox>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Components
|
||||||
|
import Grid from "@mui/material/Grid";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import { HistogramDockerContainer } from "@/Components/monitors/charts/HistogramDockerContainer";
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import { Typography, useTheme } from "@mui/material";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { Theme } from "@mui/material";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import type { DockerContainerStats } from "@/Types/Monitor";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { formatPercentage, PLACEHOLDER } from "@/Utils/FormatUtils";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import { BaseBox } from "@/Components/design-elements";
|
||||||
|
import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
|
||||||
|
import type { DateRange } from "@/Types/Query";
|
||||||
|
|
||||||
|
const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [
|
||||||
|
{
|
||||||
|
title: t("common.charts.labels.cpuUsage"),
|
||||||
|
key: "avgCpuPct",
|
||||||
|
color: theme.palette.success.main,
|
||||||
|
rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0),
|
||||||
|
formatter: formatPercentage,
|
||||||
|
labelKey: "common.charts.histogram.avgCpuUsage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("common.charts.labels.memoryUsage"),
|
||||||
|
key: "avgMemoryUsedBytes",
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
|
||||||
|
formatter: prettyBytes,
|
||||||
|
labelKey: "common.charts.histogram.avgMemoryUsage",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TabOverview = ({
|
||||||
|
stats,
|
||||||
|
range,
|
||||||
|
}: {
|
||||||
|
stats: DockerContainerStats;
|
||||||
|
range: DateRange;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
const { ports, mounts } = stats?.latest?.container ?? {};
|
||||||
|
const dedupedPorts = dedupeDockerPorts(ports ?? []);
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
spacing={LAYOUT.MD}
|
||||||
|
>
|
||||||
|
{getChartConfigs(theme, t, stats).map((config) => {
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
key={config.key}
|
||||||
|
size={isSmall ? 12 : 6}
|
||||||
|
>
|
||||||
|
<HistogramDockerContainer
|
||||||
|
title={config.title}
|
||||||
|
rightTitle={config.rightTitle}
|
||||||
|
stats={stats}
|
||||||
|
dataKey={config.key}
|
||||||
|
strokeColor={config.color}
|
||||||
|
gradientStartColor={config.color}
|
||||||
|
range={range}
|
||||||
|
formatter={config.formatter}
|
||||||
|
labelKey={config.labelKey}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Grid size={isSmall ? 12 : 6}>
|
||||||
|
<BaseBox
|
||||||
|
padding={LAYOUT.MD}
|
||||||
|
minHeight={200}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="eyebrow"
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
textTransform={"uppercase"}
|
||||||
|
>
|
||||||
|
Ports
|
||||||
|
</Typography>
|
||||||
|
<Stack>
|
||||||
|
{dedupedPorts.length === 0 && <Typography>{PLACEHOLDER}</Typography>}
|
||||||
|
{dedupedPorts.map((port) => {
|
||||||
|
const containerPort = `${port.privatePort}/${port.protocol}`;
|
||||||
|
const label =
|
||||||
|
port.publicPort === undefined
|
||||||
|
? containerPort
|
||||||
|
: `${port.hostIp ?? "0.0.0.0"}:${port.publicPort} -> ${containerPort}`;
|
||||||
|
return (
|
||||||
|
<Typography key={`${port.hostIp}/${port.publicPort}/${containerPort}`}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</BaseBox>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={isSmall ? 12 : 6}>
|
||||||
|
<BaseBox
|
||||||
|
padding={LAYOUT.MD}
|
||||||
|
minHeight={200}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="eyebrow"
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
textTransform={"uppercase"}
|
||||||
|
>
|
||||||
|
Volumes
|
||||||
|
</Typography>
|
||||||
|
<Stack>
|
||||||
|
{!mounts?.length && <Typography>{PLACEHOLDER}</Typography>}
|
||||||
|
{mounts?.map((mount) => {
|
||||||
|
return (
|
||||||
|
<Typography key={`${mount.source}/${mount.destination}`}>
|
||||||
|
{`${getDockerMountLabel(mount)} -> ${mount.destination}`}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</BaseBox>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Components
|
||||||
|
import { BasePage, Tabs, Tab, DockerStateLabel } from "@/Components/design-elements";
|
||||||
|
import { TabOverview } from "@/Pages/Docker/ContainerDetails/TabOverview";
|
||||||
|
import { TabLogs } from "@/Pages/Docker/ContainerDetails/TabLogs";
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
|
import type { DockerContainerResponse } from "@/Types/Monitor";
|
||||||
|
import { DockerContainerStatusBoxes } from "@/Pages/Docker/ContainerDetails/Components/StatBoxes";
|
||||||
|
import type { DateRange } from "@/Types/Query";
|
||||||
|
import { HeaderTimeRange } from "@/Components/common";
|
||||||
|
interface DockerContainerDetailsProps {
|
||||||
|
open?: "overview" | "logs";
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAB_MAP = {
|
||||||
|
overview: 0,
|
||||||
|
logs: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<number>(TAB_MAP[open]);
|
||||||
|
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||||
|
const { monitorId, containerName } = useParams<{
|
||||||
|
monitorId: string;
|
||||||
|
containerName: string;
|
||||||
|
}>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const containerDetailsUrl = monitorId
|
||||||
|
? `/monitors/docker/details/${monitorId}/containers/${containerName}?dateRange=${dateRange}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const { data: containerData } = useGet<DockerContainerResponse>(
|
||||||
|
containerDetailsUrl,
|
||||||
|
{},
|
||||||
|
{ refreshInterval: 10000, keepPreviousData: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const monitor = containerData?.monitor;
|
||||||
|
if (!monitor) return null;
|
||||||
|
|
||||||
|
const stats = containerData.stats;
|
||||||
|
|
||||||
|
const container = stats?.latest?.container;
|
||||||
|
if (!container) return null;
|
||||||
|
|
||||||
|
const { state, name, image } = container;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BasePage headerKey="docker.container">
|
||||||
|
<DockerStateLabel
|
||||||
|
state={state}
|
||||||
|
name={name}
|
||||||
|
image={image}
|
||||||
|
/>
|
||||||
|
<DockerContainerStatusBoxes container={container} />
|
||||||
|
<HeaderTimeRange
|
||||||
|
dateRange={dateRange}
|
||||||
|
setDateRange={setDateRange}
|
||||||
|
/>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(_, newValue: number) => setActiveTab(newValue)}
|
||||||
|
>
|
||||||
|
<Tab label={t("pages.docker.container.tabs.overview")} />
|
||||||
|
<Tab label={t("pages.docker.container.tabs.logs")} />
|
||||||
|
</Tabs>
|
||||||
|
{activeTab === 0 && (
|
||||||
|
<TabOverview
|
||||||
|
stats={stats}
|
||||||
|
range={dateRange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 1 && (
|
||||||
|
<TabLogs
|
||||||
|
monitorId={monitorId}
|
||||||
|
containerName={containerName}
|
||||||
|
enabled={monitor.dockerLogsEnabled ?? false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</BasePage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DockerDetailsPage;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { DockerStateLabel, Table } from "@/Components/design-elements";
|
||||||
|
// Hooks
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
|
import type { DockerContainerInfo } from "@/Types/Check";
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { formatPercentage, PLACEHOLDER } from "@/Utils/FormatUtils";
|
||||||
|
|
||||||
|
interface DockerContainersTableProps {
|
||||||
|
monitorId: string | undefined;
|
||||||
|
containers: DockerContainerInfo[];
|
||||||
|
onRowClick?: (container: DockerContainerInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockerContainersTable = ({
|
||||||
|
monitorId,
|
||||||
|
containers,
|
||||||
|
}: DockerContainersTableProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const getHeaders = () => {
|
||||||
|
// const renderSortIcon = (isActive: boolean) => (
|
||||||
|
// <Box
|
||||||
|
// width={16}
|
||||||
|
// display="inline-flex"
|
||||||
|
// justifyContent="center"
|
||||||
|
// >
|
||||||
|
// {isActive ? (
|
||||||
|
// sortOrder === "asc" ? (
|
||||||
|
// <ArrowUp size={16} />
|
||||||
|
// ) : (
|
||||||
|
// <ArrowDown size={16} />
|
||||||
|
// )
|
||||||
|
// ) : null}
|
||||||
|
// </Box>
|
||||||
|
// );
|
||||||
|
const headers: Header<DockerContainerInfo>[] = [
|
||||||
|
{
|
||||||
|
id: "container",
|
||||||
|
content: t("pages.docker.host.table.headers.container"),
|
||||||
|
render: (row) => {
|
||||||
|
return (
|
||||||
|
<DockerStateLabel
|
||||||
|
state={row.state}
|
||||||
|
name={row.name}
|
||||||
|
image={row.image}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
content: t("common.table.headers.status"),
|
||||||
|
render: (row) => {
|
||||||
|
return <Typography>{row.state}</Typography>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "health",
|
||||||
|
content: t("common.labels.health"),
|
||||||
|
render: (row) => {
|
||||||
|
return <Typography>{row.health}</Typography>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cpu",
|
||||||
|
content: t("pages.docker.host.table.headers.cpu"),
|
||||||
|
render: (row) => {
|
||||||
|
return <Typography>{formatPercentage(row.cpuPct ?? 0)}</Typography>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "memory",
|
||||||
|
content: t("pages.docker.host.table.headers.memory"),
|
||||||
|
render: (row) => {
|
||||||
|
return <Typography>{formatPercentage(row.memoryPct ?? 0)}</Typography>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "restarts",
|
||||||
|
content: t("common.labels.restarts"),
|
||||||
|
render: (row) => {
|
||||||
|
return <Typography>{row.restartCount ?? PLACEHOLDER}</Typography>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
let headers = getHeaders();
|
||||||
|
|
||||||
|
if (isSmall) {
|
||||||
|
headers = headers.filter((h) => h.id !== "histogram");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Table
|
||||||
|
headers={headers}
|
||||||
|
data={containers}
|
||||||
|
onRowClick={(row) => {
|
||||||
|
navigate(`/docker/host/${monitorId}/container/${row.name}`);
|
||||||
|
}}
|
||||||
|
// getRowSx={(row) => ({
|
||||||
|
// backgroundColor: isRowSelected(row.id)
|
||||||
|
// ? theme.palette.action.selected
|
||||||
|
// : "inherit",
|
||||||
|
// })}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import { StatBox } from "@/Components/design-elements";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { DockerStats } from "@/Types/Monitor";
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import { useTheme } from "@mui/material";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
|
||||||
|
export const DockerStatusBoxes = ({ stats }: { stats: DockerStats | undefined }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
const { total, running, stopped, unhealthy } = stats.latest?.summary ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
gap={theme.spacing(8)}
|
||||||
|
flexWrap={"wrap"}
|
||||||
|
>
|
||||||
|
<StatBox
|
||||||
|
title={t("pages.docker.host.statBoxes.containers")}
|
||||||
|
subtitle={String(total)}
|
||||||
|
/>
|
||||||
|
<StatBox
|
||||||
|
title={t("pages.docker.host.statBoxes.running")}
|
||||||
|
subtitle={String(running)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatBox
|
||||||
|
title={t("pages.docker.host.statBoxes.stopped")}
|
||||||
|
subtitle={String(stopped)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatBox
|
||||||
|
title={t("pages.docker.host.statBoxes.unhealthy")}
|
||||||
|
subtitle={String(unhealthy)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { BasePage } from "@/Components/design-elements";
|
||||||
|
import { HeaderMonitorControls, MonitorStatBoxes } from "@/Components/monitors";
|
||||||
|
import { DockerStatusBoxes } from "@/Pages/Docker/HostDetails/Components/StatBoxes";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { DockerDetailsResponse } from "@/Types/Monitor";
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
|
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { DockerContainersTable } from "@/Pages/Docker/HostDetails/Components/DockerContainersTable";
|
||||||
|
|
||||||
|
const DockerHostDetailsPage = () => {
|
||||||
|
const { monitorId } = useParams<{ monitorId: string }>();
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
|
// The containers list renders the latest check only, so no date range is
|
||||||
|
// offered; the endpoint's dateRange param only shapes the aggregate buckets.
|
||||||
|
const monitorDetailsUrl = monitorId ? `/monitors/docker/details/${monitorId}` : null;
|
||||||
|
|
||||||
|
const { data: monitorDetailsData, refetch: refetchMonitor } =
|
||||||
|
useGet<DockerDetailsResponse>(
|
||||||
|
monitorDetailsUrl,
|
||||||
|
{},
|
||||||
|
{ refreshInterval: 10000, keepPreviousData: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const monitor = monitorDetailsData?.monitor;
|
||||||
|
const monitorStats = monitorDetailsData?.monitorStats ?? null;
|
||||||
|
const stats = monitorDetailsData?.stats;
|
||||||
|
const containers = stats?.latest?.containers ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BasePage>
|
||||||
|
<HeaderMonitorControls
|
||||||
|
path="docker"
|
||||||
|
monitor={monitor}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
refetch={refetchMonitor}
|
||||||
|
/>
|
||||||
|
<MonitorStatBoxes
|
||||||
|
monitor={monitor}
|
||||||
|
monitorStats={monitorStats}
|
||||||
|
/>
|
||||||
|
<DockerStatusBoxes stats={stats} />
|
||||||
|
<DockerContainersTable
|
||||||
|
monitorId={monitorId}
|
||||||
|
containers={containers}
|
||||||
|
/>
|
||||||
|
</BasePage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DockerHostDetailsPage;
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Pagination,
|
||||||
|
StatusLabel,
|
||||||
|
ColoredLabel,
|
||||||
|
} from "@/Components/design-elements";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
|
||||||
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
|
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
||||||
|
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { usePost } from "@/Hooks/UseApi";
|
||||||
|
|
||||||
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
|
import type { DockerContainerSummary } from "@/Types/Check";
|
||||||
|
import { Checkbox } from "@/Components/inputs";
|
||||||
|
|
||||||
|
const summaryOf = (monitor: Monitor): DockerContainerSummary | undefined => {
|
||||||
|
const recentChecks = monitor.recentChecks ?? [];
|
||||||
|
return recentChecks[recentChecks.length - 1]?.containerSummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DockerMonitorsTableProps {
|
||||||
|
monitors: Monitor[];
|
||||||
|
tags: Tag[];
|
||||||
|
refetch: () => void;
|
||||||
|
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||||
|
sortField: string;
|
||||||
|
sortOrder: SortOrder;
|
||||||
|
setSort: (field: string, order: SortOrder) => void;
|
||||||
|
count: number;
|
||||||
|
page: number;
|
||||||
|
setPage: (page: number) => void;
|
||||||
|
rowsPerPage: number;
|
||||||
|
setRowsPerPage: (rowsPerPage: number) => void;
|
||||||
|
selectedRows?: string[];
|
||||||
|
onSelectionChange?: (selected: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockerMonitorsTable = ({
|
||||||
|
monitors,
|
||||||
|
tags,
|
||||||
|
refetch,
|
||||||
|
setSelectedMonitor,
|
||||||
|
sortField,
|
||||||
|
sortOrder,
|
||||||
|
setSort,
|
||||||
|
count,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
rowsPerPage,
|
||||||
|
setRowsPerPage,
|
||||||
|
selectedRows = [],
|
||||||
|
onSelectionChange,
|
||||||
|
}: DockerMonitorsTableProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
const navigate = useNavigate();
|
||||||
|
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,
|
||||||
|
newPage: number
|
||||||
|
) => {
|
||||||
|
setPage(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowsPerPageChange = (
|
||||||
|
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
|
||||||
|
) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
setRowsPerPage(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||||
|
setSort(nextSort.field, nextSort.order);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
label: t("pages.common.monitors.actions.details"),
|
||||||
|
action: () => {
|
||||||
|
navigate(`${monitor.id}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
label: t("pages.common.monitors.actions.incidents"),
|
||||||
|
action: () => {
|
||||||
|
navigate(`/incidents?monitorId=${monitor.id}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
label: t("pages.common.monitors.actions.configure"),
|
||||||
|
action: () => {
|
||||||
|
navigate(`/docker/configure/${monitor.id}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
label:
|
||||||
|
monitor.isActive === false
|
||||||
|
? t("common.buttons.resume")
|
||||||
|
: t("common.buttons.pause"),
|
||||||
|
action: async () => {
|
||||||
|
await post(`/monitors/pause/${monitor.id}`, {});
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
closeMenu: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
label: (
|
||||||
|
<Typography color={theme.palette.error.main}>
|
||||||
|
{t("common.buttons.delete")}
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
|
action: () => {
|
||||||
|
setSelectedMonitor(monitor);
|
||||||
|
},
|
||||||
|
closeMenu: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHeaders = () => {
|
||||||
|
const renderSortIcon = (isActive: boolean) => (
|
||||||
|
<Box
|
||||||
|
width={16}
|
||||||
|
display="inline-flex"
|
||||||
|
justifyContent="center"
|
||||||
|
>
|
||||||
|
{isActive ? (
|
||||||
|
sortOrder === "asc" ? (
|
||||||
|
<ArrowUp size={16} />
|
||||||
|
) : (
|
||||||
|
<ArrowDown size={16} />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</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("pages.docker.table.headers.host"),
|
||||||
|
content: (
|
||||||
|
<Typography
|
||||||
|
component="div"
|
||||||
|
display="inline-flex"
|
||||||
|
alignItems="center"
|
||||||
|
gap={theme.spacing(4)}
|
||||||
|
onClick={(e) => handleSort(e, "name")}
|
||||||
|
sx={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
{t("pages.docker.table.headers.host")}
|
||||||
|
{renderSortIcon(sortField === "name")}
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
|
render: (row) => {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Typography variant="body2">{row.name}</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
>
|
||||||
|
{row.url}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
mobileLabel: t("common.table.headers.status"),
|
||||||
|
content: (
|
||||||
|
<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")}
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
render: (row) => {
|
||||||
|
return <StatusLabel status={row.status} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "containers",
|
||||||
|
content: t("pages.docker.table.headers.containers"),
|
||||||
|
render: (row) => {
|
||||||
|
const summary = summaryOf(row);
|
||||||
|
return (
|
||||||
|
<Typography variant="body2">
|
||||||
|
{summary
|
||||||
|
? t("pages.docker.table.containersRunning", {
|
||||||
|
running: summary.running,
|
||||||
|
total: summary.total,
|
||||||
|
})
|
||||||
|
: "—"}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tags",
|
||||||
|
content: t("common.table.headers.tags"),
|
||||||
|
render: (row) => {
|
||||||
|
if (row.tags.length === 0) return "-";
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
justifyContent={"center"}
|
||||||
|
direction={isSmall ? "column" : "row"}
|
||||||
|
gap={theme.spacing(SPACING.LG)}
|
||||||
|
>
|
||||||
|
{row.tags.map((tagId) => {
|
||||||
|
const fullTag = tags.find((t) => t.id === tagId);
|
||||||
|
if (!fullTag) return null;
|
||||||
|
return (
|
||||||
|
<ColoredLabel
|
||||||
|
key={fullTag.id}
|
||||||
|
text={fullTag.name}
|
||||||
|
color={fullTag.color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
content: t("common.table.headers.actions"),
|
||||||
|
render: (row) => {
|
||||||
|
return <ActionsMenu items={getActions(row)} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
let headers = getHeaders();
|
||||||
|
|
||||||
|
if (isSmall) {
|
||||||
|
headers = headers.filter((h) => h.id !== "histogram");
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Table
|
||||||
|
headers={headers}
|
||||||
|
data={monitors}
|
||||||
|
onRowClick={(row) => {
|
||||||
|
navigate(`/docker/host/${row.id}`);
|
||||||
|
}}
|
||||||
|
isRowSelected={(row) => isRowSelected(row.id)}
|
||||||
|
/>
|
||||||
|
<Pagination
|
||||||
|
component="div"
|
||||||
|
count={count}
|
||||||
|
page={page}
|
||||||
|
rowsPerPage={rowsPerPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onRowsPerPageChange={handleRowsPerPageChange}
|
||||||
|
itemsOnPage={monitors.length}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { MonitorListPage } from "@/Components/monitors";
|
||||||
|
import { DockerMonitorsTable } from "@/Pages/Docker/Monitors/Components/DockerMonitorsTable";
|
||||||
|
import { useMonitorListController } from "@/Hooks/useMonitorListController";
|
||||||
|
|
||||||
|
const DockerMonitorsPage = () => {
|
||||||
|
const c = useMonitorListController({
|
||||||
|
types: ["docker"],
|
||||||
|
checksLimit: 25,
|
||||||
|
refreshInterval: 5000,
|
||||||
|
rowsPerPageTable: "docker",
|
||||||
|
rowsPerPageDefault: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MonitorListPage
|
||||||
|
headerKey="docker"
|
||||||
|
page="docker"
|
||||||
|
actionLink="/docker/create"
|
||||||
|
controller={c}
|
||||||
|
bulkActions
|
||||||
|
showTypeFilter={false}
|
||||||
|
summaryProps={{ showBreached: true }}
|
||||||
|
>
|
||||||
|
<DockerMonitorsTable
|
||||||
|
monitors={c.monitors || []}
|
||||||
|
tags={c.tags || []}
|
||||||
|
refetch={c.refetch}
|
||||||
|
setSelectedMonitor={c.setSelectedMonitor}
|
||||||
|
count={c.count || 0}
|
||||||
|
page={c.page}
|
||||||
|
setPage={c.setPage}
|
||||||
|
rowsPerPage={c.rowsPerPage}
|
||||||
|
sortField={c.sortField}
|
||||||
|
sortOrder={c.sortOrder}
|
||||||
|
setSort={c.setSort}
|
||||||
|
setRowsPerPage={c.handleSetRowsPerPage}
|
||||||
|
selectedRows={c.selectedRows}
|
||||||
|
onSelectionChange={c.setSelectedRows}
|
||||||
|
/>
|
||||||
|
</MonitorListPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DockerMonitorsPage;
|
||||||
@@ -30,6 +30,7 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
|
|||||||
direction="row"
|
direction="row"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
gap={theme.spacing(2)}
|
gap={theme.spacing(2)}
|
||||||
|
sx={{ "& svg": { color: theme.palette.text.secondary } }}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<Typography variant="body1">{label}</Typography>
|
<Typography variant="body1">{label}</Typography>
|
||||||
@@ -143,7 +144,10 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
|
|||||||
gap: theme.spacing(2),
|
gap: theme.spacing(2),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon={Globe} />
|
<Icon
|
||||||
|
icon={Globe}
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
/>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body1"
|
variant="body1"
|
||||||
fontWeight={500}
|
fontWeight={500}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useState, useEffect, useMemo } from "react";
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident";
|
import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident";
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
import type { DateRange } from "@/Types/Query";
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
|
|
||||||
const IncidentsPage = () => {
|
const IncidentsPage = () => {
|
||||||
@@ -30,7 +31,7 @@ const IncidentsPage = () => {
|
|||||||
// Filter state
|
// Filter state
|
||||||
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
|
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
|
||||||
const [filter, setFilter] = useState("all");
|
const [filter, setFilter] = useState("all");
|
||||||
const [dateRange, setDateRange] = useState("recent");
|
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||||
const [activeIncidentsPage, setActiveIncidentsPage] = useState(0);
|
const [activeIncidentsPage, setActiveIncidentsPage] = useState(0);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const formatTemp = (value: number) => `${value.toFixed(0)}°C`;
|
|||||||
const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
|
const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: t("pages.infrastructure.charts.labels.memory"),
|
title: t("common.charts.labels.memoryUsage"),
|
||||||
type: "memory",
|
type: "memory",
|
||||||
dataKeys: ["avgMemoryUsage"],
|
dataKeys: ["avgMemoryUsage"],
|
||||||
strokeColor: theme.palette.primary.main,
|
strokeColor: theme.palette.primary.main,
|
||||||
@@ -22,7 +22,7 @@ const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
|
|||||||
idx: null,
|
idx: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("pages.infrastructure.charts.labels.cpu"),
|
title: t("common.charts.labels.cpuUsage"),
|
||||||
type: "cpu",
|
type: "cpu",
|
||||||
dataKeys: ["avgCpuUsage"],
|
dataKeys: ["avgCpuUsage"],
|
||||||
strokeColor: theme.palette.success.main,
|
strokeColor: theme.palette.success.main,
|
||||||
|
|||||||
@@ -63,19 +63,42 @@ const getChartConfigs = (
|
|||||||
return configs;
|
return configs;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Loopback / bridge names Capture sees when it can only observe the container's
|
// Loopback / bridge names Capture reports when it can only observe the
|
||||||
// own network namespace. When ALL reported interfaces match this set we assume
|
// container's own network namespace.
|
||||||
// the "no real interfaces visible" case and surface the notice.
|
|
||||||
const CONTAINER_ONLY_IFACE_NAMES = new Set(["lo", "lo0", "docker0"]);
|
const CONTAINER_ONLY_IFACE_NAMES = new Set(["lo", "lo0", "docker0"]);
|
||||||
|
|
||||||
|
// Below this, an interface is carrying no traffic worth charting. Real host
|
||||||
|
// NICs sit orders of magnitude above it; a container's veth sits at or near
|
||||||
|
// zero.
|
||||||
|
const IDLE_INTERFACE_BYTES_PER_SECOND = 1024;
|
||||||
|
|
||||||
|
const interfaceIsIdle = (checks: HardwareCheckStats[], name: string): boolean =>
|
||||||
|
checks.every((check) => {
|
||||||
|
const iface = check.net?.find((candidate) => candidate.name === name);
|
||||||
|
if (!iface) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(iface.bytesSentPerSecond || 0) < IDLE_INTERFACE_BYTES_PER_SECOND &&
|
||||||
|
(iface.deltaBytesRecv || 0) < IDLE_INTERFACE_BYTES_PER_SECOND
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const onlyContainerVisibleInterfaces = (checks: HardwareCheckStats[]): boolean => {
|
const onlyContainerVisibleInterfaces = (checks: HardwareCheckStats[]): boolean => {
|
||||||
const named = new Set<string>();
|
const named = new Set<string>();
|
||||||
checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name)));
|
checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name)));
|
||||||
if (named.size === 0) return false;
|
if (named.size === 0) {
|
||||||
for (const name of named) {
|
return false;
|
||||||
if (!CONTAINER_ONLY_IFACE_NAMES.has(name)) return false;
|
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
const names = [...named];
|
||||||
|
if (names.every((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!names.some((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return names.every((name) => interfaceIsIdle(checks, name));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InfraNetworkCharts = ({
|
export const InfraNetworkCharts = ({
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useTheme } from "@mui/material";
|
|||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
import type { CheckSnapshot } from "@/Types/Check";
|
||||||
|
|
||||||
|
const GAUGE_MAX_WIDTH = 260;
|
||||||
|
|
||||||
export const InfraDetailsGauges = ({
|
export const InfraDetailsGauges = ({
|
||||||
snapshot,
|
snapshot,
|
||||||
}: {
|
}: {
|
||||||
@@ -26,37 +28,42 @@ export const InfraDetailsGauges = ({
|
|||||||
direction={isSmall ? "column" : "row"}
|
direction={isSmall ? "column" : "row"}
|
||||||
spacing={theme.spacing(8)}
|
spacing={theme.spacing(8)}
|
||||||
alignItems={"stretch"}
|
alignItems={"stretch"}
|
||||||
|
flexWrap={"wrap"}
|
||||||
|
useFlexGap
|
||||||
>
|
>
|
||||||
<DetailGauge
|
<DetailGauge
|
||||||
title={t("pages.infrastructure.gauges.memory.title")}
|
title={t("pages.infrastructure.gauges.memory.title")}
|
||||||
maxWidth={260}
|
maxWidth={GAUGE_MAX_WIDTH}
|
||||||
progress={(snapshot?.memory?.usage_percent || 0) * 100}
|
progress={(snapshot?.memory?.usage_percent || 0) * 100}
|
||||||
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
|
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
|
||||||
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
|
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
|
||||||
lowerLabel={t("pages.infrastructure.gauges.memory.lowerLabel")}
|
lowerLabel={t("pages.infrastructure.gauges.memory.lowerLabel")}
|
||||||
lowerValue={prettyBytes(snapshot?.memory?.total_bytes || 0)}
|
lowerValue={prettyBytes(snapshot?.memory?.total_bytes || 0)}
|
||||||
|
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
|
||||||
/>
|
/>
|
||||||
<DetailGauge
|
<DetailGauge
|
||||||
title={t("pages.infrastructure.gauges.cpu.title")}
|
title={t("pages.infrastructure.gauges.cpu.title")}
|
||||||
maxWidth={260}
|
maxWidth={GAUGE_MAX_WIDTH}
|
||||||
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
|
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
|
||||||
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
|
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
|
||||||
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
|
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
|
||||||
lowerLabel={t("pages.infrastructure.gauges.cpu.lowerLabel")}
|
lowerLabel={t("pages.infrastructure.gauges.cpu.lowerLabel")}
|
||||||
lowerValue={getFrequency(snapshot?.cpu?.frequency || 0)}
|
lowerValue={getFrequency(snapshot?.cpu?.frequency || 0)}
|
||||||
|
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
|
||||||
/>
|
/>
|
||||||
{snapshot?.disk?.map((disk, idx) => {
|
{snapshot?.disk?.map((disk, idx) => {
|
||||||
return (
|
return (
|
||||||
<DetailGauge
|
<DetailGauge
|
||||||
key={disk?.device || 0 + idx}
|
key={disk?.device || `disk-${idx}`}
|
||||||
// title={`Disk ${idx} usage`}
|
// title={`Disk ${idx} usage`}
|
||||||
title={t("pages.infrastructure.gauges.disk.title", { idx })}
|
title={t("pages.infrastructure.gauges.disk.title", { idx })}
|
||||||
maxWidth={260}
|
maxWidth={GAUGE_MAX_WIDTH}
|
||||||
progress={(disk.usage_percent || 0) * 100}
|
progress={(disk.usage_percent || 0) * 100}
|
||||||
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
|
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
|
||||||
upperValue={prettyBytes(disk?.used_bytes || 0)}
|
upperValue={prettyBytes(disk?.used_bytes || 0)}
|
||||||
lowerLabel={t("pages.infrastructure.gauges.disk.lowerLabel")}
|
lowerLabel={t("pages.infrastructure.gauges.disk.lowerLabel")}
|
||||||
lowerValue={prettyBytes(disk?.total_bytes || 0)}
|
lowerValue={prettyBytes(disk?.total_bytes || 0)}
|
||||||
|
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useGet } from "@/Hooks/UseApi";
|
|||||||
import type { HardwareDetailsResponse } from "@/Types/Monitor";
|
import type { HardwareDetailsResponse } from "@/Types/Monitor";
|
||||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { DateRange } from "@/Types/Query";
|
||||||
|
|
||||||
const InfrastructureDetails = () => {
|
const InfrastructureDetails = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -17,7 +18,7 @@ const InfrastructureDetails = () => {
|
|||||||
|
|
||||||
const { monitorId } = useParams<{ monitorId: string }>();
|
const { monitorId } = useParams<{ monitorId: string }>();
|
||||||
|
|
||||||
const [dateRange, setDateRange] = useState<string>("recent");
|
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||||
const [selectedTab, setSelectedTab] = useState<number>(0);
|
const [selectedTab, setSelectedTab] = useState<number>(0);
|
||||||
|
|
||||||
const monitorDetailsUrl = useMemo(() => {
|
const monitorDetailsUrl = useMemo(() => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ColoredLabel,
|
ColoredLabel,
|
||||||
} from "@/Components/design-elements";
|
} from "@/Components/design-elements";
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
||||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||||
@@ -21,6 +22,7 @@ import { usePost } from "@/Hooks/UseApi";
|
|||||||
|
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import { Checkbox } from "@/Components/inputs";
|
import { Checkbox } from "@/Components/inputs";
|
||||||
|
|
||||||
interface InfraMonitorsTableProps {
|
interface InfraMonitorsTableProps {
|
||||||
@@ -29,9 +31,8 @@ interface InfraMonitorsTableProps {
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||||
sortField: string;
|
sortField: string;
|
||||||
setSortField: (field: string) => void;
|
sortOrder: SortOrder;
|
||||||
sortOrder: "asc" | "desc";
|
setSort: (field: string, order: SortOrder) => void;
|
||||||
setSortOrder: (order: "asc" | "desc") => void;
|
|
||||||
count: number;
|
count: number;
|
||||||
page: number;
|
page: number;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
@@ -47,9 +48,8 @@ export const InfraMonitorsTable = ({
|
|||||||
refetch,
|
refetch,
|
||||||
setSelectedMonitor,
|
setSelectedMonitor,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
count,
|
count,
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
@@ -100,14 +100,8 @@ export const InfraMonitorsTable = ({
|
|||||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
setSort(nextSort.field, nextSort.order);
|
||||||
setSortOrder(newOrder);
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortOrder("asc");
|
|
||||||
}
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||||
@@ -331,11 +325,7 @@ export const InfraMonitorsTable = ({
|
|||||||
onRowClick={(row) => {
|
onRowClick={(row) => {
|
||||||
navigate(`/infrastructure/${row.id}`);
|
navigate(`/infrastructure/${row.id}`);
|
||||||
}}
|
}}
|
||||||
getRowSx={(row) => ({
|
isRowSelected={(row) => isRowSelected(row.id)}
|
||||||
backgroundColor: isRowSelected(row.id)
|
|
||||||
? theme.palette.action.selected
|
|
||||||
: "inherit",
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
<Pagination
|
<Pagination
|
||||||
component="div"
|
component="div"
|
||||||
|
|||||||
@@ -1,274 +1,43 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
import { MonitorListPage } from "@/Components/monitors";
|
||||||
import Typography from "@mui/material/Typography";
|
import { InfraMonitorsTable } from "@/Pages/Infrastructure/Monitors/Components/MonitorsTable";
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
||||||
import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
|
|
||||||
import { HeaderCreate } from "@/Components/common";
|
|
||||||
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 { useMonitorListController } from "@/Hooks/useMonitorListController";
|
||||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
|
||||||
import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor";
|
|
||||||
import type { Tag } from "@/Types/Tag";
|
|
||||||
import { useTheme } from "@mui/material";
|
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
|
||||||
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 InfrastructureMonitors = () => {
|
||||||
const { t } = useTranslation();
|
const c = useMonitorListController({
|
||||||
const theme = useTheme();
|
types: ["hardware"],
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
checksLimit: 1,
|
||||||
const isAdmin = useIsAdmin();
|
refreshInterval: 5000,
|
||||||
const dispatch = useDispatch();
|
rowsPerPageTable: "infrastructure",
|
||||||
|
rowsPerPageDefault: 5,
|
||||||
// Redux state
|
});
|
||||||
const rowsPerPage = useSelector(
|
|
||||||
(state: RootState) => state.ui?.infrastructure?.rowsPerPage ?? 5
|
|
||||||
);
|
|
||||||
|
|
||||||
// Local state
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string>("");
|
|
||||||
const [selectedState, setSelectedState] = useState<string>("");
|
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
|
||||||
const [search, setSearch] = useState<string>("");
|
|
||||||
const [page, setPage] = useState<number>(0);
|
|
||||||
const [sortField, setSortField] = useState<string>("");
|
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
|
||||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
|
||||||
|
|
||||||
const { data: tags } = useGet<Tag[]>("/tags/team");
|
|
||||||
|
|
||||||
const isDialogOpen = Boolean(selectedMonitor);
|
|
||||||
|
|
||||||
const debouncedSearch = useDebounce<string>(search, 300);
|
|
||||||
|
|
||||||
const handleClearFilters = useCallback(() => {
|
|
||||||
setSelectedStatus("");
|
|
||||||
setSelectedState("");
|
|
||||||
setSelectedTags([]);
|
|
||||||
setSearch("");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Convert filter selections to API filter values
|
|
||||||
const toFilterStatus = useMemo(() => {
|
|
||||||
if (selectedStatus === "up") return "true";
|
|
||||||
if (selectedStatus === "down") return "false";
|
|
||||||
return undefined;
|
|
||||||
}, [selectedStatus]);
|
|
||||||
|
|
||||||
const toFilterActive = useMemo(() => {
|
|
||||||
if (selectedState === "active") return "true";
|
|
||||||
if (selectedState === "paused") return "false";
|
|
||||||
return undefined;
|
|
||||||
}, [selectedState]);
|
|
||||||
|
|
||||||
// Determine field and filter for the API request
|
|
||||||
// Priority: status > isActive > search
|
|
||||||
const filterLookup = new Map<string | undefined, string>([
|
|
||||||
[toFilterStatus, "status"],
|
|
||||||
[toFilterActive, "isActive"],
|
|
||||||
]);
|
|
||||||
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
|
|
||||||
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
|
|
||||||
const filter = activeFilter?.[0] || debouncedSearch || undefined;
|
|
||||||
|
|
||||||
// Build URL for monitors with checks
|
|
||||||
const monitorsWithChecksUrl = useMemo(() => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append("type", "hardware");
|
|
||||||
selectedTags.forEach((tagId) => params.append("tags", tagId));
|
|
||||||
params.append("limit", "1");
|
|
||||||
if (page !== undefined) params.append("page", String(page));
|
|
||||||
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
|
|
||||||
if (filter) params.append("filter", filter);
|
|
||||||
if (field) params.append("field", field);
|
|
||||||
if (sortOrder) params.append("order", sortOrder);
|
|
||||||
return `/monitors/team/with-checks?${params.toString()}`;
|
|
||||||
}, [page, rowsPerPage, filter, field, sortOrder, selectedTags]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: monitorsWithChecksData,
|
|
||||||
isLoading: monitorsWithChecksLoading,
|
|
||||||
error: monitorsWithChecksError,
|
|
||||||
refetch,
|
|
||||||
} = useGet<MonitorsWithChecksResponse>(
|
|
||||||
monitorsWithChecksUrl,
|
|
||||||
{},
|
|
||||||
{ refreshInterval: 5000, keepPreviousData: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
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 || selectedTags.length > 0 || search
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show empty state only when there are truly no monitors (not just filtered out)
|
|
||||||
const effectiveTotalCount =
|
|
||||||
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
|
|
||||||
? 1
|
|
||||||
: (summary?.totalMonitors ?? 0);
|
|
||||||
|
|
||||||
// Delete hook
|
|
||||||
const { deleteFn, loading: isDeleting } = useDelete();
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
if (!selectedMonitor) return;
|
|
||||||
await deleteFn(`/monitors/${selectedMonitor.id}`);
|
|
||||||
setSelectedMonitor(null);
|
|
||||||
refetch();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
setSelectedMonitor(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MonitorBasePageWithStates
|
<MonitorListPage
|
||||||
headerKey="infrastructure"
|
headerKey="infrastructure"
|
||||||
loading={isLoading}
|
|
||||||
error={monitorsWithChecksError}
|
|
||||||
totalCount={effectiveTotalCount}
|
|
||||||
page="infrastructure"
|
page="infrastructure"
|
||||||
actionLink="/infrastructure/create"
|
actionLink="/infrastructure/create"
|
||||||
|
controller={c}
|
||||||
|
bulkActions
|
||||||
|
showTypeFilter={false}
|
||||||
|
summaryProps={{ showBreached: true }}
|
||||||
>
|
>
|
||||||
<HeaderCreate
|
|
||||||
path="/infrastructure/create"
|
|
||||||
isLoading={isLoading}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
/>
|
|
||||||
<HeaderMonitorsSummary
|
|
||||||
summary={summary}
|
|
||||||
showBreached={true}
|
|
||||||
/>
|
|
||||||
<Stack
|
|
||||||
direction={isSmall ? "column" : "row"}
|
|
||||||
justifyContent={isSmall ? "flex-start" : "space-between"}
|
|
||||||
gap={theme.spacing(4)}
|
|
||||||
>
|
|
||||||
<ControlsFilter
|
|
||||||
showTypes={false}
|
|
||||||
selectedStatus={selectedStatus}
|
|
||||||
setSelectedStatus={setSelectedStatus}
|
|
||||||
selectedState={selectedState}
|
|
||||||
setSelectedState={setSelectedState}
|
|
||||||
tagOptions={tags ?? []}
|
|
||||||
selectedTags={selectedTags}
|
|
||||||
setSelectedTags={setSelectedTags}
|
|
||||||
onClearFilters={handleClearFilters}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
placeholder={t("pages.uptime.filters.search.placeholder")}
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => {
|
|
||||||
setSearch(event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{selectedTags.length > 0 && (
|
|
||||||
<Stack
|
|
||||||
direction={isSmall ? "column" : "row"}
|
|
||||||
alignItems={isSmall ? "flex-start" : "center"}
|
|
||||||
flexWrap="wrap"
|
|
||||||
gap={theme.spacing(SPACING.XL)}
|
|
||||||
>
|
|
||||||
<Typography color={theme.palette.text.secondary}>
|
|
||||||
{t("pages.uptime.filters.activeTags")}
|
|
||||||
</Typography>
|
|
||||||
{selectedTags.map((tagId) => {
|
|
||||||
const tag = tags?.find((t) => t.id === tagId);
|
|
||||||
if (!tag) return null;
|
|
||||||
return (
|
|
||||||
<ColoredLabel
|
|
||||||
key={tag.id}
|
|
||||||
text={tag.name}
|
|
||||||
color={tag.color}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</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
|
<InfraMonitorsTable
|
||||||
monitors={monitorsWithChecksData?.monitors || []}
|
monitors={c.monitors || []}
|
||||||
tags={tags ?? []}
|
tags={c.tags || []}
|
||||||
refetch={refetch}
|
refetch={c.refetch}
|
||||||
setSelectedMonitor={setSelectedMonitor}
|
setSelectedMonitor={c.setSelectedMonitor}
|
||||||
sortField={sortField}
|
count={c.count || 0}
|
||||||
setSortField={setSortField}
|
page={c.page}
|
||||||
sortOrder={sortOrder}
|
setPage={c.setPage}
|
||||||
setSortOrder={setSortOrder}
|
rowsPerPage={c.rowsPerPage}
|
||||||
count={count || 0}
|
sortField={c.sortField}
|
||||||
page={page}
|
sortOrder={c.sortOrder}
|
||||||
setPage={setPage}
|
setSort={c.setSort}
|
||||||
rowsPerPage={rowsPerPage}
|
setRowsPerPage={c.handleSetRowsPerPage}
|
||||||
setRowsPerPage={(value: number) => {
|
selectedRows={c.selectedRows}
|
||||||
dispatch(
|
onSelectionChange={c.setSelectedRows}
|
||||||
setRowsPerPage({
|
|
||||||
value,
|
|
||||||
table: "infrastructure",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
selectedRows={selectedRows}
|
|
||||||
onSelectionChange={setSelectedRows}
|
|
||||||
/>
|
/>
|
||||||
<Dialog
|
</MonitorListPage>
|
||||||
open={isDialogOpen}
|
|
||||||
title={t("common.dialogs.delete.title")}
|
|
||||||
content={t("common.dialogs.delete.description")}
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
loading={isDeleting}
|
|
||||||
/>
|
|
||||||
</MonitorBasePageWithStates>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import { DetailGauge } from "@/Components/design-elements";
|
import { DetailGauge } from "@/Components/design-elements";
|
||||||
|
|
||||||
import { getPercentage, formatPercentageFromWhole } from "@/Utils/FormatUtils";
|
import { getPercentage, formatPercentageFromWhole, PLACEHOLDER } from "@/Utils/FormatUtils";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
@@ -11,7 +11,6 @@ interface StatGaugesProps {
|
|||||||
diagnostics: Diagnostics | null;
|
diagnostics: Diagnostics | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLACEHOLDER = "—";
|
|
||||||
|
|
||||||
export const StatGauges = ({ diagnostics }: StatGaugesProps) => {
|
export const StatGauges = ({ diagnostics }: StatGaugesProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { useTheme } from "@mui/material";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Diagnostics } from "@/Types/Diagnostics";
|
import type { Diagnostics } from "@/Types/Diagnostics";
|
||||||
import { formatDuration, formatMs } from "@/Utils/TimeUtils";
|
import { formatDuration, formatMs } from "@/Utils/TimeUtils";
|
||||||
|
import { PLACEHOLDER } from "@/Utils/FormatUtils";
|
||||||
|
|
||||||
interface StatsProps {
|
interface StatsProps {
|
||||||
diagnostics: Diagnostics | null;
|
diagnostics: Diagnostics | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLACEHOLDER = "—";
|
|
||||||
|
|
||||||
export const Stats = ({ diagnostics }: StatsProps) => {
|
export const Stats = ({ diagnostics }: StatsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
const LogsPage = () => {
|
const LogsPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<number>(1);
|
const [activeTab, setActiveTab] = useState<number>(0);
|
||||||
return (
|
return (
|
||||||
<BasePage headerKey="logs">
|
<BasePage headerKey="logs">
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import { logger } from "@/Utils/logger";
|
import { logger } from "@/Utils/logger";
|
||||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
import { BasePage, ColoredLabel, ConfigBox } from "@/Components/design-elements";
|
||||||
import { Button } from "@/Components/inputs";
|
import { Button } from "@/Components/inputs";
|
||||||
|
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
@@ -15,6 +16,7 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
|||||||
import { mutate } from "swr";
|
import { mutate } from "swr";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
import type { Tag } from "@/Types/Tag";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, FormProvider } from "react-hook-form";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||||
@@ -41,6 +43,7 @@ const CreateMaintenanceWindowPage = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { data: monitors } = useGet<Monitor[]>("/monitors/team");
|
const { data: monitors } = useGet<Monitor[]>("/monitors/team");
|
||||||
|
const { data: tags } = useGet<Tag[]>("/tags/team");
|
||||||
|
|
||||||
const { post, loading: isPosting } = usePost();
|
const { post, loading: isPosting } = usePost();
|
||||||
const { patch, loading: isPatching } = usePatch();
|
const { patch, loading: isPatching } = usePatch();
|
||||||
@@ -203,6 +206,26 @@ const CreateMaintenanceWindowPage = () => {
|
|||||||
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
|
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
|
||||||
)}
|
)}
|
||||||
options={monitors ?? []}
|
options={monitors ?? []}
|
||||||
|
renderOptionContent={(monitor) => (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={theme.spacing(2)}
|
||||||
|
flexWrap="wrap"
|
||||||
|
>
|
||||||
|
<Typography>{monitor.name}</Typography>
|
||||||
|
{monitor.tags.map((tagId) => {
|
||||||
|
const tag = tags?.find(({ id }) => id === tagId);
|
||||||
|
return tag ? (
|
||||||
|
<ColoredLabel
|
||||||
|
key={tag.id}
|
||||||
|
text={tag.name}
|
||||||
|
color={tag.color}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import NotFoundSvg from "@/assets/Images/sushi_404.svg";
|
import NotFoundSvg from "@/assets/Images/sushi_404.svg?react";
|
||||||
import { Button } from "@/Components/inputs";
|
import { Button } from "@/Components/inputs";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
@@ -36,10 +36,11 @@ const NotFoundPage = ({ title, desc }: NotFoundProps) => {
|
|||||||
alignItems="center"
|
alignItems="center"
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component={NotFoundSvg}
|
||||||
src={NotFoundSvg}
|
role="img"
|
||||||
alt="404"
|
aria-label="404"
|
||||||
maxHeight={"25rem"}
|
width="100%"
|
||||||
|
maxHeight="25rem"
|
||||||
/>
|
/>
|
||||||
<Typography
|
<Typography
|
||||||
component="h1"
|
component="h1"
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ const NotificationsCreatePage = () => {
|
|||||||
{watchedType !== "matrix" &&
|
{watchedType !== "matrix" &&
|
||||||
watchedType !== "telegram" &&
|
watchedType !== "telegram" &&
|
||||||
watchedType !== "pushover" &&
|
watchedType !== "pushover" &&
|
||||||
|
watchedType !== "signalgrid" &&
|
||||||
watchedType !== "twilio" &&
|
watchedType !== "twilio" &&
|
||||||
watchedType !== "ntfy" && (
|
watchedType !== "ntfy" && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
@@ -201,6 +202,31 @@ const NotificationsCreatePage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{watchedType === "signalgrid" && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.notifications.form.signalgrid.title")}
|
||||||
|
subtitle={t("pages.notifications.form.signalgrid.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack spacing={theme.spacing(8)}>
|
||||||
|
<FormTextField
|
||||||
|
name="accessToken"
|
||||||
|
fieldLabel={t("pages.notifications.form.signalgrid.optionClientKey")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.notifications.form.signalgrid.placeholderClientKey"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormTextField
|
||||||
|
name="address"
|
||||||
|
fieldLabel={t("pages.notifications.form.signalgrid.optionChannel")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.notifications.form.signalgrid.placeholderChannel"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{watchedType === "twilio" && (
|
{watchedType === "twilio" && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.notifications.form.twilio.title")}
|
title={t("pages.notifications.form.twilio.title")}
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ import { useGet } from "@/Hooks/UseApi";
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
import type { DateRange } from "@/Types/Query";
|
||||||
|
|
||||||
const PageSpeedDetails = () => {
|
const PageSpeedDetails = () => {
|
||||||
const { monitorId } = useParams();
|
const { monitorId } = useParams();
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [dateRange, setDateRange] = useState<string>("day");
|
const [dateRange, setDateRange] = useState<DateRange>("day");
|
||||||
|
|
||||||
const monitorDetailsUrl = useMemo(() => {
|
const monitorDetailsUrl = useMemo(() => {
|
||||||
if (!monitorId) {
|
if (!monitorId) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ColoredLabel,
|
ColoredLabel,
|
||||||
} from "@/Components/design-elements";
|
} from "@/Components/design-elements";
|
||||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
|
||||||
import { HistogramPageSpeed } from "@/Components/monitors";
|
import { HistogramPageSpeed } from "@/Components/monitors";
|
||||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
@@ -22,6 +23,7 @@ import { usePatch } from "@/Hooks/UseApi";
|
|||||||
|
|
||||||
import type { Monitor, MonitorWithChecks } from "@/Types/Monitor";
|
import type { Monitor, MonitorWithChecks } from "@/Types/Monitor";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import type { ActionMenuItem } from "@/Components/actions-menu";
|
import type { ActionMenuItem } from "@/Components/actions-menu";
|
||||||
|
|
||||||
export const PageSpeedMonitorsTable = ({
|
export const PageSpeedMonitorsTable = ({
|
||||||
@@ -30,9 +32,8 @@ export const PageSpeedMonitorsTable = ({
|
|||||||
refetch,
|
refetch,
|
||||||
setSelectedMonitor,
|
setSelectedMonitor,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
count,
|
count,
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
@@ -44,9 +45,8 @@ export const PageSpeedMonitorsTable = ({
|
|||||||
refetch: Function;
|
refetch: Function;
|
||||||
setSelectedMonitor: Function;
|
setSelectedMonitor: Function;
|
||||||
sortField: string;
|
sortField: string;
|
||||||
setSortField: (field: string) => void;
|
sortOrder: SortOrder;
|
||||||
sortOrder: "asc" | "desc";
|
setSort: (field: string, order: SortOrder) => void;
|
||||||
setSortOrder: (order: "asc" | "desc") => void;
|
|
||||||
count: number;
|
count: number;
|
||||||
page: number;
|
page: number;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
@@ -81,14 +81,8 @@ export const PageSpeedMonitorsTable = ({
|
|||||||
const handleSort = (e: any, field: string) => {
|
const handleSort = (e: any, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
setSort(nextSort.field, nextSort.order);
|
||||||
setSortOrder(newOrder);
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortOrder("asc");
|
|
||||||
}
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||||
|
|||||||
@@ -1,229 +1,56 @@
|
|||||||
import {
|
import { PageSpeedKeyPriorityFallback } from "@/Components/design-elements";
|
||||||
MonitorBasePageWithStates,
|
|
||||||
PageSpeedKeyPriorityFallback,
|
|
||||||
ColoredLabel,
|
|
||||||
} from "@/Components/design-elements";
|
|
||||||
import { Dialog, TextField } from "@/Components/inputs";
|
|
||||||
import { HeaderCreate } from "@/Components/common";
|
|
||||||
import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable";
|
import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable";
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
|
||||||
import type { Tag } from "@/Types/Tag";
|
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
||||||
import { useTheme } from "@mui/material";
|
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||||
import { useGet, useDelete } from "@/Hooks/UseApi";
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
import useDebounce from "@/Hooks/useDebounce";
|
|
||||||
import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
|
|
||||||
import type { AppSettingsResponse } from "@/Types/Settings";
|
import type { AppSettingsResponse } from "@/Types/Settings";
|
||||||
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
|
import { MonitorListPage } from "@/Components/monitors";
|
||||||
|
import { useMonitorListController } from "@/Hooks/useMonitorListController";
|
||||||
|
|
||||||
const PageSpeedMonitorsPage = () => {
|
const PageSpeedMonitorsPage = () => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const theme = useTheme();
|
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
const { deleteFn, loading: isDeleting } = useDelete();
|
|
||||||
|
|
||||||
const settingsUrl = "/settings";
|
|
||||||
const {
|
const {
|
||||||
data: settingsData,
|
data: settingsData,
|
||||||
isLoading: settingsIsLoading,
|
isLoading: settingsIsLoading,
|
||||||
error: settingsError,
|
error: settingsError,
|
||||||
} = useGet<AppSettingsResponse>(settingsUrl);
|
} = useGet<AppSettingsResponse>("/settings");
|
||||||
|
|
||||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
const c = useMonitorListController({
|
||||||
const isDialogOpen = Boolean(selectedMonitor);
|
types: ["pagespeed"],
|
||||||
const [sortField, setSortField] = useState<string>("name");
|
checksLimit: 25,
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
refreshInterval: 30000,
|
||||||
const [page, setPage] = useState(0);
|
rowsPerPageTable: "pagespeed",
|
||||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
rowsPerPageDefault: 10,
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
initialSortField: "name",
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string>("");
|
});
|
||||||
const [selectedState, setSelectedState] = useState<string>("");
|
|
||||||
const [search, setSearch] = useState<string>("");
|
|
||||||
|
|
||||||
const debouncedSearch = useDebounce<string>(search, 300);
|
|
||||||
|
|
||||||
// Status: pass "up"/"down" directly to the API
|
|
||||||
// State: "active" -> true, "paused" -> false
|
|
||||||
const toFilterStatus = useMemo(() => {
|
|
||||||
if (selectedStatus === "up") return "up";
|
|
||||||
if (selectedStatus === "down") return "down";
|
|
||||||
return undefined;
|
|
||||||
}, [selectedStatus]);
|
|
||||||
|
|
||||||
const toFilterActive = useMemo(() => {
|
|
||||||
if (selectedState === "active") return "true";
|
|
||||||
if (selectedState === "paused") return "false";
|
|
||||||
return undefined;
|
|
||||||
}, [selectedState]);
|
|
||||||
|
|
||||||
// Priority: status > isActive > search > sort
|
|
||||||
const filterLookup = new Map<string | undefined, string>([
|
|
||||||
[toFilterStatus, "status"],
|
|
||||||
[toFilterActive, "isActive"],
|
|
||||||
]);
|
|
||||||
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
|
|
||||||
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
|
|
||||||
const filter = activeFilter?.[0] || debouncedSearch;
|
|
||||||
|
|
||||||
const { data: tags } = useGet<Tag[]>("/tags/team");
|
|
||||||
|
|
||||||
const monitorsWithChecksUrl = useMemo(() => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append("type", "pagespeed");
|
|
||||||
selectedTags.forEach((tagId) => params.append("tags", tagId));
|
|
||||||
params.append("limit", "25");
|
|
||||||
if (page !== undefined) params.append("page", String(page));
|
|
||||||
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
|
|
||||||
if (filter) params.append("filter", filter);
|
|
||||||
if (field) params.append("field", field);
|
|
||||||
if (sortOrder) params.append("order", sortOrder);
|
|
||||||
return `/monitors/team/with-checks?${params.toString()}`;
|
|
||||||
}, [page, rowsPerPage, sortOrder, selectedTags, filter, field]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: monitorsData,
|
|
||||||
isLoading: monitorsIsLoading,
|
|
||||||
error: monitorsError,
|
|
||||||
refetch,
|
|
||||||
} = useGet<MonitorsWithChecksResponse>(
|
|
||||||
monitorsWithChecksUrl,
|
|
||||||
{},
|
|
||||||
{ refreshInterval: 30000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const monitors = monitorsData?.monitors;
|
|
||||||
const monitorsCount = monitorsData?.count ?? 0;
|
|
||||||
const summary = monitorsData?.summary ?? null;
|
|
||||||
|
|
||||||
const isLoading = monitorsIsLoading || settingsIsLoading;
|
|
||||||
|
|
||||||
const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet;
|
const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet;
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
if (!selectedMonitor) return;
|
|
||||||
await deleteFn(`/monitors/${selectedMonitor.id}`);
|
|
||||||
setSelectedMonitor(null);
|
|
||||||
refetch();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
setSelectedMonitor(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearFilters = useCallback(() => {
|
|
||||||
setSelectedStatus("");
|
|
||||||
setSelectedState("");
|
|
||||||
setSelectedTags([]);
|
|
||||||
setSearch("");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const hasActiveFilters = Boolean(
|
|
||||||
selectedStatus || selectedState || selectedTags.length > 0 || search
|
|
||||||
);
|
|
||||||
|
|
||||||
const effectiveTotalCount =
|
|
||||||
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
|
|
||||||
? 1
|
|
||||||
: (summary?.totalMonitors ?? 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MonitorBasePageWithStates
|
<MonitorListPage
|
||||||
headerKey="pageSpeed"
|
headerKey="pageSpeed"
|
||||||
loading={isLoading}
|
|
||||||
error={monitorsError || settingsError}
|
|
||||||
totalCount={effectiveTotalCount}
|
|
||||||
page="pageSpeed"
|
page="pageSpeed"
|
||||||
actionLink="/pagespeed/create"
|
actionLink="/pagespeed/create"
|
||||||
|
controller={c}
|
||||||
|
showTypeFilter={false}
|
||||||
|
extraLoading={settingsIsLoading}
|
||||||
|
extraError={settingsError}
|
||||||
priorityFallback={showApiKeyWarning ? <PageSpeedKeyPriorityFallback /> : undefined}
|
priorityFallback={showApiKeyWarning ? <PageSpeedKeyPriorityFallback /> : undefined}
|
||||||
>
|
>
|
||||||
<HeaderCreate
|
|
||||||
path="/pagespeed/create"
|
|
||||||
isLoading={isLoading}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
/>
|
|
||||||
<HeaderMonitorsSummary summary={summary} />
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
direction={isSmall ? "column" : "row"}
|
|
||||||
justifyContent={isSmall ? "flex-start" : "space-between"}
|
|
||||||
gap={theme.spacing(4)}
|
|
||||||
>
|
|
||||||
<ControlsFilter
|
|
||||||
showTypes={false}
|
|
||||||
selectedStatus={selectedStatus}
|
|
||||||
setSelectedStatus={setSelectedStatus}
|
|
||||||
selectedState={selectedState}
|
|
||||||
setSelectedState={setSelectedState}
|
|
||||||
tagOptions={tags ?? []}
|
|
||||||
selectedTags={selectedTags}
|
|
||||||
setSelectedTags={setSelectedTags}
|
|
||||||
onClearFilters={handleClearFilters}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
placeholder={t("pages.uptime.filters.search.placeholder")}
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => {
|
|
||||||
setSearch(event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{selectedTags.length > 0 && (
|
|
||||||
<Stack
|
|
||||||
direction={isSmall ? "column" : "row"}
|
|
||||||
alignItems={isSmall ? "flex-start" : "center"}
|
|
||||||
flexWrap="wrap"
|
|
||||||
gap={theme.spacing(SPACING.XL)}
|
|
||||||
>
|
|
||||||
<Typography color={theme.palette.text.secondary}>
|
|
||||||
{t("pages.uptime.filters.activeTags")}
|
|
||||||
</Typography>
|
|
||||||
{selectedTags.map((tagId) => {
|
|
||||||
const tag = tags?.find((t) => t.id === tagId);
|
|
||||||
if (!tag) return null;
|
|
||||||
return (
|
|
||||||
<ColoredLabel
|
|
||||||
key={tag.id}
|
|
||||||
text={tag.name}
|
|
||||||
color={tag.color}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PageSpeedMonitorsTable
|
<PageSpeedMonitorsTable
|
||||||
monitors={monitors || []}
|
monitors={c.monitors || []}
|
||||||
tags={tags ?? []}
|
tags={c.tags || []}
|
||||||
refetch={refetch}
|
refetch={c.refetch}
|
||||||
setSelectedMonitor={setSelectedMonitor}
|
setSelectedMonitor={c.setSelectedMonitor}
|
||||||
sortField={sortField}
|
count={c.count || 0}
|
||||||
setSortField={setSortField}
|
page={c.page}
|
||||||
sortOrder={sortOrder}
|
setPage={c.setPage}
|
||||||
setSortOrder={setSortOrder}
|
rowsPerPage={c.rowsPerPage}
|
||||||
count={monitorsCount || 0}
|
sortField={c.sortField}
|
||||||
page={page}
|
sortOrder={c.sortOrder}
|
||||||
setPage={setPage}
|
setSort={c.setSort}
|
||||||
rowsPerPage={rowsPerPage}
|
setRowsPerPage={c.handleSetRowsPerPage}
|
||||||
setRowsPerPage={setRowsPerPage}
|
|
||||||
/>
|
/>
|
||||||
<Dialog
|
</MonitorListPage>
|
||||||
open={isDialogOpen}
|
|
||||||
title={t("common.dialogs.delete.title")}
|
|
||||||
content={t("common.dialogs.delete.description")}
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
loading={isDeleting}
|
|
||||||
/>
|
|
||||||
</MonitorBasePageWithStates>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+691
-518
File diff suppressed because it is too large.
Load diff
@@ -25,7 +25,12 @@ export const HeaderConfigStatusControls = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="error"
|
color="error"
|
||||||
startIcon={<Icon icon={Trash} />}
|
startIcon={
|
||||||
|
<Icon
|
||||||
|
icon={Trash}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
}
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
>
|
>
|
||||||
{translate.t("common.buttons.delete")}
|
{translate.t("common.buttons.delete")}
|
||||||
|
|||||||
@@ -77,7 +77,12 @@ export const HeaderStatusPageControls = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
startIcon={<Icon icon={Settings} />}
|
startIcon={
|
||||||
|
<Icon
|
||||||
|
icon={Settings}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
}
|
||||||
onClick={() => navigate(`/status/configure/${statusPage.url}`)}
|
onClick={() => navigate(`/status/configure/${statusPage.url}`)}
|
||||||
>
|
>
|
||||||
{t("common.buttons.configure")}
|
{t("common.buttons.configure")}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
|
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
|
||||||
import { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
|
import { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
|
||||||
import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
|
import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
|
||||||
|
import { IncidentModal } from "@/Pages/StatusPage/Status/themes/shared/IncidentModal";
|
||||||
import {
|
import {
|
||||||
ThemedInfrastructure,
|
ThemedInfrastructure,
|
||||||
type GaugeFillLevel,
|
type GaugeFillLevel,
|
||||||
@@ -116,6 +117,8 @@ export const BaseStatusPage = ({
|
|||||||
[config, tokens, mode]
|
[config, tokens, mode]
|
||||||
);
|
);
|
||||||
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
|
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
|
||||||
|
const [selectedMonitorId, setSelectedMonitorId] = useState<string | null>(null);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
|
|
||||||
const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions);
|
const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions);
|
||||||
const logoSrc = statusPage.logo?.data
|
const logoSrc = statusPage.logo?.data
|
||||||
@@ -211,6 +214,7 @@ export const BaseStatusPage = ({
|
|||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon={TriangleAlert}
|
icon={TriangleAlert}
|
||||||
|
size={20}
|
||||||
color={theme.palette.warning.main}
|
color={theme.palette.warning.main}
|
||||||
/>
|
/>
|
||||||
<Typography>
|
<Typography>
|
||||||
@@ -305,6 +309,10 @@ export const BaseStatusPage = ({
|
|||||||
cells={cells}
|
cells={cells}
|
||||||
containerSx={styles.heatmap}
|
containerSx={styles.heatmap}
|
||||||
cellSx={styles.heatmapCell}
|
cellSx={styles.heatmapCell}
|
||||||
|
onCellClick={(date) => {
|
||||||
|
setSelectedMonitorId(monitor.id);
|
||||||
|
setSelectedDate(date);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ThemedHistogram
|
<ThemedHistogram
|
||||||
@@ -312,6 +320,10 @@ export const BaseStatusPage = ({
|
|||||||
containerSx={styles.histogram}
|
containerSx={styles.histogram}
|
||||||
barSx={styles.bar}
|
barSx={styles.bar}
|
||||||
statsSx={styles.chartStats}
|
statsSx={styles.chartStats}
|
||||||
|
onCellClick={(date) => {
|
||||||
|
setSelectedMonitorId(monitor.id);
|
||||||
|
setSelectedDate(date);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -332,6 +344,16 @@ export const BaseStatusPage = ({
|
|||||||
Checkmate
|
Checkmate
|
||||||
</a>
|
</a>
|
||||||
</Box>
|
</Box>
|
||||||
|
<IncidentModal
|
||||||
|
url={statusPage.url}
|
||||||
|
monitorId={selectedMonitorId}
|
||||||
|
date={selectedDate}
|
||||||
|
timezone={bucketTimezone}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedMonitorId(null);
|
||||||
|
setSelectedDate(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -25,6 +25,7 @@ export interface ChartCell {
|
|||||||
responseTime: number; // 0 for empty cells
|
responseTime: number; // 0 for empty cells
|
||||||
tooltip: React.ReactNode | null;
|
tooltip: React.ReactNode | null;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
|
date: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAY_DOWN_THRESHOLD = 0.5;
|
const DAY_DOWN_THRESHOLD = 0.5;
|
||||||
@@ -61,7 +62,7 @@ export const classifyResponseTime = (
|
|||||||
return "fast";
|
return "fast";
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyCell = (key: string): ChartCell => ({
|
const emptyCell = (key: string, date: string | null = null): ChartCell => ({
|
||||||
key,
|
key,
|
||||||
barKind: "empty",
|
barKind: "empty",
|
||||||
heatKind: "empty",
|
heatKind: "empty",
|
||||||
@@ -70,6 +71,7 @@ const emptyCell = (key: string): ChartCell => ({
|
|||||||
tooltip: null,
|
tooltip: null,
|
||||||
ariaLabel: "",
|
ariaLabel: "",
|
||||||
severity: 0,
|
severity: 0,
|
||||||
|
date,
|
||||||
});
|
});
|
||||||
|
|
||||||
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
|
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
|
||||||
@@ -92,6 +94,7 @@ export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
|
|||||||
tooltip: <ThemedChartTooltip check={check} />,
|
tooltip: <ThemedChartTooltip check={check} />,
|
||||||
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
|
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
|
||||||
severity: 0,
|
severity: 0,
|
||||||
|
date: null,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return padCells(cells, MAX_RECENT_CHECKS);
|
return padCells(cells, MAX_RECENT_CHECKS);
|
||||||
@@ -108,7 +111,7 @@ export const dailyBucketsToCells = (
|
|||||||
const enumerated = enumerateDays(days, bucketTimezone);
|
const enumerated = enumerateDays(days, bucketTimezone);
|
||||||
return enumerated.map((date) => {
|
return enumerated.map((date) => {
|
||||||
const bucket = byDate.get(date);
|
const bucket = byDate.get(date);
|
||||||
if (!bucket) return emptyCell(`day-${date}`);
|
if (!bucket) return emptyCell(`day-${date}`, date);
|
||||||
const upRatio = bucket.upChecks / bucket.totalChecks;
|
const upRatio = bucket.upChecks / bucket.totalChecks;
|
||||||
|
|
||||||
const barKind = classifyDay(bucket, upRatio);
|
const barKind = classifyDay(bucket, upRatio);
|
||||||
@@ -132,6 +135,7 @@ export const dailyBucketsToCells = (
|
|||||||
date,
|
date,
|
||||||
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
|
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
|
||||||
}),
|
}),
|
||||||
|
date,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import { X as XIcon } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
|
import { useTheme } from "@mui/material";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
|
|
||||||
|
import type { Incident } from "@/Types/Incident";
|
||||||
|
interface IncidentModalProps {
|
||||||
|
url: string;
|
||||||
|
monitorId: string | null;
|
||||||
|
date: string | null;
|
||||||
|
timezone: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentModal = ({
|
||||||
|
url,
|
||||||
|
monitorId,
|
||||||
|
date,
|
||||||
|
timezone,
|
||||||
|
onClose,
|
||||||
|
}: IncidentModalProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const open = Boolean(monitorId && date);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useGet<{ incidents: Incident[] }>(
|
||||||
|
open ? `/status-page/${url}/incidents/${monitorId}?date=${date}` : null,
|
||||||
|
{},
|
||||||
|
{ revalidateOnFocus: false, keepPreviousData: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
{t("pages.statusPages.monitorsList.incidents.modalTitle", { date })} ({timezone})
|
||||||
|
<IconButton
|
||||||
|
onClick={onClose}
|
||||||
|
size="small"
|
||||||
|
aria-label="close"
|
||||||
|
>
|
||||||
|
<XIcon size={20} />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<Typography mt={SPACING.SM}>
|
||||||
|
{t("pages.statusPages.monitorsList.incidents.loading")}
|
||||||
|
</Typography>
|
||||||
|
) : error ? (
|
||||||
|
<Typography
|
||||||
|
mt={SPACING.SM}
|
||||||
|
color={theme.palette.error.main}
|
||||||
|
>
|
||||||
|
{t("pages.statusPages.monitorsList.incidents.errorLoading")}
|
||||||
|
</Typography>
|
||||||
|
) : data?.incidents && data.incidents.length > 0 ? (
|
||||||
|
<Stack
|
||||||
|
gap={SPACING.LG}
|
||||||
|
mt={SPACING.SM}
|
||||||
|
>
|
||||||
|
{data.incidents.map((incident) => (
|
||||||
|
<Stack
|
||||||
|
key={incident.id}
|
||||||
|
p={SPACING.LG}
|
||||||
|
border={1}
|
||||||
|
borderColor="divider"
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{incident.message ||
|
||||||
|
(incident.statusCode ? `Error ${incident.statusCode}` : "Incident")}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
>
|
||||||
|
{formatDateWithTz(incident.startTime, "h:mm A", timezone)} -{" "}
|
||||||
|
{incident.endTime
|
||||||
|
? formatDateWithTz(incident.endTime, "h:mm A", timezone)
|
||||||
|
: t("pages.statusPages.monitorsList.incidents.ongoing")}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Typography mt={SPACING.SM}>
|
||||||
|
{t("pages.statusPages.monitorsList.incidents.noIncidents")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,6 +2,8 @@ import Tooltip from "@mui/material/Tooltip";
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { mergeSx } from "@/Pages/StatusPage/Status/themes/shared/sx";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ChartCell,
|
ChartCell,
|
||||||
HeatCellKind,
|
HeatCellKind,
|
||||||
@@ -11,17 +13,15 @@ interface Props {
|
|||||||
cells: ChartCell[];
|
cells: ChartCell[];
|
||||||
containerSx: SxProps<Theme>;
|
containerSx: SxProps<Theme>;
|
||||||
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
||||||
|
onCellClick?: (date: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
export const ThemedHeatmap = ({ cells, containerSx, cellSx, onCellClick }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={[
|
sx={mergeSx(containerSx, { gridTemplateColumns: `repeat(${cells.length}, 1fr)` })}
|
||||||
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
|
|
||||||
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
|
|
||||||
]}
|
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
|
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
|
||||||
>
|
>
|
||||||
@@ -30,7 +30,15 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={cell.key}
|
key={cell.key}
|
||||||
sx={cellSx("empty")}
|
sx={mergeSx(
|
||||||
|
cellSx("empty"),
|
||||||
|
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (cell.date && onCellClick) {
|
||||||
|
onCellClick(cell.date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,8 +50,16 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
|||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={cellSx(cell.heatKind, cell.severity)}
|
sx={mergeSx(
|
||||||
|
cellSx(cell.heatKind, cell.severity),
|
||||||
|
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
|
||||||
|
)}
|
||||||
aria-label={cell.ariaLabel}
|
aria-label={cell.ariaLabel}
|
||||||
|
onClick={() => {
|
||||||
|
if (cell.date && onCellClick) {
|
||||||
|
onCellClick(cell.date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import Stack from "@mui/material/Stack";
|
|||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatMs } from "@/Utils/TimeUtils";
|
import { formatMs } from "@/Utils/TimeUtils";
|
||||||
|
import { mergeSx } from "@/Pages/StatusPage/Status/themes/shared/sx";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BarKind,
|
BarKind,
|
||||||
ChartCell,
|
ChartCell,
|
||||||
@@ -16,6 +18,7 @@ interface Props {
|
|||||||
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||||
statsSx: SxProps<Theme>;
|
statsSx: SxProps<Theme>;
|
||||||
statsGap?: number;
|
statsGap?: number;
|
||||||
|
onCellClick?: (date: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThemedHistogram = ({
|
export const ThemedHistogram = ({
|
||||||
@@ -24,6 +27,7 @@ export const ThemedHistogram = ({
|
|||||||
barSx,
|
barSx,
|
||||||
statsSx,
|
statsSx,
|
||||||
statsGap = 1,
|
statsGap = 1,
|
||||||
|
onCellClick,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -39,17 +43,22 @@ export const ThemedHistogram = ({
|
|||||||
return (
|
return (
|
||||||
<Stack gap={statsGap}>
|
<Stack gap={statsGap}>
|
||||||
<Box
|
<Box
|
||||||
sx={[
|
sx={mergeSx(containerSx, { gridTemplateColumns: `repeat(${cells.length}, 1fr)` })}
|
||||||
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
|
|
||||||
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
{cells.map((cell) => {
|
{cells.map((cell) => {
|
||||||
if (cell.barKind === "empty") {
|
if (cell.barKind === "empty") {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={cell.key}
|
key={cell.key}
|
||||||
sx={barSx("empty", cell.heightPct)}
|
sx={mergeSx(
|
||||||
|
barSx("empty", cell.heightPct),
|
||||||
|
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (cell.date && onCellClick) {
|
||||||
|
onCellClick(cell.date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,8 +70,16 @@ export const ThemedHistogram = ({
|
|||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
|
sx={mergeSx(
|
||||||
|
barSx(cell.barKind, cell.heightPct, cell.severity),
|
||||||
|
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
|
||||||
|
)}
|
||||||
aria-label={cell.ariaLabel}
|
aria-label={cell.ariaLabel}
|
||||||
|
onClick={() => {
|
||||||
|
if (cell.date && onCellClick) {
|
||||||
|
onCellClick(cell.date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const SANS_STACK =
|
export const SANS_STACK =
|
||||||
'-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||||
|
|
||||||
export const BOLD_SANS_STACK = `ui-sans-serif, ${SANS_STACK}`;
|
export const BOLD_SANS_STACK = `ui-sans-serif, ${SANS_STACK}`;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
export const mergeSx = (
|
||||||
|
...styles: Array<SxProps<Theme> | false | null | undefined>
|
||||||
|
): SxProps<Theme> => {
|
||||||
|
const result: SxProps<Theme>[] = [];
|
||||||
|
for (const style of styles) {
|
||||||
|
if (!style) continue;
|
||||||
|
if (Array.isArray(style)) {
|
||||||
|
result.push(...(style.filter(Boolean) as SxProps<Theme>[]));
|
||||||
|
} else {
|
||||||
|
result.push(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result as SxProps<Theme>;
|
||||||
|
};
|
||||||
Loaded 100 of 352 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user