Compare commits
455 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bda862e4d3 | |||
| 6887dc291b | |||
| 29daddfc3b | |||
| 9c44ba6d7f | |||
| c61a740725 | |||
| a9d3d86394 | |||
| 9b0b2663ec | |||
| 0b04034141 | |||
| 0a635ab3aa | |||
| 175d2b6329 | |||
| 3d6c4151b8 | |||
| 5f893e3b20 | |||
| 1296cf6a32 | |||
| 31e5335aac | |||
| b6d4ca5269 | |||
| 7e7522608a | |||
| 43957f65d2 | |||
| 09ece0a77e | |||
| 06ba38aa15 | |||
| abd878fe6c | |||
| f4d47bbd45 | |||
| b675fddc92 | |||
| 0a70ed2143 | |||
| 4e07279a54 | |||
| 020cdec912 | |||
| 4771b2ead0 | |||
| 87c829e100 | |||
| e6093f9fe2 | |||
| 956786a859 | |||
| 6a0169e4d8 | |||
| 92959945f3 | |||
| b2269187d8 | |||
| 05f46969c4 | |||
| ab168b440e | |||
| 2e866d7a95 | |||
| b372131978 | |||
| 5f7c451fc0 | |||
| 3d7172d9aa | |||
| 22d4597f88 | |||
| d4423e328b | |||
| b3b64806f6 | |||
| 001f6f8783 | |||
| d01f682a83 | |||
| bce523bdf1 | |||
| 623e3d5ebf | |||
| e3a7865e40 | |||
| 3c93354c7d | |||
| 323ac36b65 | |||
| c437704007 | |||
| 1953d54dc5 | |||
| b574073294 | |||
| b164602ee5 | |||
| 4bd08efc9a | |||
| 58fa09bea7 | |||
| 7ff180bea4 | |||
| 7b18806066 | |||
| d4434a64e1 | |||
| 08580794da | |||
| 70fdcc9ea6 | |||
| aba3120235 | |||
| b038d87848 | |||
| 87588a9127 | |||
| 4090fa5aef | |||
| c04561dd99 | |||
| 7f1dfcdd42 | |||
| 6f753f2901 | |||
| c39296cee6 | |||
| a1a8a8443e | |||
| f866d36db4 | |||
| 98ec911281 | |||
| 046db61326 | |||
| 3c00d0933e | |||
| 8e013f0b0b | |||
| f686134525 | |||
| 98d33a609e | |||
| d33244956e | |||
| 572c572c14 | |||
| f9743407a4 | |||
| fa3ad9b2e1 | |||
| 6bf75b2bf7 | |||
| b068c0cd51 | |||
| 92617038f0 | |||
| 40488a5a53 | |||
| 35fe0b9071 | |||
| 3bc1fd7c02 | |||
| ee4ce50193 | |||
| 67a0d1d7f4 | |||
| f006df3b02 | |||
| 5234303565 | |||
| 4ff0457966 | |||
| 7a00d8828e | |||
| 6193ad8b4f | |||
| 41c387a301 | |||
| 933e70a3f6 | |||
| 58242c33f8 | |||
| 47a38d3c64 | |||
| 07c101617c | |||
| 37bb7e25b6 | |||
| a4e819d220 | |||
| 7b01cffb64 | |||
| 6479ea50a5 | |||
| 9596f53742 | |||
| f15a00772e | |||
| d4636d5436 | |||
| 37bc86e9d8 | |||
| 34d3e81154 | |||
| e70b23cbe5 | |||
| 36dfd8a52c | |||
| 86503b7160 | |||
| aecf16ba40 | |||
| 2ca7c35c69 | |||
| 81604cb736 | |||
| 9f9d773256 | |||
| 92e5f3f074 | |||
| 2f12f8f805 | |||
| c6e7e243e6 | |||
| 074fc18ee1 | |||
| 34dccb763d | |||
| cc6abf58ac | |||
| 269240c6c9 | |||
| 7856aff98e | |||
| 99d67deb10 | |||
| 96bf456338 | |||
| f4cc96733c | |||
| da4678570f | |||
| 881d745776 | |||
| b4dc510611 | |||
| 914f1aba60 | |||
| f7cf2fcc20 | |||
| 50b4d1dfb0 | |||
| 8bb78e68ea | |||
| 205bedfba5 | |||
| 1fac88dcc9 | |||
| 44bc2bc1e5 | |||
| bc06a24adb | |||
| 952eb3d8d7 | |||
| 1d8b5172c5 | |||
| ff4938533c | |||
| 384920e952 | |||
| cad092bc6b | |||
| be57688a5e | |||
| e2f41fdef1 | |||
| c329425ac1 | |||
| fd3b959966 | |||
| 4157ee11cc | |||
| 86e707803e | |||
| 8d279aa0da | |||
| 07dc10a422 | |||
| e9cedef443 | |||
| c827151521 | |||
| 9d5fb72708 | |||
| 2bc508a230 | |||
| fda91aedb9 | |||
| 4ff92b4c4d | |||
| fc568891d5 | |||
| 57f815e29e | |||
| 9f704bb1ad | |||
| 0cba09f53d | |||
| 2d2122d2e5 | |||
| d17a70dfa8 | |||
| 3f3ce9122d | |||
| 60960cb3e0 | |||
| 50c742d82d | |||
| d0ecaaaf8f | |||
| ff19d46391 | |||
| e5afe276ee | |||
| c098ecb6bf | |||
| dc586dc15f | |||
| d40d5898c0 | |||
| 63d63a908a | |||
| 868832f5ba | |||
| c1c94115e0 | |||
| e388912cee | |||
| 6c227f496e | |||
| 0cad374616 | |||
| 6906a65952 | |||
| b9cdff37d5 | |||
| 88877a9fc0 | |||
| 2de3a17967 | |||
| b4a12a46df | |||
| e4284afb09 | |||
| 664e2c8ecf | |||
| 7c08c8bc66 | |||
| 88b4803f52 | |||
| fd7035965f | |||
| debf35703b | |||
| 213446422e | |||
| 79beb20b65 | |||
| f235043417 | |||
| 6512d4974a | |||
| 73f2566d78 | |||
| 46077e8deb | |||
| d72d6fb009 | |||
| c6ed816c04 | |||
| 0ba3b3182b | |||
| 24c5a9957f | |||
| cb258d72c3 | |||
| 0941250867 | |||
| 7ef4d27148 | |||
| 2621544b24 | |||
| 9fd977ed15 | |||
| 1b40a1b663 | |||
| 53d8b110c9 | |||
| a013f9170a | |||
| 5daea8138c | |||
| 59dcdcb5df | |||
| 35e161e40d | |||
| de79eb963d | |||
| 7fd6d07c10 | |||
| 2d2bd790b0 | |||
| 917d8a6d29 | |||
| ddeb22dc4d | |||
| e355e771d1 | |||
| f4aeaebe0d | |||
| 6b49119797 | |||
| be7c80d7c4 | |||
| 08f31835e1 | |||
| 5d2b8b2ed2 | |||
| f0eec90e75 | |||
| 671f22c1d3 | |||
| f80b2c8a51 | |||
| dc9e8b981a | |||
| 306d2fc336 | |||
| d072ba7d23 | |||
| 1113ede9a6 | |||
| 1e84db4ecc | |||
| 20d5e86d3c | |||
| 549dcc21e4 | |||
| f97bc4f62f | |||
| d6793f104c | |||
| bdcf5c75b9 | |||
| 4d68c6b8a9 | |||
| fda40de155 | |||
| 9b1ccc7f8d | |||
| d183ca9791 | |||
| 48ddc2b4c6 | |||
| 30bba14e37 | |||
| 460fcbc7dc | |||
| 0b029dc914 | |||
| 691cd4f298 | |||
| 4bfeb42142 | |||
| c1fa02c380 | |||
| 15f0e93524 | |||
| b37a84734f | |||
| c979dd24d8 | |||
| fd08a64a05 | |||
| c30bcf9e73 | |||
| 7b18a224c1 | |||
| 202a09993c | |||
| 094f5fcd5e | |||
| ab4f0aa6ea | |||
| 35b11f5a16 | |||
| 1b11deb40f | |||
| 879e106051 | |||
| 044f9e07fd | |||
| ded509bd10 | |||
| 1e7c20593f | |||
| d385f24c28 | |||
| 57a65106cb | |||
| ea95490f13 | |||
| 918a248b8f | |||
| 458f5f7d82 | |||
| 5ceaa09710 | |||
| 1eabf66311 | |||
| a0c0a3ed75 | |||
| abb129bde0 | |||
| 220be3bea4 | |||
| 614c10188d | |||
| 11408dcba0 | |||
| d3ea5dc77c | |||
| 89572c44b7 | |||
| dfe98f9fa6 | |||
| 549a3793a5 | |||
| cca2338468 | |||
| c1c74bb2a8 | |||
| a2ae66fe76 | |||
| 907fdaa0dc | |||
| 93b58cfa2c | |||
| 879622b364 | |||
| 2f52010963 | |||
| c90f0dd963 | |||
| 4a14f64e07 | |||
| 19b4de78d1 | |||
| bdf648c2fe | |||
| 0705fc89b4 | |||
| 883a541b9b | |||
| 0758b1d24b | |||
| 9c78def48d | |||
| 94e2c0da77 | |||
| 78674f758c | |||
| 3a6ab2258a | |||
| 1daaf067d9 | |||
| 6d1b8387ca | |||
| e3f9111484 | |||
| 575869c50a | |||
| 08b00728c2 | |||
| 4486a79e58 | |||
| 41fcaaaf05 | |||
| ddcabac564 | |||
| 4e0a9953db | |||
| f25fa7de83 | |||
| aea70d0023 | |||
| 4e07c8c932 | |||
| 5f18fde775 | |||
| e1d466a638 | |||
| 8dc3bd6a0d | |||
| 6d63d9f485 | |||
| 895da83999 | |||
| f6592d331f | |||
| da0808cefe | |||
| 1e77f2747e | |||
| 99e988e316 | |||
| 419e9118f9 | |||
| e2c2705378 | |||
| 82fa9a4ac2 | |||
| 576f2fbb34 | |||
| 554faefe4a | |||
| c92e9fe442 | |||
| ede5c36ac4 | |||
| 9097a618cd | |||
| f4f6cb48db | |||
| 1264d293bf | |||
| 8608b6a263 | |||
| 86e1e70e22 | |||
| dd2c64420d | |||
| ba47c1d953 | |||
| ba6dc41d95 | |||
| 11ddc77e32 | |||
| 5a5772f9a4 | |||
| 039a832b79 | |||
| a88b356bf5 | |||
| 2d798e2983 | |||
| 426f41e337 | |||
| 4ef84b5469 | |||
| 0739d6b0ce | |||
| bd17ef7140 | |||
| 518b469c04 | |||
| 6652ac5e76 | |||
| 0b52e7ffa9 | |||
| 9b1167955e | |||
| 611380a5e4 | |||
| 0b9bc76d37 | |||
| 0562adbef8 | |||
| d4d4040cc8 | |||
| c17d902b9a | |||
| a5ad5eef42 | |||
| c7846a2fed | |||
| 4ad001b560 | |||
| d52842a8ea | |||
| 9e02eea0d1 | |||
| a1d2f1a9e0 | |||
| 230f5562c7 | |||
| bff3cd7d91 | |||
| 3799be8b23 | |||
| 0932acfb61 | |||
| 38374acdcd | |||
| a58180f252 | |||
| a307fd006c | |||
| 31738b3a52 | |||
| e3e7206d91 | |||
| fe1b94d8e3 | |||
| 4f1c78f8e1 | |||
| 7ccd413973 | |||
| e74bf35264 | |||
| 23be1fca0f | |||
| df047541b9 | |||
| c8323e91b0 | |||
| 9da272b464 | |||
| 706a01e5aa | |||
| 8c159f402e | |||
| f483fbd2ee | |||
| 616148f794 | |||
| 7a125bf0b7 | |||
| dfe052aba6 | |||
| 641e98c001 | |||
| 1ee63ec74a | |||
| 0d8b88d9a1 | |||
| 280c5dcc86 | |||
| 09ce22b4fb | |||
| 1e8962cb11 | |||
| a3727b5dfd | |||
| 4c2f65b432 | |||
| 0692a78576 | |||
| 839fcc8771 | |||
| 686634f0ac | |||
| 705acfbe1d | |||
| b845e46231 | |||
| 1f13d5c6be | |||
| 9781a0ad68 | |||
| 1e51c964cf | |||
| b282b0b2d5 | |||
| 8910c1355c | |||
| b4bef67220 | |||
| 462b6d6d40 | |||
| 747ae5c23f | |||
| 395a580032 | |||
| a7a1c9a144 | |||
| 11deebe45f | |||
| 0e705e1309 | |||
| 4a6cd92661 | |||
| 3f6d606190 | |||
| 7c382ddb7a | |||
| e4b5ba96c1 | |||
| 34e78e5c46 | |||
| 442e025f7b | |||
| 975fb65c3d | |||
| 48abc618f3 | |||
| ba318f13a5 | |||
| c099f03bbb | |||
| 19f13598fb | |||
| 918e012082 | |||
| e0c7541a87 | |||
| a1c6c7053e | |||
| 389eb22737 | |||
| 466ef1cb90 | |||
| caaa32ea13 | |||
| 78be48ffd0 | |||
| aaee189ace | |||
| ce988574e4 | |||
| a166938d42 | |||
| e4d6d84477 | |||
| c0cf913935 | |||
| c8d640ebb5 | |||
| 9f38e23b22 | |||
| 1ea81a23b2 | |||
| 8ee933563e | |||
| 687619ce0d | |||
| 9e931ad439 | |||
| c7fd7f4fbe | |||
| eafc4c33ab | |||
| 7644480782 | |||
| 1515ae56af | |||
| caf3e73dc3 | |||
| 98c86ff5dd | |||
| 42441b615a | |||
| e5651c152a | |||
| fc84922959 | |||
| 093300c38a | |||
| 2dcedf7b30 | |||
| 38e06b9bec | |||
| 9f2ab1a79b | |||
| 54ebe0604d | |||
| cf28eb41d6 | |||
| 8ef3614908 | |||
| 7773c1bc66 | |||
| 580dbc424c | |||
| 981bc972f0 | |||
| a9043e8088 | |||
| 1ae961c5d9 | |||
| 51226a7afe | |||
| dfd57a6b62 | |||
| 12f4d18521 | |||
| aea32f10b6 | |||
| a16b1d228a | |||
| 930622b5be |
@@ -0,0 +1,2 @@
|
||||
# These owners will be the default owners for everything in
|
||||
* @tolaleng
|
||||
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: #[tolaleng]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: #checkcle
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -0,0 +1,94 @@
|
||||
name: 🐛 Bug Report
|
||||
description: Report a reproducible bug to help us improve.
|
||||
title: "[Bug]: "
|
||||
labels: [bug]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Thank you for taking the time to report a bug!**
|
||||
Please complete the form below to help us reproduce and fix the issue more effectively.
|
||||
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: |
|
||||
Describe the environment where the issue occurred.
|
||||
(e.g., OS, browser, Node.js version, Docker version, etc.)
|
||||
placeholder: "Example: Ubuntu 22.04, Chrome 125, Node.js 20.3.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: |
|
||||
A clear and concise description of the issue you encountered.
|
||||
placeholder: "When I do X, Y happens instead of Z..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: |
|
||||
Provide step-by-step instructions to reproduce the issue.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See the error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
placeholder: "The app should..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots or Videos
|
||||
description: |
|
||||
If applicable, add screenshots or screen recordings to help illustrate the issue.
|
||||
placeholder: "Drag and drop or paste screenshots here."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
If applicable, paste any relevant logs or error messages.
|
||||
Please remove sensitive information before submitting.
|
||||
render: shell
|
||||
placeholder: |
|
||||
```
|
||||
Error: Something went wrong
|
||||
at index.js:123:45
|
||||
```
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: |
|
||||
Please confirm the following before submitting:
|
||||
options:
|
||||
- label: I have searched existing issues to avoid creating duplicates.
|
||||
required: true
|
||||
- label: I have provided sufficient information to reproduce the issue.
|
||||
required: true
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: true
|
||||
@@ -0,0 +1,49 @@
|
||||
name: 📝 Documentation Improvement
|
||||
description: Suggest improvements or report issues in documentation.
|
||||
title: "[Docs]: "
|
||||
labels: [documentation]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for helping improve our documentation!
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What part of the documentation needs improvement?
|
||||
description: |
|
||||
Describe the issue clearly.
|
||||
placeholder: "The installation guide is missing steps for..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: location
|
||||
attributes:
|
||||
label: Location
|
||||
description: |
|
||||
Provide a link or path to the affected documentation.
|
||||
placeholder: "https://github.com/operacle/checkcle/docs/INSTALL.md"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: suggestion
|
||||
attributes:
|
||||
label: Suggested Change
|
||||
description: |
|
||||
How would you improve it?
|
||||
placeholder: "I suggest adding a section about..."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I have searched existing issues for similar documentation problems.
|
||||
required: true
|
||||
@@ -0,0 +1,60 @@
|
||||
name: 🚀 Feature Request
|
||||
description: Suggest an idea to improve this project.
|
||||
title: "[Feature]: "
|
||||
labels: [enhancement]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Thank you for suggesting a feature!**
|
||||
Please fill out this form so we can better understand your idea.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Feature Description
|
||||
description: A clear and concise description of the feature you’d like to see.
|
||||
placeholder: "I would like to have..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: Motivation
|
||||
description: Please explain why this feature would be useful.
|
||||
placeholder: "This feature would help because..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you considered any alternative solutions or workarounds?
|
||||
placeholder: "I have tried..."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
placeholder: "Links to related issues or references..."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: |
|
||||
Before submitting, please confirm:
|
||||
options:
|
||||
- label: I have searched existing issues to make sure this feature hasn’t been requested yet.
|
||||
required: true
|
||||
- label: I have described the feature clearly and provided supporting details.
|
||||
required: true
|
||||
@@ -0,0 +1,54 @@
|
||||
name: ❓ Ask for Help
|
||||
description: Ask a question or request guidance related to this project.
|
||||
title: "[Question]: "
|
||||
labels: [question]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Thanks for reaching out!**
|
||||
Please provide as much detail as possible so we can better assist you.
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: What do you need help with?
|
||||
description: Clearly describe your question or what you're trying to achieve.
|
||||
placeholder: |
|
||||
I am trying to do X, but I'm not sure how to...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: |
|
||||
Provide any relevant context, related issues, pull requests, or documentation links that could help us understand your question better.
|
||||
placeholder: "Mention related issues, links to docs, or previous discussions..."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment Details
|
||||
description: |
|
||||
If relevant, provide details about your environment (e.g., OS, browser, Node.js version).
|
||||
placeholder: "Example: Windows 11, Node.js v20.3.0, Firefox 125"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: |
|
||||
Before submitting, please confirm the following:
|
||||
options:
|
||||
- label: I have searched existing issues and discussions for a solution.
|
||||
required: true
|
||||
- label: I have read the relevant documentation.
|
||||
required: true
|
||||
@@ -0,0 +1,51 @@
|
||||
name: 🛡️ Security Issue
|
||||
description: Report a potential security vulnerability.
|
||||
title: "[Security]: "
|
||||
labels: [security]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
⚠️ **IMPORTANT: Please do NOT disclose sensitive security details in this public issue.**
|
||||
|
||||
If you believe you have found a security vulnerability, **please report it privately** to help keep users safe.
|
||||
|
||||
📧 **Disclosure Process:**
|
||||
- Email: [security@checkcle.io](mailto:security@checkcle.io)
|
||||
- Or follow our [SECURITY.md](../../blob/main/SECURITY.md) policy.
|
||||
|
||||
You can use this issue only to let maintainers know that you have sent or will send a report.
|
||||
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: |
|
||||
Briefly describe the type of vulnerability you believe you have found (without including sensitive details).
|
||||
placeholder: "Example: Possible SQL injection in the user login endpoint."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: contact
|
||||
attributes:
|
||||
label: Contact Information
|
||||
description: |
|
||||
Provide an email or other way for maintainers to contact you if we need clarification.
|
||||
placeholder: "your.email@example.com"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
attributes:
|
||||
label: Confirmation
|
||||
description: |
|
||||
Please confirm:
|
||||
options:
|
||||
- label: I will not share sensitive exploit details in this issue.
|
||||
required: true
|
||||
- label: I have sent or will send a full report to the private disclosure contact.
|
||||
required: true
|
||||
@@ -0,0 +1,38 @@
|
||||
name: 🌎 Translation Request
|
||||
description: Request a translation or report translation issues.
|
||||
title: "[Translation]: "
|
||||
labels: [translation]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Help us make this project accessible in more languages!
|
||||
|
||||
- type: input
|
||||
id: language
|
||||
attributes:
|
||||
label: Language
|
||||
description: What language is this about?
|
||||
placeholder: "Example: Spanish"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Details
|
||||
description: |
|
||||
Describe what you’d like to have translated or what issue you found.
|
||||
placeholder: "Please translate the Getting Started guide..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I have searched existing issues for similar requests.
|
||||
required: true
|
||||
@@ -21,4 +21,9 @@ dist-ssr
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.sw?
|
||||
pocketbase.exe
|
||||
application/src/lib/pocketbase.ts
|
||||
server/pb_data/
|
||||
Dockerfile
|
||||
application/src/lib/pocketbase-prod.ts
|
||||
@@ -0,0 +1,81 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders of the **CheckCle** project pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others’ private information, such as a physical or email address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all project spaces, and also applies when an individual is officially representing the project in public spaces. Examples include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project team at:
|
||||
|
||||
📧 **Email:** [hello@checkcle.io](mailto:hello@checkcle.io)
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All project maintainers are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Project maintainers will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
**1. Correction**
|
||||
|
||||
- *Community Impact:* Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
- *Consequence:* A private, written warning from project maintainers, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
**2. Warning**
|
||||
|
||||
- *Community Impact:* A violation through a single incident or series of actions.
|
||||
- *Consequence:* A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
**3. Temporary Ban**
|
||||
|
||||
- *Community Impact:* A serious violation of community standards, including sustained inappropriate behavior.
|
||||
- *Consequence:* A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
|
||||
**4. Permanent Ban**
|
||||
|
||||
- *Community Impact:* Demonstrating a pattern of violation of community standards, including sustained harassment, or showing no remorse after being warned.
|
||||
- *Consequence:* A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at:
|
||||
|
||||
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
|
||||
For answers to common questions about this code of conduct, see:
|
||||
|
||||
https://www.contributor-covenant.org/faq
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
@@ -1,3 +1,36 @@
|
||||
## 🌐 Select Language
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="CONTRIBUTING.md">
|
||||
<img src="https://flagcdn.com/24x18/gb.png" alt="English" />
|
||||
<br/><strong>English</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="docs/CONTRIBUTING_km.md">
|
||||
<img src="https://flagcdn.com/24x18/kh.png" alt="Khmer" />
|
||||
<br/><strong>ខ្មែរ</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="docs/CONTRIBUTING_ja.md">
|
||||
<img src="https://flagcdn.com/24x18/jp.png" alt="Japanese" />
|
||||
<br/><strong>Japanese</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<p align="center">
|
||||
Thank you to all our contributors, users, and supporters for making this project thrive.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🚀 <strong>Stay tuned for more updates, features, and improvements.</strong>
|
||||
</p>
|
||||
|
||||
# 🛠️ Contributing to CheckCle
|
||||
|
||||
@@ -46,7 +79,7 @@ Click "Fork" on [GitHub](https://github.com/operacle/checkcle) to create your ow
|
||||
|
||||
### 2. Clone Your Fork
|
||||
```bash
|
||||
git clone https://github.com/operacle/checkcle.git
|
||||
git clone --branch develop https://github.com/operacle/checkcle.git
|
||||
cd checkcle
|
||||
```
|
||||
|
||||
@@ -61,16 +94,45 @@ npm install && npm run dev
|
||||
|
||||
#Server Backend
|
||||
cd server
|
||||
./pocketbase serve --dir pb_data
|
||||
./pocketbase serve --dir pb_data
|
||||
|
||||
If you're not using localhost, please run with this (./pocketbase serve --http=0.0.0.0:8090 --dir pb_data)
|
||||
```
|
||||
|
||||
### 4. Start Service Check Operation
|
||||
|
||||
```bash
|
||||
#Server Backend
|
||||
Start Service Operation (Check Service for PING, HTTP, TCP, DNS)
|
||||
|
||||
cd server/service-operation
|
||||
|
||||
go run main.go (you do not need to change the .env while it's the localhost connection)
|
||||
```
|
||||
|
||||
### 5. Start Distributed Regional Agent
|
||||
```bash
|
||||
#### 1. Fork the Repository
|
||||
Click "Fork" on [GitHub](https://github.com/operacle/Distributed-Regional-Monitoring) to create your own copy.
|
||||
|
||||
#### 2. Clone Your Fork
|
||||
git clone --branch main https://github.com/operacle/Distributed-Regional-Monitoring.git
|
||||
cd Distributed-Regional-Monitoring
|
||||
|
||||
#### 3. Install Go Service (make sure you have installed the Go Service)
|
||||
|
||||
Copy .env.example -> .env
|
||||
Change regional agent configuration in .env file
|
||||
and Run: go run main.go
|
||||
|
||||
```
|
||||
---
|
||||
|
||||
## ✅ Pull Request Process
|
||||
|
||||
1. Ensure your code follows the existing style and naming conventions.
|
||||
2. Write clear, concise commit messages.
|
||||
3. Push your branch and open a Pull Request (PR) on the `main` branch.
|
||||
3. Push your branch and open a Pull Request (PR) on the `develop` branch.
|
||||
4. Provide a meaningful PR description (what/why/how).
|
||||
5. Link related issues if applicable (e.g. `Closes #12`).
|
||||
6. Make sure all checks pass (e.g., linting, tests).
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# Stage 1: Build the frontend
|
||||
FROM node:18-alpine AS frontend-builder
|
||||
WORKDIR /app
|
||||
COPY application/package*.json ./
|
||||
RUN npm install
|
||||
COPY application/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Download PocketBase binary
|
||||
FROM alpine:3.17 AS pb-builder
|
||||
WORKDIR /pb
|
||||
ARG PB_VERSION=0.27.2
|
||||
RUN apk add --no-cache wget unzip \
|
||||
&& wget https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip \
|
||||
&& unzip pocketbase_${PB_VERSION}_linux_amd64.zip \
|
||||
&& chmod +x /pb/pocketbase
|
||||
|
||||
# Stage 3: Final runtime image
|
||||
FROM alpine:3.17
|
||||
WORKDIR /app
|
||||
|
||||
# Copy PocketBase binary
|
||||
COPY --from=pb-builder /pb/pocketbase /app/pocketbase
|
||||
|
||||
# Copy frontend build to PocketBase public directory
|
||||
COPY --from=frontend-builder /app/dist /app/pb_public
|
||||
|
||||
# Copy backend files
|
||||
COPY server/pb_migrations /app/pb_migrations
|
||||
COPY server/pb_hooks /app/pb_hooks
|
||||
COPY server/pb_data /app/pb_data
|
||||
|
||||
# Mark pb_data as a volume for runtime persistence
|
||||
VOLUME /app/pb_data
|
||||
|
||||
# Expose default PocketBase port
|
||||
EXPOSE 8090
|
||||
|
||||
# Launch PocketBase
|
||||
#CMD ["/app/pocketbase", "serve", "--dir", "/app/pb_data"]
|
||||
|
||||
|
||||
# Launch PocketBase and bind to 0.0.0.0 for external access
|
||||
CMD ["/app/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir", "/app/pb_data"]
|
||||
@@ -1,17 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2022 - Present, Tola Leng
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following conditions:
|
||||
Copyright (c) 2025 Tola Leng
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or
|
||||
substantial portions of the Software.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,18 +1,60 @@
|
||||

|
||||
## 🌐 Select Language
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="README.md">
|
||||
<img src="https://flagcdn.com/24x18/gb.png" alt="English" />
|
||||
<br/><strong>English</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="docs/README_km.md">
|
||||
<img src="https://flagcdn.com/24x18/kh.png" alt="Khmer" />
|
||||
<br/><strong>ខ្មែរ</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="docs/README_ja.md">
|
||||
<img src="https://flagcdn.com/24x18/jp.png" alt="Japanese" />
|
||||
<br/><strong>Japanese</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="docs/README_zhcn.md">
|
||||
<img src="https://flagcdn.com/24x18/cn.png" alt="Chinese" />
|
||||
<br/><strong>Chinese</strong>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p align="center">
|
||||
Thank you to all our contributors, users, and supporters for making this project thrive.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🚀 <strong>Stay tuned for more updates, features, and improvements.</strong>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
# 🚀 What is CheckCle?
|
||||
|
||||
CheckCle is an Open Source solution for seamless, real-time monitoring of full-stack systems, applications, and infrastructure. It provides developers, sysadmins, and DevOps teams with deep insights and actionable data across every layer of their environment—whether it's servers, applications, or services. With CheckCle, you gain visibility, control, and the ability to ensure optimal performance throughout your entire technology stack.
|
||||
|
||||
## 🎯 Live Demo
|
||||
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io) Coming Soon!
|
||||
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io)
|
||||
User: admin@example.com | Passwd: Admin123456
|
||||
|
||||
## 🌟 Core Features
|
||||
### 📝 Roadmap : [DEVELOPMENT_ROADMAP](docs/DEVELOPMENT_ROADMAP.md)
|
||||
|
||||
### Uptime Services & Infrastructure Server Monitoring
|
||||
### Uptime Services & Infrastructure Server Monitoring
|
||||
- Monitor HTTP, DNS, and Ping protocols
|
||||
- Monitor TCP-based, API services (e.g., FTP, SMTP, HTTP)
|
||||
- Track detail uptime, response times, and performance issues
|
||||
- Distributed Regional Monitoring
|
||||
- Incident History (UP/DOWN/WARNING/PAUSE)
|
||||
- SSL & Domain Monitoring (Domain, Issuer, Expiration Date, Days Left, Status, Last Notified)
|
||||
- Infrastructure Server Monitoring, Supports Linux (🐧 Debian, Ubuntu, CentOS, Red Hat, etc.) and Windows (Beta). And Servers metrics like CPU, RAM, disk usage, and network activity) with an one-line installation angent script.
|
||||
@@ -24,21 +66,16 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
|
||||
|
||||
## #️⃣ Getting Started
|
||||
|
||||
### Installation with Docker Compose
|
||||
1. Clone the repository and run
|
||||
### Current Architecture Support
|
||||
* ✅ x86_64 PCs, laptops, servers (amd64)
|
||||
* ✅ Modern Raspberry Pi 3/4/5 with (64-bit OS), Apple Silicon Macs (arm64)
|
||||
|
||||
### Install CheckCle using one of the following methods:
|
||||
|
||||
1. Install with Docker Compose Configuration (Recommended)
|
||||
```bash
|
||||
#Clone the repository
|
||||
|
||||
git clone https://github.com/operacle/checkcle.git
|
||||
cd checkcle
|
||||
|
||||
# Run docker compose
|
||||
|
||||
docker compose up -d
|
||||
|
||||
```
|
||||
2. Docker Compose - Recommended
|
||||
```bash
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
checkcle:
|
||||
@@ -48,45 +85,40 @@ services:
|
||||
ports:
|
||||
- "8090:8090" # Web Application
|
||||
volumes:
|
||||
- pb_data:/app/pb_data # Ensure persistent data across rebuilds
|
||||
- /opt/pb_data:/mnt/pb_data # Host directory mapped to container path
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 4096
|
||||
hard: 8192
|
||||
|
||||
volumes:
|
||||
pb_data: # Docker-managed volume for data persistence
|
||||
```
|
||||
2. Install with docker run. Just copy ready docker run command below
|
||||
```bash
|
||||
docker run -d \
|
||||
--name checkcle \
|
||||
--restart unless-stopped \
|
||||
-p 8090:8090 \
|
||||
-v /opt/pb_data:/mnt/pb_data \
|
||||
--ulimit nofile=4096:8192 \
|
||||
operacle/checkcle:latest
|
||||
|
||||
```
|
||||
|
||||
3. Admin Web Management
|
||||
|
||||
Default URL: http://0.0.0.0:8090
|
||||
User: admin@example.com
|
||||
Passwd: Admin123456
|
||||
|
||||
4. Follow the Quick Start Guide at https://docs.checkcle.com (Coming Soon)
|
||||
4. Follow the Quick Start Guide at https://docs.checkcle.io
|
||||
|
||||
###
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 📝 Development Roadmap
|
||||
|
||||
- [x] Health check & uptime monitoring (HTTP)
|
||||
- [x] Dashboard UI with live stats
|
||||
- [x] Auth with Multi-users system (admin)
|
||||
- [x] Notifications (Telegram)
|
||||
- [x] Docker containerization
|
||||
- [ ] SSL & Domain Monitoring
|
||||
- [ ] Infrastructure Server Monitoring
|
||||
- [ ] Schedule Maintenance & Incident Management
|
||||
- [ ] Operational Status / Public Status Pages
|
||||
- [ ] Uptime monitoring (TCP, PING, DNS)
|
||||
- [ ] User Permission Roles & Service Group
|
||||
- [ ] Notifications (Email/Slack/Discord/Signal)
|
||||
- [ ] Mobile PWA version
|
||||
- [ ] Open-source release with full documentation
|
||||
|
||||
## 🌟 CheckCle for Communities?
|
||||
- **Built with Passion**: Created by an open-source enthusiast for the community
|
||||
@@ -95,6 +127,42 @@ volumes:
|
||||
|
||||
---
|
||||
|
||||
## Sponsors
|
||||
Sponsorships will no longer be accepted. From now on, support will only be accepted in the form of ecosystem and community partnerships that provide infrastructure such as cloud servers, domains, or hosting credits.
|
||||
|
||||
If you’re a tech company interested in supporting CheckCle, please contact the author directly at tolaleng@checkcle.io
|
||||
|
||||
### 🤝 Ecosystem & Community Partner
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<a href="https://github.com/gitbookio">
|
||||
<img src="https://avatars.githubusercontent.com/u/7111340?s=200&v=4"
|
||||
width="75" height="75"
|
||||
style="border-radius: 50%;"
|
||||
alt="GitBook Logo" />
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com">
|
||||
<img src="https://cdn.checkcle.io/images/sponsor/cloudflare-checkcle_logo.png"
|
||||
height="60"
|
||||
alt="Cloudflare Logo" />
|
||||
</a>
|
||||
<a href="https://m.do.co/c/0c27ef82475f">
|
||||
<img src="https://cdn.checkcle.io/images/sponsor/digitalocean_checkcle.png"
|
||||
height="50"
|
||||
alt="DigitalOcean Logo" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 👥 Contributors
|
||||
Thank you for contributing and continuously making CheckCle better, you're awesome 🫶
|
||||
|
||||
[](https://github.com/operacle/checkcle/graphs/contributors)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Ways to Contribute
|
||||
|
||||
Here are some ways you can help improve CheckCle:
|
||||
@@ -103,21 +171,18 @@ Here are some ways you can help improve CheckCle:
|
||||
- 🌟 **Suggest Features** – Have an idea? Start a [Discussion](https://github.com/operacle/checkcle/discussions) or open a Feature Request issue.
|
||||
- 🛠 **Submit Pull Requests** – Improve the code, fix bugs, add features, or enhance the docs.
|
||||
- 📝 **Improve Documentation** – Even a typo fix helps!
|
||||
- 🌍 **Spread the Word** – Star ⭐ the repo, share it on socials, and invite others to contribute!
|
||||
- 🌍 **Spread the Word** – Star ⭐ the [CheckCle](https://github.com/operacle/checkcle.git) repo, share it on socials, and invite others to contribute!
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Stay Connected
|
||||
- Website: [checkcle.io](https://checkcle.io)
|
||||
- GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git)
|
||||
- Community Channels: Engage via discussions and issues!
|
||||
- Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
|
||||
- X: [@tlengoss](https://x.com/tlengoss)
|
||||
- Documentation: [docs.checkcle.io](https://docs.checkcle.io) | Big thanks to [GitBook](https://github.com/gitbookio) for sponsoring the OSS site plan for CheckCle!
|
||||
- Chat on Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
|
||||
- Follow us on X: [@checkcle_oss](https://x.com/checkcle_oss)
|
||||
|
||||
## 📜 License
|
||||
|
||||
CheckCle is released under the MIT License.
|
||||
|
||||
---
|
||||
|
||||
Stay informed, stay online, with CheckCle! 🌐
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# 🔐 Security Policy
|
||||
|
||||
## 📦 Project: [checkcle](https://github.com/operacle/checkcle)
|
||||
|
||||
**checkcle** is a self-hosted uptime and server monitoring tool built with TypeScript and Go.
|
||||
|
||||
We care about the security and privacy of users running this project in production environments.
|
||||
|
||||
---
|
||||
|
||||
## 📣 Reporting a Vulnerability
|
||||
|
||||
If you believe you have found a security vulnerability in this project:
|
||||
|
||||
- **DO NOT** open a public issue to report it.
|
||||
- Please report it responsibly via one of the following methods:
|
||||
|
||||
### 🔐 Preferred: [Report a Vulnerability via GitHub](https://github.com/operacle/checkcle/security/advisories/new)
|
||||
|
||||
- Use the GitHub security advisory form (private and secure).
|
||||
- Attach as much detail as possible:
|
||||
- Description of the issue
|
||||
- Affected version or commit hash
|
||||
- Reproduction steps
|
||||
- Impact and any potential mitigations
|
||||
- Logs or screenshots (if available)
|
||||
|
||||
### 📧 Alternatively: Contact the Maintainer
|
||||
- Email: `security@checkcle.io`
|
||||
- Optionally include a PGP public key for encrypted messages
|
||||
|
||||
We aim to respond within **3–5 business days**.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Supported Versions
|
||||
|
||||
We support the latest stable release of `checkcle`. Security patches may also be applied to recent versions at our discretion.
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| `main` (latest) | ✅ Yes |
|
||||
| Older versions | ⚠️ Best-effort |
|
||||
| Pre-release or forks | ❌ No |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Security Practices
|
||||
|
||||
CheckCle follows these practices to improve overall security:
|
||||
|
||||
- 🔎 Regular vulnerability scanning (npm audit for JavaScript dependencies, govulncheck for Go modules)
|
||||
- ⛓️ Dependency pinning (package-lock.json and Go modules)
|
||||
- ✅ Type-safe code in TypeScript and memory-safe design in Go
|
||||
- 🧪 Continuous testing and CI pipelines
|
||||
- 🔐 No data is stored or transmitted unless explicitly configured by the user
|
||||
- 🧑💻 All code contributions are reviewed before merging
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Known Security Limitations
|
||||
|
||||
- Outbound HTTPS requests: CheckCle agents perform outbound HTTPS connections to send metric data to the backend server. Avoid deploying in untrusted or high-risk environments without appropriate network policies and monitoring.
|
||||
- The data may be lost upon system restarts or crashes. Always ensure that backup (pb_data) and recovery mechanisms are in place in production environments.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is released under the [MIT License](./LICENSE). Use at your own risk. The Creator and contributors are not liable for misuse, data loss, or operational impact resulting from use of the software.
|
||||
|
||||
---
|
||||
|
||||
## 🙌 Acknowledgements
|
||||
|
||||
We appreciate responsible disclosures from the community. Your efforts help us make the open-source ecosystem safer for everyone.
|
||||
|
||||
Thanks & Regards,
|
||||
|
||||
— [Tola Leng](https://github.com/tolaleng)
|
||||
@@ -1,26 +1,29 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CheckCle is an open-source monitoring stack</title>
|
||||
<meta name="description" content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
|
||||
<meta name="author" content="Tola Leng" />
|
||||
|
||||
<meta property="og:title" content="checkcle-an-open-source" />
|
||||
<meta property="og:description" content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CheckCle - Dev</title>
|
||||
<meta name="description"
|
||||
content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
|
||||
<meta name="author" content="Tola Leng" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@tlengoss" />
|
||||
<meta name="twitter:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
|
||||
</head>
|
||||
<meta property="og:title" content="checkcle-an-open-source" />
|
||||
<meta property="og:description"
|
||||
content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<!-- IMPORTANT: DO NOT REMOVE THIS SCRIPT TAG OR THIS VERY COMMENT! -->
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@checkcle_oss" />
|
||||
<meta name="twitter:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -8,6 +8,7 @@
|
||||
"name": "vite_react_shadcn_ts",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "2.2.7",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
@@ -27,7 +28,7 @@
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-separator": "^1.1.6",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
@@ -43,6 +44,8 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"input-otp": "^1.2.4",
|
||||
"jspdf": "^3.0.1",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"pocketbase": "^0.19.0",
|
||||
@@ -91,13 +94,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.9.tgz",
|
||||
"integrity": "sha512-4zpTHZ9Cm6L9L+uIqghQX8ZXg8HKFcjYO3qHoO8zTmRm6HQUJ8SSJ+KRvbMBZn0EGVlT4DRYeQ/6hjlyXBh+Kg==",
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz",
|
||||
"integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -645,6 +645,21 @@
|
||||
"@floating-ui/utils": "^0.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.26.28",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
|
||||
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.2",
|
||||
"@floating-ui/utils": "^0.2.8",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
|
||||
@@ -664,6 +679,26 @@
|
||||
"integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@headlessui/react": {
|
||||
"version": "2.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.7.tgz",
|
||||
"integrity": "sha512-WKdTymY8Y49H8/gUc/lIyYK1M+/6dq0Iywh4zTZVAaiTDprRfioxSgD0wnXTQTBpjpGJuTL1NO/mqEvc//5SSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.16",
|
||||
"@react-aria/focus": "^3.20.2",
|
||||
"@react-aria/interactions": "^3.25.0",
|
||||
"@tanstack/react-virtual": "^3.13.9",
|
||||
"use-sync-external-store": "^1.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.0.tgz",
|
||||
@@ -1811,12 +1846,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz",
|
||||
"integrity": "sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==",
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
|
||||
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.0.0"
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1833,6 +1868,62 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.1.tgz",
|
||||
@@ -2225,6 +2316,103 @@
|
||||
"integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-aria/focus": {
|
||||
"version": "3.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.1.tgz",
|
||||
"integrity": "sha512-hmH1IhHlcQ2lSIxmki1biWzMbGgnhdxJUM0MFfzc71Rv6YAzhlx4kX3GYn4VNcjCeb6cdPv4RZ5vunV4kgMZYQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/interactions": "^3.25.5",
|
||||
"@react-aria/utils": "^3.30.1",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/interactions": {
|
||||
"version": "3.25.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.5.tgz",
|
||||
"integrity": "sha512-EweYHOEvMwef/wsiEqV73KurX/OqnmbzKQa2fLxdULbec5+yDj6wVGaRHIzM4NiijIDe+bldEl5DG05CAKOAHA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-aria/utils": "^3.30.1",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/ssr": {
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
|
||||
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/utils": {
|
||||
"version": "3.30.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.30.1.tgz",
|
||||
"integrity": "sha512-zETcbDd6Vf9GbLndO6RiWJadIZsBU2MMm23rBACXLmpRztkrIqPEb2RVdlLaq1+GklDx0Ii6PfveVjx+8S5U6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-stately/utils": "^3.10.8",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/flags": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
|
||||
"integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/utils": {
|
||||
"version": "3.10.8",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz",
|
||||
"integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.0.tgz",
|
||||
"integrity": "sha512-t+cligIJsZYFMSPFMvsJMjzlzde06tZMOIOFa1OV5Z0BcMowrb2g4mB57j/9nP28iJIRYn10xCniQts+qadrqQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz",
|
||||
@@ -2674,6 +2862,15 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/types": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.13.tgz",
|
||||
@@ -2738,6 +2935,33 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
|
||||
"integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
|
||||
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||
@@ -2832,6 +3056,13 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
|
||||
@@ -2853,6 +3084,13 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz",
|
||||
@@ -3206,6 +3444,18 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/atob": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
|
||||
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"bin": {
|
||||
"atob": "bin/atob.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.20",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
|
||||
@@ -3250,6 +3500,16 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -3318,6 +3578,18 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/btoa": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
|
||||
"integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"bin": {
|
||||
"btoa": "bin/btoa.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -3358,6 +3630,26 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -3843,6 +4135,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.45.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.0.tgz",
|
||||
"integrity": "sha512-c2KZL9lP4DjkN3hk/an4pWn5b5ZefhRJnAc42n6LJ19kSnbeRbdQZE5dSeE2LBol1OwJD3X1BQvFTAsa8ReeDA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3856,6 +4160,16 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -4064,6 +4378,16 @@
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
|
||||
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
@@ -4424,6 +4748,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@@ -4647,6 +4977,20 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -4848,6 +5192,33 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.1.tgz",
|
||||
"integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.7",
|
||||
"atob": "^2.1.2",
|
||||
"btoa": "^1.2.1",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf-autotable": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.2.tgz",
|
||||
"integrity": "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"jspdf": "^2 || ^3"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -5208,6 +5579,13 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -5450,6 +5828,16 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -5708,10 +6096,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
||||
"license": "MIT"
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.8",
|
||||
@@ -5750,6 +6139,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.24.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz",
|
||||
@@ -5883,6 +6282,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
@@ -6039,6 +6448,22 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.4.tgz",
|
||||
@@ -6095,6 +6520,16 @@
|
||||
"tailwindcss": ">=3.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/text-table": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||
@@ -6308,12 +6743,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "0.9.9",
|
||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "2.2.7",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
@@ -30,7 +31,7 @@
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-separator": "^1.1.6",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
@@ -45,7 +46,10 @@
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"framer-motion": "12.23.12",
|
||||
"input-otp": "^1.2.4",
|
||||
"jspdf": "^3.0.1",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"pocketbase": "^0.19.0",
|
||||
@@ -80,4 +84,4 @@
|
||||
"typescript-eslint": "^8.0.1",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -1,107 +1,166 @@
|
||||
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import React, { useState } from "react";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authService } from "@/services/authService";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { LanguageProvider } from "@/contexts/LanguageContext";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import { LanguageProvider } from "./contexts/LanguageContext";
|
||||
import { SidebarProvider } from "./contexts/SidebarContext";
|
||||
|
||||
import Index from "./pages/Index";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import InstanceMonitoring from "./pages/InstanceMonitoring";
|
||||
import ContainerMonitoring from "./pages/ContainerMonitoring";
|
||||
import ServiceDetail from "./pages/ServiceDetail";
|
||||
import SslDomain from "./pages/SslDomain";
|
||||
import ScheduleIncident from "./pages/ScheduleIncident";
|
||||
import OperationalPage from "./pages/OperationalPage";
|
||||
import RegionalMonitoring from "./pages/RegionalMonitoring";
|
||||
import Settings from "./pages/Settings";
|
||||
import Profile from "./pages/Profile";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import PublicStatusPage from "./pages/PublicStatusPage";
|
||||
import { ProtectedRoute } from "./components/auth/ProtectedRoute";
|
||||
import ServerDetail from "./pages/ServerDetail";
|
||||
|
||||
// Create a Protected route component
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const isAuthenticated = authService.isAuthenticated();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const App = () => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check authentication status when the app loads
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
// Just check the auth state
|
||||
authService.isAuthenticated();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center h-screen bg-background text-foreground">Loading...</div>;
|
||||
}
|
||||
function App() {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<SidebarProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster />
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/public/:slug" element={<PublicStatusPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Index />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/service/:id"
|
||||
<Route
|
||||
path="/instance-monitoring"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<InstanceMonitoring />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/server-detail/:serverId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ServerDetail />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/container-monitoring"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ContainerMonitoring />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/container-monitoring/:serverId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ContainerMonitoring />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/service/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ServiceDetail />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
<Route
|
||||
path="/ssl-domain"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SslDomain />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/schedule-incident"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ScheduleIncident />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/operational-page"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<OperationalPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/regional-monitoring"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<RegionalMonitoring />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Settings />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Profile />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
}
|
||||
/>
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</SidebarProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
// API routes handler
|
||||
import realtime from './realtime';
|
||||
import settingsApi from './settings';
|
||||
|
||||
/**
|
||||
* Simple API router for client-side application
|
||||
@@ -10,16 +11,19 @@ const api = {
|
||||
* Handle API requests
|
||||
*/
|
||||
async handleRequest(path, method, body) {
|
||||
console.log(`API request: ${method} ${path}`, body);
|
||||
// console.log(`API request: ${method} ${path}`, body);
|
||||
|
||||
// Route to the appropriate handler
|
||||
if (path === '/api/realtime') {
|
||||
console.log("Routing to realtime handler");
|
||||
// console.log("Routing to realtime handler");
|
||||
return await realtime(body);
|
||||
} else if (path === '/api/settings' || path.startsWith('/api/settings/')) {
|
||||
// console.log("Routing to settings handler");
|
||||
return await settingsApi(body, path);
|
||||
}
|
||||
|
||||
// Return 404 for unknown routes
|
||||
console.error(`Endpoint not found: ${path}`);
|
||||
// console.error(`Endpoint not found: ${path}`);
|
||||
return {
|
||||
status: 404,
|
||||
json: {
|
||||
@@ -31,4 +35,52 @@ const api = {
|
||||
}
|
||||
};
|
||||
|
||||
export default api;
|
||||
// Mock fetch override for development
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async (url, options = {}) => {
|
||||
// Check if this is an API request to our mock endpoints
|
||||
if (typeof url === 'string' && url.startsWith('/api/')) {
|
||||
// console.log('Intercepting API request:', url, options);
|
||||
|
||||
try {
|
||||
let body = {};
|
||||
|
||||
// Properly handle different body types
|
||||
if (options.body) {
|
||||
if (typeof options.body === 'string') {
|
||||
body = JSON.parse(options.body);
|
||||
} else {
|
||||
// Handle ReadableStream or other BodyInit types
|
||||
const bodyText = await new Response(options.body).text();
|
||||
body = bodyText ? JSON.parse(bodyText) : {};
|
||||
}
|
||||
}
|
||||
|
||||
const result = await api.handleRequest(url, options.method || 'GET', body);
|
||||
|
||||
// Create a proper Response object
|
||||
return new Response(JSON.stringify(result.json), {
|
||||
status: result.status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in API handler:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
message: 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// For all other requests, use the original fetch
|
||||
return originalFetch(url, options);
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -2,7 +2,7 @@
|
||||
// This file handles realtime notifications in a client-side environment
|
||||
// In a production app, this would be a server-side endpoint
|
||||
|
||||
console.log("API Realtime endpoint loaded");
|
||||
//console.log("API Realtime endpoint loaded");
|
||||
|
||||
// Simple implementation that simulates sending notifications
|
||||
export default async function handler(req) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
import { getAuthHeaders, getBaseUrl } from '../utils';
|
||||
import { SettingsApiResponse } from '../types';
|
||||
|
||||
export const getSettings = async (): Promise<SettingsApiResponse> => {
|
||||
try {
|
||||
const response = await fetch(`${getBaseUrl()}/api/settings`, {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
|
||||
const settings = await response.json();
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: true, data: settings },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
return {
|
||||
status: 500,
|
||||
json: { success: false, message: 'Failed to fetch settings' },
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
|
||||
import { getAuthHeaders, getBaseUrl, validateEmail } from '../utils';
|
||||
import { SettingsApiResponse } from '../types';
|
||||
|
||||
const createEmailTemplate = (template: string, data: any): { subject: string; htmlBody: string } => {
|
||||
let subject = 'Test Email from CheckCle';
|
||||
let htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #2563eb;">Test Email</h2>
|
||||
<p>This is a test email from your monitoring system.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">
|
||||
Sent from CheckCle Monitoring System<br>
|
||||
Template: ${template}<br>
|
||||
${data.collection ? `Collection: ${data.collection}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
switch (template) {
|
||||
case 'verification':
|
||||
subject = 'Email Verification Test - CheckCle';
|
||||
htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #10b981;">Email Verification Test</h2>
|
||||
<p>This is a test of the email verification template.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>Template:</strong> Verification Email</p>
|
||||
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
break;
|
||||
case 'password-reset':
|
||||
subject = 'Password Reset Test - CheckCle';
|
||||
htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #f59e0b;">Password Reset Test</h2>
|
||||
<p>This is a test of the password reset template.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>Template:</strong> Password Reset Email</p>
|
||||
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
break;
|
||||
case 'email-change':
|
||||
subject = 'Email Change Confirmation Test - CheckCle';
|
||||
htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #8b5cf6;">Email Change Confirmation Test</h2>
|
||||
<p>This is a test of the email change confirmation template.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>Template:</strong> Email Change Confirmation</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
|
||||
return { subject, htmlBody };
|
||||
};
|
||||
|
||||
export const sendTestEmail = async (data: any): Promise<SettingsApiResponse> => {
|
||||
console.log('sendTestEmail function called with data:', data);
|
||||
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!data || typeof data !== 'object') {
|
||||
console.log('Invalid request data - not object');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Invalid request data' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!data.email || typeof data.email !== 'string') {
|
||||
console.log('Email address missing or invalid type');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Email address is required and must be a string' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!validateEmail(data.email)) {
|
||||
console.log('Invalid email format:', data.email);
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Invalid email address format' },
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Email validation passed for:', data.email);
|
||||
|
||||
const headers = getAuthHeaders();
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
// Get current SMTP settings first
|
||||
console.log('Fetching SMTP settings from:', `${baseUrl}/api/settings`);
|
||||
|
||||
const settingsResponse = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!settingsResponse.ok) {
|
||||
console.error('Failed to get SMTP settings, status:', settingsResponse.status);
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Failed to get SMTP settings' },
|
||||
};
|
||||
}
|
||||
|
||||
const settingsData = await settingsResponse.json();
|
||||
console.log('Retrieved settings data:', settingsData);
|
||||
|
||||
const smtpSettings = settingsData?.smtp;
|
||||
|
||||
if (!smtpSettings || !smtpSettings.enabled) {
|
||||
console.log('SMTP not enabled or missing');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'SMTP is not enabled. Please enable and configure SMTP settings first.' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!smtpSettings.host || !smtpSettings.username) {
|
||||
console.log('SMTP configuration incomplete - missing host or username');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'SMTP configuration is incomplete. Please check host and username.' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!smtpSettings.password) {
|
||||
console.log('SMTP password missing');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'SMTP password is required for authentication. Please configure the SMTP password.' },
|
||||
};
|
||||
}
|
||||
|
||||
// Create test email content based on template
|
||||
const template = data.template || 'basic';
|
||||
const { subject, htmlBody } = createEmailTemplate(template, data);
|
||||
|
||||
console.log('Test email prepared successfully:', {
|
||||
to: data.email,
|
||||
subject: subject,
|
||||
template: template,
|
||||
smtpHost: smtpSettings.host,
|
||||
smtpPort: smtpSettings.port || 587
|
||||
});
|
||||
|
||||
// Simulate processing time
|
||||
console.log('Simulating email send...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
console.log('Email send simulation completed');
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
success: true,
|
||||
message: `Test email sent successfully to ${data.email}`,
|
||||
},
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in sendTestEmail function:', error);
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to send test email. Please check your SMTP configuration.'
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
import { getAuthHeaders, getBaseUrl, validateEmail } from '../utils';
|
||||
import { SettingsApiResponse } from '../types';
|
||||
|
||||
const createEmailTemplate = (template: string, data: any): { subject: string; htmlBody: string } => {
|
||||
let subject = 'Test Email from CheckCle';
|
||||
let htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #2563eb;">Test Email</h2>
|
||||
<p>This is a test email from your monitoring system.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">
|
||||
Sent from CheckCle Monitoring System<br>
|
||||
Template: ${template}<br>
|
||||
${data.collection ? `Collection: ${data.collection}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
switch (template) {
|
||||
case 'verification':
|
||||
subject = 'Email Verification Test - CheckCle';
|
||||
htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #10b981;">Email Verification Test</h2>
|
||||
<p>This is a test of the email verification template.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>Template:</strong> Verification Email</p>
|
||||
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
break;
|
||||
case 'password-reset':
|
||||
subject = 'Password Reset Test - CheckCle';
|
||||
htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #f59e0b;">Password Reset Test</h2>
|
||||
<p>This is a test of the password reset template.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>Template:</strong> Password Reset Email</p>
|
||||
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
break;
|
||||
case 'email-change':
|
||||
subject = 'Email Change Confirmation Test - CheckCle';
|
||||
htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #8b5cf6;">Email Change Confirmation Test</h2>
|
||||
<p>This is a test of the email change confirmation template.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>Template:</strong> Email Change Confirmation</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
|
||||
return { subject, htmlBody };
|
||||
};
|
||||
|
||||
export const testEmail = async (data: any): Promise<SettingsApiResponse> => {
|
||||
console.log('testEmail function called with data:', data);
|
||||
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!data || typeof data !== 'object') {
|
||||
console.log('Invalid request data - not object');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Invalid request data' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!data.email || typeof data.email !== 'string') {
|
||||
console.log('Email address missing or invalid type');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Email address is required and must be a string' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!validateEmail(data.email)) {
|
||||
console.log('Invalid email format:', data.email);
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Invalid email address format' },
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Email validation passed for:', data.email);
|
||||
|
||||
const headers = getAuthHeaders();
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
// Get current SMTP settings first
|
||||
console.log('Fetching SMTP settings from:', `${baseUrl}/api/settings`);
|
||||
|
||||
const settingsResponse = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!settingsResponse.ok) {
|
||||
console.error('Failed to get SMTP settings, status:', settingsResponse.status);
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Failed to get SMTP settings' },
|
||||
};
|
||||
}
|
||||
|
||||
const settingsData = await settingsResponse.json();
|
||||
console.log('Retrieved settings data:', settingsData);
|
||||
|
||||
const smtpSettings = settingsData?.smtp;
|
||||
|
||||
if (!smtpSettings || !smtpSettings.enabled) {
|
||||
console.log('SMTP not enabled or missing');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'SMTP is not enabled. Please enable and configure SMTP settings first.' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!smtpSettings.host || !smtpSettings.username) {
|
||||
console.log('SMTP configuration incomplete - missing host or username');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'SMTP configuration is incomplete. Please check host and username.' },
|
||||
};
|
||||
}
|
||||
|
||||
// Create test email content based on template
|
||||
const template = data.template || 'basic';
|
||||
const { subject, htmlBody } = createEmailTemplate(template, data);
|
||||
|
||||
console.log('Test email prepared successfully:', {
|
||||
to: data.email,
|
||||
subject: subject,
|
||||
template: template,
|
||||
smtpHost: smtpSettings.host,
|
||||
smtpPort: smtpSettings.port || 587
|
||||
});
|
||||
|
||||
// Send actual email using the correct PocketBase API endpoint
|
||||
console.log('Sending actual email via PocketBase...');
|
||||
|
||||
// Fix the payload structure to match PocketBase API expectations
|
||||
const emailPayload = {
|
||||
email: data.email, // Use 'email' instead of 'to'
|
||||
template: template, // Add the template field
|
||||
subject: subject,
|
||||
html: htmlBody,
|
||||
};
|
||||
|
||||
console.log('Email payload:', emailPayload);
|
||||
|
||||
const emailResponse = await fetch(`${baseUrl}/api/settings/test/email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(emailPayload),
|
||||
});
|
||||
|
||||
if (!emailResponse.ok) {
|
||||
console.error('Failed to send email, status:', emailResponse.status);
|
||||
const errorText = await emailResponse.text();
|
||||
console.error('Email send error response:', errorText);
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Failed to send email. Please check your SMTP configuration.' },
|
||||
};
|
||||
}
|
||||
|
||||
// Handle 204 No Content response (successful but no body)
|
||||
if (emailResponse.status === 204) {
|
||||
console.log('Email sent successfully (204 No Content)');
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
success: true,
|
||||
message: `Test email sent successfully to ${data.email}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// For other successful responses, try to parse JSON
|
||||
const emailResult = await emailResponse.json();
|
||||
console.log('Email sent successfully:', emailResult);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
success: true,
|
||||
message: `Test email sent successfully to ${data.email}`,
|
||||
},
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in testEmail function:', error);
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to send test email. Please check your SMTP configuration.'
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
import { getAuthHeaders, getBaseUrl } from '../utils';
|
||||
import { SettingsApiResponse } from '../types';
|
||||
|
||||
export const testEmailConnection = async (data: any): Promise<SettingsApiResponse> => {
|
||||
try {
|
||||
const response = await fetch(`${getBaseUrl()}/api/settings/test-email`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
success: result.success || false,
|
||||
message:
|
||||
result.message || (result.success ? 'Connection successful' : 'Connection failed'),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error testing email connection:', error);
|
||||
return {
|
||||
status: 500,
|
||||
json: { success: false, message: 'Failed to test email connection' },
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
import { getAuthHeaders, getBaseUrl } from '../utils';
|
||||
import { SettingsApiResponse } from '../types';
|
||||
|
||||
export const updateSettings = async (data: any): Promise<SettingsApiResponse> => {
|
||||
try {
|
||||
const headers = getAuthHeaders();
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
let response = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok && (response.status === 404 || response.status === 405)) {
|
||||
response = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
|
||||
const updatedSettings = await response.json();
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: true, data: updatedSettings },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating settings:', error);
|
||||
return {
|
||||
status: 500,
|
||||
json: { success: false, message: 'Failed to update settings' },
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
|
||||
import { getSettings } from './actions/getSettings';
|
||||
import { updateSettings } from './actions/updateSettings';
|
||||
import { testEmailConnection } from './actions/testEmailConnection';
|
||||
import { testEmail } from './actions/testEmail';
|
||||
|
||||
/**
|
||||
* Settings API handler
|
||||
*/
|
||||
const settingsApi = async (body: any, path?: string) => {
|
||||
// console.log('Settings API called with path:', path, 'body:', body);
|
||||
|
||||
// Handle test email endpoint specifically
|
||||
if (path === '/api/settings/test/email') {
|
||||
console.log('Handling test email request');
|
||||
return await testEmail(body);
|
||||
}
|
||||
|
||||
// Handle regular settings API with action-based routing
|
||||
const action = body?.action;
|
||||
// console.log('Settings API called with action:', action, 'data:', body?.data);
|
||||
|
||||
switch (action) {
|
||||
case 'getSettings':
|
||||
return await getSettings();
|
||||
|
||||
case 'updateSettings':
|
||||
return await updateSettings(body.data);
|
||||
|
||||
case 'testEmailConnection':
|
||||
return await testEmailConnection(body.data);
|
||||
|
||||
default:
|
||||
console.error('Unknown action:', action);
|
||||
return {
|
||||
status: 400,
|
||||
json: { success: false, message: 'Unknown action' },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default settingsApi;
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
export interface SettingsApiRequest {
|
||||
action: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface SettingsApiResponse {
|
||||
status: number;
|
||||
json: {
|
||||
success: boolean;
|
||||
data?: any;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SmtpSettings {
|
||||
enabled?: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
authMethod?: string;
|
||||
tls?: boolean;
|
||||
localName?: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
import { pb, getCurrentEndpoint } from '@/lib/pocketbase';
|
||||
|
||||
export const getAuthHeaders = (): Record<string, string> => {
|
||||
const authToken = pb.authStore.token;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const getBaseUrl = (): string => {
|
||||
return getCurrentEndpoint();
|
||||
};
|
||||
|
||||
export const validateEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center p-6">
|
||||
<h2 className="text-2xl font-bold text-destructive mb-4">Something went wrong</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { Mail, ArrowLeft } from 'lucide-react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { getCurrentEndpoint } from '@/lib/pocketbase';
|
||||
|
||||
interface ForgotPasswordDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ForgotPasswordDialog({ open, onOpenChange }: ForgotPasswordDialogProps) {
|
||||
const [step, setStep] = useState<'request' | 'confirm'>('request');
|
||||
const [email, setEmail] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useLanguage();
|
||||
|
||||
const handleRequestReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const apiUrl = getCurrentEndpoint();
|
||||
const response = await fetch(`${apiUrl}/api/collections/_superusers/request-password-reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to send reset email');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Reset Email Sent",
|
||||
description: "Please check your email for password reset instructions.",
|
||||
});
|
||||
setStep('confirm');
|
||||
} catch (error) {
|
||||
console.error('Password reset request error:', error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Reset Failed",
|
||||
description: error instanceof Error ? error.message : "Failed to send reset email. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Password Mismatch",
|
||||
description: "Passwords do not match. Please try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Password Too Short",
|
||||
description: "Password must be at least 6 characters long.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const apiUrl = getCurrentEndpoint();
|
||||
const response = await fetch(`${apiUrl}/api/collections/_superusers/confirm-password-reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
password,
|
||||
passwordConfirm
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to reset password');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Password Reset Successful",
|
||||
description: "Your password has been reset successfully. You can now log in with your new password.",
|
||||
});
|
||||
onOpenChange(false);
|
||||
// Reset form state
|
||||
setStep('request');
|
||||
setEmail('');
|
||||
setToken('');
|
||||
setPassword('');
|
||||
setPasswordConfirm('');
|
||||
} catch (error) {
|
||||
console.error('Password reset confirmation error:', error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Reset Failed",
|
||||
description: error instanceof Error ? error.message : "Failed to reset password. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
// Reset form state when closing
|
||||
setStep('request');
|
||||
setEmail('');
|
||||
setToken('');
|
||||
setPassword('');
|
||||
setPasswordConfirm('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === 'request' ? 'Reset Password' : 'Confirm Password Reset'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 'request'
|
||||
? 'Enter your email address and we\'ll send you a reset link.'
|
||||
: 'Enter the reset token from your email and your new password.'
|
||||
}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'request' ? (
|
||||
<form onSubmit={handleRequestReset} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="reset-email">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Input
|
||||
id="reset-email"
|
||||
placeholder="your.email@provider.com"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !email}
|
||||
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send Reset Email'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleConfirmReset} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="reset-token">
|
||||
Reset Token
|
||||
</label>
|
||||
<Input
|
||||
id="reset-token"
|
||||
placeholder="Enter token from email"
|
||||
type="text"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="new-password">
|
||||
New Password
|
||||
</label>
|
||||
<Input
|
||||
id="new-password"
|
||||
placeholder="••••••••••••"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="confirm-password">
|
||||
Confirm Password
|
||||
</label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
placeholder="••••••••••••"
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setStep('request')}
|
||||
className="flex-1"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !token || !password || !passwordConfirm}
|
||||
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
|
||||
>
|
||||
{loading ? 'Resetting...' : 'Reset Password'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { authService } from '@/services/authService';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
||||
if (!authService.isAuthenticated()) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
@@ -7,6 +6,7 @@ import { StatusCards } from "./StatusCards";
|
||||
import { ServiceFilters } from "./ServiceFilters";
|
||||
import { ServicesTable } from "./ServicesTable";
|
||||
import { AddServiceDialog } from "@/components/services/AddServiceDialog";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface DashboardContentProps {
|
||||
services: Service[];
|
||||
@@ -14,16 +14,24 @@ interface DashboardContentProps {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export const DashboardContent = ({ services, isLoading, error }: DashboardContentProps) => {
|
||||
export const DashboardContent = ({
|
||||
services,
|
||||
isLoading,
|
||||
error,
|
||||
}: DashboardContentProps) => {
|
||||
const { t } = useLanguage();
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState<boolean>(false);
|
||||
|
||||
// Filter services based on search term and type filter
|
||||
const filteredServices = services.filter(service => {
|
||||
const matchesSearch = service.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(service.url && service.url.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const matchesFilter = filter === 'all' || service.type.toLowerCase() === filter.toLowerCase();
|
||||
const filteredServices = services.filter((service) => {
|
||||
const matchesSearch =
|
||||
service.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(service.url &&
|
||||
service.url.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const matchesFilter =
|
||||
filter === "all" || service.type.toLowerCase() === filter.toLowerCase();
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
|
||||
@@ -31,40 +39,42 @@ export const DashboardContent = ({ services, isLoading, error }: DashboardConten
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-foreground">
|
||||
<p>Error loading service data.</p>
|
||||
<Button onClick={() => window.location.reload()}>Retry</Button>
|
||||
<Button onClick={() => window.location.reload()}>{t("retry")}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 flex flex-col overflow-auto bg-background p-6 pb-0">
|
||||
<div className="flex flex-col flex-1">
|
||||
<main className="flex-1 flex flex-col overflow-auto dark:bg-black p-2 shadow pb-0 ">
|
||||
<div className="flex flex-col flex-1 bg-white dark:bg-black p-4 rounded-xl ">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-foreground">Overview</h2>
|
||||
<Button
|
||||
<h2 className="text-2xl font-bold text-foreground">
|
||||
{t("overview")}
|
||||
</h2>
|
||||
<Button
|
||||
className="text-primary-foreground"
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> New Service
|
||||
<Plus className="w-4 h-4 mr-2" /> {t("newService")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<StatusCards services={services} />
|
||||
|
||||
<ServiceFilters
|
||||
|
||||
<ServiceFilters
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
servicesCount={filteredServices.length}
|
||||
/>
|
||||
|
||||
|
||||
<div className="flex-1 flex flex-col pb-6">
|
||||
<ServicesTable services={filteredServices} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddServiceDialog
|
||||
<AddServiceDialog
|
||||
open={isAddDialogOpen}
|
||||
onOpenChange={setIsAddDialogOpen}
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AuthUser } from "@/services/authService";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { Moon, PanelLeft, PanelLeftClose, Sun, Globe, FileText, Github, Twitter, MessageSquare, Bell, User, Settings, LogOut, Grid3x3 } from "lucide-react";
|
||||
import {
|
||||
Moon,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
Sun,
|
||||
Globe,
|
||||
FileText,
|
||||
Github,
|
||||
Twitter,
|
||||
MessageSquare,
|
||||
Bell,
|
||||
User,
|
||||
Settings,
|
||||
LogOut,
|
||||
CaseUpper,
|
||||
BookA,
|
||||
CaseSensitive,
|
||||
CaseLower,
|
||||
ALargeSmall,
|
||||
} from "lucide-react";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||
import useDynamicFontSize from "@/contexts/FontSizeContext";
|
||||
|
||||
interface HeaderProps {
|
||||
currentUser: AuthUser | null;
|
||||
@@ -21,7 +46,7 @@ export const Header = ({
|
||||
currentUser,
|
||||
onLogout,
|
||||
sidebarCollapsed,
|
||||
toggleSidebar
|
||||
toggleSidebar,
|
||||
}: HeaderProps) => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { language, setLanguage, t } = useLanguage();
|
||||
@@ -51,121 +76,275 @@ export const Header = ({
|
||||
// Log avatar data for debugging
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
console.log("Avatar URL in Header:", currentUser.avatar);
|
||||
//console.log("Avatar URL in Header:", currentUser.avatar);
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
// Prepare avatar URL - ensure it displays correctly if it's a local profile image
|
||||
let avatarUrl = '';
|
||||
let avatarUrl = "";
|
||||
if (currentUser?.avatar) {
|
||||
// If it's a relative path from the public folder, make sure it's resolved properly
|
||||
if (currentUser.avatar.startsWith('/upload/profile/')) {
|
||||
if (currentUser.avatar.startsWith("/upload/profile/")) {
|
||||
avatarUrl = currentUser.avatar;
|
||||
} else {
|
||||
avatarUrl = currentUser.avatar;
|
||||
}
|
||||
console.log("Final avatar URL:", avatarUrl);
|
||||
}
|
||||
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
const savedFontSize = localStorage.getItem("fontSize");
|
||||
return savedFontSize ? parseInt(savedFontSize, 10) : 14;
|
||||
});
|
||||
useDynamicFontSize(fontSize);
|
||||
return (
|
||||
<header className="relative bg-background border-b border-border px-6 flex justify-between items-center py-[12px] overflow-hidden">
|
||||
{/* Grid Pattern Overlay - Similar to StatusCards */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px),
|
||||
linear-gradient(90deg, ${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px'
|
||||
<div
|
||||
className="w-full h-full dark:bg-black"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${
|
||||
theme === "dark" ? "#ffffff10" : "#00000010"
|
||||
} 1px, transparent 1px),
|
||||
linear-gradient(90deg, ${
|
||||
theme === "dark" ? "#ffffff10" : "#00000010"
|
||||
} 1px, transparent 1px)`,
|
||||
backgroundSize: "20px 20px",
|
||||
}}
|
||||
>
|
||||
<div className="w-full h-full backdrop-blur-[1px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Header Content */}
|
||||
<div className="flex items-center gap-4 z-10">
|
||||
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2">
|
||||
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
|
||||
<div
|
||||
className={` p-1 h-8 w-8 -ml-2 rounded bg-gray-600 ${
|
||||
!sidebarCollapsed ? "hidden" : ""
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src="/favicon_sidebar.ico"
|
||||
alt="CheckCle"
|
||||
className={`h-6 w-6 `}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className="mr-2"
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<PanelLeft className="h-5 w-5" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Grid3x3 className="h-5 w-5 text-green-500" />
|
||||
<h1 className="text-lg font-medium">{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 ✨</h1>
|
||||
<h1 className="font-medium text-xl">
|
||||
{greeting},{" "}
|
||||
{currentUser?.name || currentUser?.email?.split("@")[0] || "User"}{" "}
|
||||
👋 ✨
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center space-x-4 z-10">
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border" onClick={toggleTheme}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
{theme === "dark" ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
>
|
||||
<span className="sr-only">{t("language")}</span>
|
||||
<Globe className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setLanguage("en")} className={language === "en" ? "bg-accent" : ""}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setLanguage("en")}
|
||||
className={language === "en" ? "bg-accent" : ""}
|
||||
>
|
||||
{t("english")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setLanguage("km")}
|
||||
className={language === "km" ? "bg-accent" : ""}
|
||||
>
|
||||
{t("khmer")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setLanguage("de")}
|
||||
className={language === "de" ? "bg-accent" : ""}
|
||||
>
|
||||
{t("Deutsch")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setLanguage("ko")}
|
||||
className={language === "ko" ? "bg-accent" : ""}
|
||||
>
|
||||
{t("korean")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setLanguage("ja")}
|
||||
className={language === "ja" ? "bg-accent" : ""}
|
||||
>
|
||||
{t("japanese")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setLanguage("zhcn")}
|
||||
className={language === "zhcn" ? "bg-accent" : ""}
|
||||
>
|
||||
{t("simplifiedChinese")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
|
||||
{/* Documentation */}
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
onClick={() => window.open("https://docs.checkcle.io", "_blank")}
|
||||
>
|
||||
<span className="sr-only">{t("documentation")}</span>
|
||||
<FileText className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
|
||||
{/* GitHub */}
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
onClick={() =>
|
||||
window.open("https://github.com/operacle/checkcle", "_blank")
|
||||
}
|
||||
>
|
||||
<span className="sr-only">GitHub</span>
|
||||
<Github className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
|
||||
{/* X (Twitter) */}
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
onClick={() => window.open("https://x.com/checkcle_oss)", "_blank")}
|
||||
>
|
||||
<span className="sr-only">X (Twitter)</span>
|
||||
<Twitter className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Discord (replaced with MessageSquare) */}
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
||||
|
||||
{/* Discord */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
onClick={() => window.open("https://discord.gg/xs9gbubGwX", "_blank")}
|
||||
>
|
||||
<span className="sr-only">Discord</span>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
|
||||
{/* Notifications */}
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
>
|
||||
<span className="sr-only">{t("notifications")}</span>
|
||||
<Bell className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Fond Size */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
>
|
||||
<span className="sr-only">{t("language")}</span>
|
||||
<BookA className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setFontSize((prev) => prev + 1)}
|
||||
className={`cursor-pointer`}
|
||||
>
|
||||
<span className={`text-[15px] font-semibold`}> Aa +</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setFontSize((prev) => prev - 1)}
|
||||
className={`cursor-pointer`}
|
||||
>
|
||||
<span className={`text-[12px] font-semibold`}> Aa -</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* User Profile Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Avatar className="h-8 w-8 cursor-pointer ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} /> : <AvatarFallback className="bg-primary/20 text-primary">
|
||||
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
|
||||
</AvatarFallback>}
|
||||
{avatarUrl ? (
|
||||
<AvatarImage
|
||||
src={avatarUrl}
|
||||
alt={
|
||||
currentUser?.name ||
|
||||
currentUser?.email?.split("@")[0] ||
|
||||
"User"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<AvatarFallback className="bg-primary/20 text-primary">
|
||||
{currentUser?.name?.[0] || currentUser?.email?.[0] || "U"}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="flex items-center gap-3 p-2">
|
||||
<Avatar className="h-10 w-10">
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} /> : <AvatarFallback className="bg-primary/20 text-primary">
|
||||
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
|
||||
</AvatarFallback>}
|
||||
{avatarUrl ? (
|
||||
<AvatarImage
|
||||
src={avatarUrl}
|
||||
alt={
|
||||
currentUser?.name ||
|
||||
currentUser?.email?.split("@")[0] ||
|
||||
"User"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<AvatarFallback className="bg-primary/20 text-primary">
|
||||
{currentUser?.name?.[0] || currentUser?.email?.[0] || "U"}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span className="font-medium">{currentUser?.name || 'User'}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{currentUser?.email}</span>
|
||||
<span className="font-medium">
|
||||
{currentUser?.name || "User"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{currentUser?.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -178,7 +357,10 @@ export const Header = ({
|
||||
<span>{t("settings")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onLogout} className="text-red-500 focus:text-red-500">
|
||||
<DropdownMenuItem
|
||||
onClick={onLogout}
|
||||
className="text-red-500 focus:text-red-500"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>{t("logout")}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface ServiceFiltersProps {
|
||||
filter: string;
|
||||
@@ -12,28 +18,31 @@ interface ServiceFiltersProps {
|
||||
servicesCount: number;
|
||||
}
|
||||
|
||||
export const ServiceFilters = ({
|
||||
filter,
|
||||
setFilter,
|
||||
searchTerm,
|
||||
export const ServiceFilters = ({
|
||||
filter,
|
||||
setFilter,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
servicesCount
|
||||
servicesCount,
|
||||
}: ServiceFiltersProps) => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="mb-6 flex justify-between items-center ">
|
||||
<div className="flex items-center">
|
||||
<h3 className="text-xl font-semibold mr-2 text-foreground">Currently Monitoring</h3>
|
||||
<h3 className="text-xl font-semibold mr-2 text-foreground">
|
||||
{t("currentlyMonitoring")}
|
||||
</h3>
|
||||
<span className="bg-secondary text-secondary-foreground px-2 py-0.5 rounded text-sm">
|
||||
{servicesCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex space-x-4">
|
||||
<Select value={filter} onValueChange={setFilter}>
|
||||
<SelectTrigger className="w-40 bg-card border-border">
|
||||
<SelectValue placeholder="All Types" />
|
||||
<SelectTrigger className="w-40 bg-card border-border dark:bg-gray-800">
|
||||
<SelectValue placeholder={t("allTypes")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="all">{t("allTypes")}</SelectItem>
|
||||
<SelectItem value="HTTP">HTTP</SelectItem>
|
||||
<SelectItem value="PING">PING</SelectItem>
|
||||
<SelectItem value="TCP">TCP</SelectItem>
|
||||
@@ -41,9 +50,9 @@ export const ServiceFilters = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-72 bg-card border-border"
|
||||
placeholder="Search"
|
||||
<Input
|
||||
className="w-72 bg-card border-border"
|
||||
placeholder={t("search")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -1,141 +1,34 @@
|
||||
|
||||
import { Globe, Boxes, Radar, Calendar, BarChart2, LineChart, FileText, Settings, User, UserCog, Bell, FileClock, Database, RefreshCw, Info, ChevronDown, BookOpen } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { SidebarHeader } from "./sidebar/SidebarHeader";
|
||||
import { MainNavigation } from "./sidebar/MainNavigation";
|
||||
import { SettingsPanel } from "./sidebar/SettingsPanel";
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export const Sidebar = ({
|
||||
collapsed
|
||||
}: SidebarProps) => {
|
||||
const {
|
||||
theme
|
||||
} = useTheme();
|
||||
const {
|
||||
t
|
||||
} = useLanguage();
|
||||
const location = useLocation();
|
||||
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>("general");
|
||||
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
|
||||
export const Sidebar = ({ collapsed }: SidebarProps) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
// Update active settings item based on URL
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/settings') {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const panel = params.get('panel');
|
||||
if (panel) {
|
||||
setActiveSettingsItem(panel);
|
||||
}
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const handleSettingsItemClick = (item: string) => {
|
||||
setActiveSettingsItem(item);
|
||||
};
|
||||
|
||||
const getMenuItemClasses = (isActive: boolean) => {
|
||||
return `p-2 ${isActive ? theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent'}`} rounded-lg flex items-center`;
|
||||
};
|
||||
|
||||
// New larger icon size for the main menu
|
||||
const mainIconSize = "h-6 w-6";
|
||||
|
||||
return <div className={`${collapsed ? 'w-16' : 'w-64'} ${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'} border-r flex flex-col transition-all duration-300 h-full`}>
|
||||
<div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
|
||||
<div className="h-8 w-8 bg-green-500 rounded flex items-center justify-center mr-2">
|
||||
<span className="text-white font-bold">C</span>
|
||||
</div>
|
||||
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
${
|
||||
theme === "dark"
|
||||
? "bg-black border-[#1e1e1e]"
|
||||
: "bg-sidebars border-sidebar-border"
|
||||
}
|
||||
border-r flex flex-col h-full
|
||||
${collapsed ? "w-16" : "w-64"}
|
||||
${collapsed ? "hidden" : ""}`}
|
||||
>
|
||||
<SidebarHeader collapsed={collapsed} />
|
||||
<MainNavigation collapsed={collapsed} />
|
||||
<SettingsPanel collapsed={collapsed} />
|
||||
<div className="items-center ml-2 p-2 h-30 border-t ">
|
||||
Version: latest
|
||||
</div>
|
||||
|
||||
<nav className="my-2 mx-1 py-1 px-1">
|
||||
<Link to="/dashboard" className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${location.pathname === '/dashboard' ? theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'}`} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||
<Globe className={`${mainIconSize} text-purple-400`} />
|
||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("uptimeMonitoring")}</span>}
|
||||
</Link>
|
||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||
<Boxes className={`${mainIconSize} text-blue-400`} />
|
||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("instanceMonitoring")}</span>}
|
||||
</div>
|
||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||
<Radar className={`${mainIconSize} text-cyan-400`} />
|
||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("sslDomain")}</span>}
|
||||
</div>
|
||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||
<Calendar className={`${mainIconSize} text-emerald-400`} />
|
||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("scheduleIncident")}</span>}
|
||||
</div>
|
||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||
<BarChart2 className={`${mainIconSize} text-amber-400`} />
|
||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("operationalPage")}</span>}
|
||||
</div>
|
||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||
<LineChart className={`${mainIconSize} text-rose-400`} />
|
||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("reports")}</span>}
|
||||
</div>
|
||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||
<FileText className={`${mainIconSize} text-indigo-400`} />
|
||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("apiDocumentation")}</span>}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{!collapsed && <div className={`flex-1 flex flex-col border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4`}>
|
||||
<Collapsible open={settingsPanelOpen} onOpenChange={setSettingsPanelOpen} className="w-full flex flex-col flex-1">
|
||||
<CollapsibleTrigger className={`flex items-center justify-between w-full mb-4 px-2 py-2 rounded-lg ${theme === 'dark' ? 'hover:bg-[#1a1a1a]' : 'hover:bg-sidebar-accent'}`}>
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium tracking-wide">{t("settingPanel")}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${settingsPanelOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className={`${theme === 'dark' ? 'bg-[#121212]' : 'bg-sidebar'} flex-1 flex flex-col`}>
|
||||
<div className="max-h-[300px] overflow-y-auto custom-scrollbar relative pr-1">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-2 pr-4">
|
||||
<Link to={`/settings?panel=general`} className={getMenuItemClasses(activeSettingsItem === 'general')} onClick={() => handleSettingsItemClick('general')}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
<span className="text-sm">{t("generalSettings")}</span>
|
||||
</Link>
|
||||
<Link to={`/settings?panel=users`} className={getMenuItemClasses(activeSettingsItem === 'users')} onClick={() => handleSettingsItemClick('users')}>
|
||||
<User className="h-4 w-4 mr-2" />
|
||||
<span className="text-sm">{t("userManagement")}</span>
|
||||
</Link>
|
||||
<Link to={`/settings?panel=notifications`} className={getMenuItemClasses(activeSettingsItem === 'notifications')} onClick={() => handleSettingsItemClick('notifications')}>
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
<span className="text-sm">{t("notificationSettings")}</span>
|
||||
</Link>
|
||||
<Link to={`/settings?panel=templates`} className={getMenuItemClasses(activeSettingsItem === 'templates')} onClick={() => handleSettingsItemClick('templates')}>
|
||||
<BookOpen className="h-4 w-4 mr-2" />
|
||||
<span className="text-sm">{t("alertsTemplates")}</span>
|
||||
</Link>
|
||||
<div className={getMenuItemClasses(false)}>
|
||||
<Database className="h-4 w-4 mr-2" />
|
||||
<span className="text-sm">{t("dataRetention")}</span>
|
||||
</div>
|
||||
<Link to={`/settings?panel=about`} className={getMenuItemClasses(activeSettingsItem === 'about')} onClick={() => handleSettingsItemClick('about')}>
|
||||
<Info className="h-4 w-4 mr-2" />
|
||||
<span className="text-sm">{t("aboutSystem")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>}
|
||||
|
||||
{collapsed && <div className={`border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4 flex justify-center`}>
|
||||
<Link to="/settings">
|
||||
<Settings className={`${mainIconSize} text-purple-400`} />
|
||||
</Link>
|
||||
</div>}
|
||||
</div>;
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,12 +3,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ArrowUp, ArrowDown, Pause, AlertTriangle } from "lucide-react";
|
||||
import { Service } from "@/services/serviceService";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import {useLanguage} from "@/contexts/LanguageContext.tsx";
|
||||
|
||||
interface StatusCardsProps {
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
export const StatusCards = ({ services }: StatusCardsProps) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
// Count services by status
|
||||
const upServices = services.filter(s => s.status === "up").length;
|
||||
const downServices = services.filter(s => s.status === "down").length;
|
||||
@@ -27,22 +30,22 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
|
||||
} relative z-10`}
|
||||
style={{
|
||||
background: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(67, 160, 71, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #43a047 0%, #66bb6a 100%)"
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #66bb6a 100%)"
|
||||
}}
|
||||
>
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 z-0 opacity-10">
|
||||
<div className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
|
||||
backgroundImage: `linear-gradient(#000 1px, transparent 1px),
|
||||
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<CardHeader className="pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-white">UP SERVICES</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-white">{t("upServices")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between relative z-10">
|
||||
<span className="text-5xl font-bold text-white">{upServices}</span>
|
||||
@@ -59,22 +62,22 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
|
||||
} relative z-10`}
|
||||
style={{
|
||||
background: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(229, 57, 53, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #e53935 0%, #ef5350 100%)"
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #ef5350 100%)"
|
||||
}}
|
||||
>
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 z-0 opacity-10">
|
||||
<div className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
|
||||
backgroundImage: `linear-gradient(#000 1px, transparent 1px),
|
||||
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<CardHeader className="pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-white">DOWN SERVICES</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-white">{t("downServices")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between relative z-10">
|
||||
<span className="text-5xl font-bold text-white">{downServices}</span>
|
||||
@@ -91,22 +94,22 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
|
||||
} relative z-10`}
|
||||
style={{
|
||||
background: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(25, 118, 210, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #1976d2 0%, #42a5f5 100%)"
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #42a5f5 100%)"
|
||||
}}
|
||||
>
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 z-0 opacity-10">
|
||||
<div className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
|
||||
backgroundImage: `linear-gradient(#000 1px, transparent 1px),
|
||||
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<CardHeader className="pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-white">PAUSED SERVICES</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-white">{t("pausedServices")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between relative z-10">
|
||||
<span className="text-5xl font-bold text-white">{pausedServices}</span>
|
||||
@@ -123,22 +126,22 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
|
||||
} relative z-10`}
|
||||
style={{
|
||||
background: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(255, 152, 0, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #ff9800 0%, #ffb74d 100%)"
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #ffb74d 100%)"
|
||||
}}
|
||||
>
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 z-0 opacity-10">
|
||||
<div className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
|
||||
backgroundImage: `linear-gradient(#000 1px, transparent 1px),
|
||||
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<CardHeader className="pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-white">WARNING SERVICES</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-white">{t("warningServices")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between relative z-10">
|
||||
<span className="text-5xl font-bold text-white">{warningServices}</span>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { MenuItem } from "./MenuItem";
|
||||
import { mainMenuItems } from "./navigationData";
|
||||
|
||||
interface MainNavigationProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export const MainNavigation: React.FC<MainNavigationProps> = ({
|
||||
collapsed,
|
||||
}) => {
|
||||
return (
|
||||
<nav
|
||||
className={`my-2 mx-1 py-1 px-1 dark:bg-black ${
|
||||
collapsed ? "hidden" : ""
|
||||
}`}
|
||||
>
|
||||
{mainMenuItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
path={item.path}
|
||||
icon={item.icon}
|
||||
translationKey={item.translationKey}
|
||||
color={item.color}
|
||||
hasNavigation={item.hasNavigation}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
interface MenuItemProps {
|
||||
id: string;
|
||||
path: string | null;
|
||||
icon: LucideIcon;
|
||||
translationKey: string;
|
||||
color: string;
|
||||
hasNavigation: boolean;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export const MenuItem: React.FC<MenuItemProps> = ({
|
||||
id,
|
||||
path,
|
||||
icon: Icon,
|
||||
translationKey,
|
||||
color,
|
||||
hasNavigation,
|
||||
collapsed,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const { t } = useLanguage();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (hasNavigation && path) {
|
||||
navigate(path, { replace: false });
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = path && location.pathname === path;
|
||||
const mainIconSize = "h-6 w-6";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
${collapsed ? "p-3" : "p-2 pl-3"}
|
||||
mb-1 rounded-lg
|
||||
${
|
||||
isActive
|
||||
? theme === "dark"
|
||||
? "bg-drak"
|
||||
: "bg-sidebar-accent "
|
||||
: `hover:${theme === "dark" ? "bg-gray-800" : "bg-sidebar-accent "}`
|
||||
}
|
||||
flex items-center
|
||||
${collapsed ? "justify-center" : ""}
|
||||
cursor-pointer
|
||||
`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Icon className={`${mainIconSize} ${color}`} />
|
||||
{!collapsed && (
|
||||
<span className="ml-2.5 font-medium text-foreground tracking-wide ">
|
||||
{t(translationKey)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { Settings, ChevronDown } from "lucide-react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { settingsMenuItems } from "./navigationData";
|
||||
|
||||
interface SettingsPanelProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
|
||||
const { theme } = useTheme();
|
||||
const { t } = useLanguage();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>(
|
||||
"general"
|
||||
);
|
||||
//const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
|
||||
const [settingsPanelOpen, setSettingsPanelOpen] = useState(() => {
|
||||
const savedValue = localStorage.getItem("settingsPanelOpen");
|
||||
// Check if the saved value exists and is "true"
|
||||
return savedValue === "true";
|
||||
});
|
||||
|
||||
// Update active settings item based on URL
|
||||
useEffect(() => {
|
||||
if (location.pathname === "/settings") {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const panel = params.get("panel");
|
||||
if (panel) {
|
||||
setActiveSettingsItem(panel);
|
||||
}
|
||||
}
|
||||
localStorage.setItem("settingsPanelOpen", settingsPanelOpen.toString());
|
||||
}, [location, settingsPanelOpen]);
|
||||
|
||||
const handleSettingsItemClick = (item: string) => {
|
||||
setActiveSettingsItem(item);
|
||||
};
|
||||
|
||||
const handleMenuItemClick = (path: string, event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// Use navigate instead of window.location to prevent full page reload
|
||||
navigate(path, { replace: false });
|
||||
};
|
||||
|
||||
const getMenuItemClasses = (isActive: boolean) => {
|
||||
return `p-2 ${
|
||||
isActive
|
||||
? theme === "dark"
|
||||
? "bg-[#1a1a1a]"
|
||||
: "bg-sidebar-accent"
|
||||
: `hover:${theme === "dark" ? "bg-[#1a1a1a]" : "bg-sidebar-accent"}`
|
||||
} rounded-lg flex items-center `;
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
const mainIconSize = "h-6 w-6";
|
||||
return (
|
||||
<div
|
||||
className={`border-t ${
|
||||
theme === "dark"
|
||||
? "border-[#1e1e1e] bg-[#121212]"
|
||||
: "border-sidebar-border bg-sidebar"
|
||||
} p-4 flex justify-center ${collapsed ? "hidden" : ""}`}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => handleMenuItemClick("/settings", e)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Settings className={`${mainIconSize} text-purple-400`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 flex flex-col border-t ${
|
||||
theme === "dark"
|
||||
? "border-[#1e1e1e] bg-black"
|
||||
: "border-sidebar-border bg-sidebars"
|
||||
} p-4 ${collapsed ? "hidden" : ""}`}
|
||||
>
|
||||
<Collapsible
|
||||
open={settingsPanelOpen}
|
||||
onOpenChange={setSettingsPanelOpen}
|
||||
className="w-full flex flex-col flex-1"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
className={`flex items-center justify-between w-full mb-4 px-2 py-2 rounded-lg ${
|
||||
theme === "dark" ? "hover:bg-[#1a1a1a]" : "hover:bg-sidebar-accent"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium tracking-wide">
|
||||
{t("settingPanel")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform duration-200 ${
|
||||
settingsPanelOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent
|
||||
className={`${
|
||||
theme === "dark" ? "bg-black" : "bg-sidebars"
|
||||
} flex-1 flex flex-col`}
|
||||
>
|
||||
<div className="max-h-[300px] overflow-y-auto custom-scrollbar relative pr-1">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-2 pr-4 cursor-pointer">
|
||||
{settingsMenuItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={getMenuItemClasses(
|
||||
activeSettingsItem === item.id
|
||||
)}
|
||||
onClick={(e) => {
|
||||
handleMenuItemClick(`/settings?panel=${item.id}`, e);
|
||||
handleSettingsItemClick(item.id);
|
||||
}}
|
||||
>
|
||||
<item.icon className="h-4 w-4 mr-2" />
|
||||
<span className="text-base">{t(item.translationKey)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export const SidebarHeader: React.FC<SidebarHeaderProps> = ({ collapsed }) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-[15.7px] ${
|
||||
theme === "dark"
|
||||
? "border-[#1e1e1e] bg-black"
|
||||
: "border-sidebar-border bg-white"
|
||||
} border-b flex items-center ${collapsed ? "justify-center" : ""}`}
|
||||
>
|
||||
<div className="h-8 w-8 rounded flex items-center justify-center mr-2 dark:bg-drak">
|
||||
<img src="/favicon_sidebar.ico" alt="CheckCle" className="h-7 w-6" />
|
||||
</div>
|
||||
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
export { SidebarHeader } from './SidebarHeader';
|
||||
export { MainNavigation } from './MainNavigation';
|
||||
export { MenuItem } from './MenuItem';
|
||||
export { SettingsPanel } from './SettingsPanel';
|
||||
export { mainMenuItems, settingsMenuItems } from './navigationData';
|
||||
@@ -0,0 +1,94 @@
|
||||
|
||||
import { Globe, Boxes, Layers, Calendar, BarChart2, LineChart, MapPin, Settings, User, Bell, Database, Info, BookOpen } from "lucide-react";
|
||||
|
||||
export const mainMenuItems = [
|
||||
{
|
||||
id: 'uptime-monitoring',
|
||||
path: '/dashboard',
|
||||
icon: Globe,
|
||||
translationKey: 'uptimeMonitoring',
|
||||
color: 'text-purple-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'instance-monitoring',
|
||||
path: '/instance-monitoring',
|
||||
icon: Boxes,
|
||||
translationKey: 'instanceMonitoring',
|
||||
color: 'text-blue-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'ssl-domain',
|
||||
path: '/ssl-domain',
|
||||
icon: Layers,
|
||||
translationKey: 'sslDomain',
|
||||
color: 'text-cyan-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'schedule-incident',
|
||||
path: '/schedule-incident',
|
||||
icon: Calendar,
|
||||
translationKey: 'scheduleIncident',
|
||||
color: 'text-emerald-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'operational-page',
|
||||
path: '/operational-page',
|
||||
icon: BarChart2,
|
||||
translationKey: 'operationalPage',
|
||||
color: 'text-amber-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'regional-monitoring',
|
||||
path: '/regional-monitoring',
|
||||
icon: MapPin,
|
||||
translationKey: 'regionalMonitoring',
|
||||
color: 'text-indigo-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'reports',
|
||||
path: null,
|
||||
icon: LineChart,
|
||||
translationKey: 'reports',
|
||||
color: 'text-rose-400',
|
||||
hasNavigation: false
|
||||
}
|
||||
];
|
||||
|
||||
export const settingsMenuItems = [
|
||||
{
|
||||
id: 'general',
|
||||
icon: Settings,
|
||||
translationKey: 'generalSettings'
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
icon: User,
|
||||
translationKey: 'userManagement'
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
icon: Bell,
|
||||
translationKey: 'notificationSettings'
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
icon: BookOpen,
|
||||
translationKey: 'alertsTemplates'
|
||||
},
|
||||
{
|
||||
id: 'data-retention',
|
||||
icon: Database,
|
||||
translationKey: 'dataRetention'
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
icon: Info,
|
||||
translationKey: 'aboutSystem'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,97 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody } from "@/components/ui/table";
|
||||
import { DockerContainer } from "@/types/docker.types";
|
||||
import { DockerMetricsDialog } from "./DockerMetricsDialog";
|
||||
import {
|
||||
DockerTableSearch,
|
||||
DockerTableHeader,
|
||||
DockerTableRow,
|
||||
DockerEmptyState
|
||||
} from "./table";
|
||||
|
||||
interface DockerContainersTableProps {
|
||||
containers: DockerContainer[];
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export const DockerContainersTable = ({ containers, isLoading, onRefresh }: DockerContainersTableProps) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedContainer, setSelectedContainer] = useState<DockerContainer | null>(null);
|
||||
const [metricsDialogOpen, setMetricsDialogOpen] = useState(false);
|
||||
|
||||
const filteredContainers = containers.filter(container =>
|
||||
container.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
container.docker_id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
container.hostname.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleContainerAction = (action: string, containerId: string, containerName: string) => {
|
||||
console.log(`${action} action for container ${containerName} (${containerId})`);
|
||||
// TODO: Implement container actions
|
||||
};
|
||||
|
||||
const handleRowClick = (container: DockerContainer) => {
|
||||
setSelectedContainer(container);
|
||||
setMetricsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleViewMetrics = (container: DockerContainer) => {
|
||||
setSelectedContainer(container);
|
||||
setMetricsDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="w-full bg-transparent border-0 shadow-none">
|
||||
<CardHeader className="pb-4 px-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<CardTitle className="text-lg sm:text-xl font-semibold">Docker Containers</CardTitle>
|
||||
<DockerTableSearch
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
onRefresh={onRefresh}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-full inline-block align-middle">
|
||||
<div className="overflow-hidden border border-border rounded-lg shadow-sm">
|
||||
<Table>
|
||||
<DockerTableHeader />
|
||||
<TableBody>
|
||||
{filteredContainers.length === 0 ? (
|
||||
<DockerEmptyState searchTerm={searchTerm} />
|
||||
) : (
|
||||
filteredContainers.map((container) => (
|
||||
<DockerTableRow
|
||||
key={container.id}
|
||||
container={container}
|
||||
onRowClick={handleRowClick}
|
||||
onContainerAction={handleContainerAction}
|
||||
onViewMetrics={handleViewMetrics}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DockerMetricsDialog
|
||||
container={selectedContainer}
|
||||
open={metricsDialogOpen}
|
||||
onOpenChange={setMetricsDialogOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,695 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer, AreaChart, Area } from "recharts";
|
||||
import { DockerContainer, DockerMetrics } from "@/types/docker.types";
|
||||
import { dockerService } from "@/services/dockerService";
|
||||
import { Loader2, Cpu, HardDrive, Network, MemoryStick } from "lucide-react";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
interface DockerMetricsDialogProps {
|
||||
container: DockerContainer | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
|
||||
|
||||
const timeRangeOptions = [
|
||||
{ value: '60m' as TimeRange, label: '60 minutes', hours: 1 },
|
||||
{ value: '1d' as TimeRange, label: '1 day', hours: 24 },
|
||||
{ value: '7d' as TimeRange, label: '7 days', hours: 24 * 7 },
|
||||
{ value: '1m' as TimeRange, label: '1 month', hours: 24 * 30 },
|
||||
{ value: '3m' as TimeRange, label: '3 months', hours: 24 * 90 },
|
||||
];
|
||||
|
||||
export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMetricsDialogProps) => {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
|
||||
const { theme } = useTheme();
|
||||
|
||||
const {
|
||||
data: metrics = [],
|
||||
isLoading,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ['docker-metrics', container?.docker_id, timeRange],
|
||||
queryFn: () => container ? dockerService.getContainerMetrics(container.docker_id) : Promise.resolve([]),
|
||||
enabled: !!container && open,
|
||||
refetchInterval: 30000
|
||||
});
|
||||
|
||||
const formatBytes = (bytes: number, decimals = 2) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const parseValueWithUnit = (value: string | number): { numeric: number; unit: string; original: string } => {
|
||||
if (typeof value === 'number') {
|
||||
return { numeric: value, unit: 'B', original: value.toString() };
|
||||
}
|
||||
|
||||
const str = value.toString();
|
||||
const match = str.match(/^([\d.]+)\s*([A-Za-z%]*)/);
|
||||
if (match) {
|
||||
const numeric = parseFloat(match[1]);
|
||||
const unit = match[2] || '';
|
||||
return { numeric, unit, original: str };
|
||||
}
|
||||
return { numeric: 0, unit: '', original: str };
|
||||
};
|
||||
|
||||
const convertToBytes = (value: string | number): number => {
|
||||
if (typeof value === 'number') return value;
|
||||
|
||||
const parsed = parseValueWithUnit(value);
|
||||
const multipliers: { [key: string]: number } = {
|
||||
'B': 1,
|
||||
'KB': 1024,
|
||||
'MB': 1024 * 1024,
|
||||
'GB': 1024 * 1024 * 1024,
|
||||
'TB': 1024 * 1024 * 1024 * 1024
|
||||
};
|
||||
|
||||
const multiplier = multipliers[parsed.unit.toUpperCase()] || 1;
|
||||
return parsed.numeric * multiplier;
|
||||
};
|
||||
|
||||
const filterMetricsByTimeRange = (metrics: DockerMetrics[], timeRange: TimeRange): DockerMetrics[] => {
|
||||
const now = new Date();
|
||||
const selectedRange = timeRangeOptions.find(opt => opt.value === timeRange);
|
||||
if (!selectedRange) return metrics;
|
||||
|
||||
const cutoffTime = new Date(now.getTime() - (selectedRange.hours * 60 * 60 * 1000));
|
||||
|
||||
return metrics.filter(metric => {
|
||||
const metricTime = new Date(metric.timestamp);
|
||||
return metricTime >= cutoffTime;
|
||||
});
|
||||
};
|
||||
|
||||
const formatChartData = (metrics: DockerMetrics[]) => {
|
||||
const filteredMetrics = filterMetricsByTimeRange(metrics, timeRange);
|
||||
|
||||
return filteredMetrics.slice(0, 100).reverse().map((metric, index) => {
|
||||
// Parse CPU usage
|
||||
const cpuUsage = typeof metric.cpu_usage === 'string' ?
|
||||
parseFloat(metric.cpu_usage.replace('%', '')) :
|
||||
parseFloat(metric.cpu_usage) || 0;
|
||||
|
||||
// Parse memory values
|
||||
const ramUsedBytes = convertToBytes(metric.ram_used);
|
||||
const ramTotalBytes = convertToBytes(metric.ram_total);
|
||||
const ramFreeBytes = convertToBytes(metric.ram_free);
|
||||
const ramUsagePercent = ramTotalBytes > 0 ? (ramUsedBytes / ramTotalBytes) * 100 : 0;
|
||||
|
||||
// Parse disk values
|
||||
const diskUsedBytes = convertToBytes(metric.disk_used);
|
||||
const diskTotalBytes = convertToBytes(metric.disk_total);
|
||||
const diskFreeBytes = convertToBytes(metric.disk_free);
|
||||
const diskUsagePercent = diskTotalBytes > 0 ? (diskUsedBytes / diskTotalBytes) * 100 : 0;
|
||||
|
||||
// Network values
|
||||
const networkRxBytes = metric.network_rx_bytes || 0;
|
||||
const networkTxBytes = metric.network_tx_bytes || 0;
|
||||
const networkRxSpeed = metric.network_rx_speed || 0;
|
||||
const networkTxSpeed = metric.network_tx_speed || 0;
|
||||
|
||||
return {
|
||||
timestamp: new Date(metric.timestamp).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}),
|
||||
// CPU
|
||||
cpuUsage: Math.round(cpuUsage * 100) / 100,
|
||||
cpuCores: parseInt(metric.cpu_cores) || 0,
|
||||
cpuFree: 100 - cpuUsage,
|
||||
|
||||
// Memory
|
||||
ramUsedBytes,
|
||||
ramTotalBytes,
|
||||
ramFreeBytes,
|
||||
ramUsed: formatBytes(ramUsedBytes),
|
||||
ramTotal: formatBytes(ramTotalBytes),
|
||||
ramFree: formatBytes(ramFreeBytes),
|
||||
ramUsagePercent: Math.round(ramUsagePercent * 100) / 100,
|
||||
|
||||
// Disk
|
||||
diskUsedBytes,
|
||||
diskTotalBytes,
|
||||
diskFreeBytes,
|
||||
diskUsed: formatBytes(diskUsedBytes),
|
||||
diskTotal: formatBytes(diskTotalBytes),
|
||||
diskFree: formatBytes(diskFreeBytes),
|
||||
diskUsagePercent: Math.round(diskUsagePercent * 100) / 100,
|
||||
|
||||
// Network
|
||||
networkRxBytes,
|
||||
networkTxBytes,
|
||||
networkRx: formatBytes(networkRxBytes),
|
||||
networkTx: formatBytes(networkTxBytes),
|
||||
networkRxSpeed: Math.round(networkRxSpeed * 100) / 100,
|
||||
networkTxSpeed: Math.round(networkTxSpeed * 100) / 100,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const chartData = formatChartData(metrics);
|
||||
const latestMetric = chartData[chartData.length - 1];
|
||||
|
||||
const chartConfig = {
|
||||
cpuUsage: {
|
||||
label: "CPU Usage (%)",
|
||||
color: theme === 'dark' ? "#3b82f6" : "#2563eb",
|
||||
},
|
||||
ramUsagePercent: {
|
||||
label: "RAM Usage (%)",
|
||||
color: theme === 'dark' ? "#10b981" : "#059669",
|
||||
},
|
||||
diskUsagePercent: {
|
||||
label: "Disk Usage (%)",
|
||||
color: theme === 'dark' ? "#f59e0b" : "#d97706",
|
||||
},
|
||||
networkRx: {
|
||||
label: "Network RX",
|
||||
color: theme === 'dark' ? "#8b5cf6" : "#7c3aed",
|
||||
},
|
||||
networkTx: {
|
||||
label: "Network TX",
|
||||
color: theme === 'dark' ? "#ef4444" : "#dc2626",
|
||||
},
|
||||
};
|
||||
|
||||
const getGridColor = () => theme === 'dark' ? '#374151' : '#e5e7eb';
|
||||
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||
|
||||
const MetricCard = ({ title, used, total, free, percentage, icon: Icon, color }: {
|
||||
title: string;
|
||||
used: string;
|
||||
total: string;
|
||||
free: string;
|
||||
percentage: number;
|
||||
icon: any;
|
||||
color: string;
|
||||
}) => (
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" style={{ color }} />
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Used:</span>
|
||||
<span className="font-mono">{used}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Free:</span>
|
||||
<span className="font-mono">{free}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total:</span>
|
||||
<span className="font-mono">{total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Usage:</span>
|
||||
<span className="font-mono">{percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!container) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-7xl max-h-[95vh] overflow-y-auto bg-background text-foreground">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Cpu className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
Container Metrics: {container.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={timeRange} onValueChange={(value: TimeRange) => setTimeRange(value)}>
|
||||
<SelectTrigger className="w-[140px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{timeRangeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Docker ID: {container.docker_id} • {container.hostname}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="ml-2">Loading metrics...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-96 text-muted-foreground">
|
||||
<p>Error loading metrics: {error.message}</p>
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-96 text-muted-foreground">
|
||||
<p>No metrics data available for this container</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Current Metrics Summary */}
|
||||
{latestMetric && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<MetricCard
|
||||
title="CPU"
|
||||
used={`${latestMetric.cpuUsage}%`}
|
||||
total={`${latestMetric.cpuCores} cores`}
|
||||
free={`${(100 - latestMetric.cpuUsage).toFixed(1)}%`}
|
||||
percentage={latestMetric.cpuUsage}
|
||||
icon={Cpu}
|
||||
color={chartConfig.cpuUsage.color}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Memory"
|
||||
used={latestMetric.ramUsed}
|
||||
total={latestMetric.ramTotal}
|
||||
free={latestMetric.ramFree}
|
||||
percentage={latestMetric.ramUsagePercent}
|
||||
icon={MemoryStick}
|
||||
color={chartConfig.ramUsagePercent.color}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Disk"
|
||||
used={latestMetric.diskUsed}
|
||||
total={latestMetric.diskTotal}
|
||||
free={latestMetric.diskFree}
|
||||
percentage={latestMetric.diskUsagePercent}
|
||||
icon={HardDrive}
|
||||
color={chartConfig.diskUsagePercent.color}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Network"
|
||||
used={`RX: ${latestMetric.networkRx}`}
|
||||
total={`TX: ${latestMetric.networkTx}`}
|
||||
free={`Speed: ${latestMetric.networkRxSpeed} KB/s`}
|
||||
percentage={0}
|
||||
icon={Network}
|
||||
color={chartConfig.networkRx.color}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="cpu" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4 bg-muted">
|
||||
<TabsTrigger value="cpu" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||
<Cpu className="h-4 w-4" />
|
||||
CPU
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="memory" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||
<MemoryStick className="h-4 w-4" />
|
||||
Memory
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="disk" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
Disk
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="network" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||
<Network className="h-4 w-4" />
|
||||
Network
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="cpu" className="space-y-4 mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">CPU Usage (%)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cpuUsage"
|
||||
stroke={chartConfig.cpuUsage.color}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: chartConfig.cpuUsage.color }}
|
||||
name="CPU Usage (%)"
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">CPU Usage vs Available</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpuFree"
|
||||
stackId="1"
|
||||
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fillOpacity={0.3}
|
||||
name="CPU Available (%)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpuUsage"
|
||||
stackId="1"
|
||||
stroke={chartConfig.cpuUsage.color}
|
||||
fill={chartConfig.cpuUsage.color}
|
||||
fillOpacity={0.6}
|
||||
name="CPU Usage (%)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="memory" className="space-y-4 mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">Memory Usage (%)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="ramUsagePercent"
|
||||
stackId="1"
|
||||
stroke={chartConfig.ramUsagePercent.color}
|
||||
fill={chartConfig.ramUsagePercent.color}
|
||||
fillOpacity={0.6}
|
||||
name="RAM Usage (%)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">Memory Usage (Bytes)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
tickFormatter={(value) => formatBytes(value)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
formatter={(value, name) => [
|
||||
name === 'Used Memory' ? formatBytes(Number(value)) :
|
||||
name === 'Total Memory' ? formatBytes(Number(value)) : value,
|
||||
name
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="ramTotalBytes"
|
||||
stackId="1"
|
||||
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fillOpacity={0.3}
|
||||
name="Total Memory"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="ramUsedBytes"
|
||||
stackId="1"
|
||||
stroke={chartConfig.ramUsagePercent.color}
|
||||
fill={chartConfig.ramUsagePercent.color}
|
||||
fillOpacity={0.6}
|
||||
name="Used Memory"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="disk" className="space-y-4 mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">Disk Usage (%)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="diskUsagePercent"
|
||||
stackId="1"
|
||||
stroke={chartConfig.diskUsagePercent.color}
|
||||
fill={chartConfig.diskUsagePercent.color}
|
||||
fillOpacity={0.6}
|
||||
name="Disk Usage (%)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">Disk Usage (Bytes)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
tickFormatter={(value) => formatBytes(value)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
formatter={(value, name) => [
|
||||
name === 'Used Disk' ? formatBytes(Number(value)) :
|
||||
name === 'Total Disk' ? formatBytes(Number(value)) : value,
|
||||
name
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="diskTotalBytes"
|
||||
stackId="1"
|
||||
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fillOpacity={0.3}
|
||||
name="Total Disk"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="diskUsedBytes"
|
||||
stackId="1"
|
||||
stroke={chartConfig.diskUsagePercent.color}
|
||||
fill={chartConfig.diskUsagePercent.color}
|
||||
fillOpacity={0.6}
|
||||
name="Used Disk"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="network" className="space-y-4 mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">Network Traffic</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-64">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkRxBytes"
|
||||
stroke={chartConfig.networkRx.color}
|
||||
strokeWidth={2}
|
||||
name="RX Bytes"
|
||||
dot={{ r: 2 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkTxBytes"
|
||||
stroke={chartConfig.networkTx.color}
|
||||
strokeWidth={2}
|
||||
name="TX Bytes"
|
||||
dot={{ r: 2 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">Network Speed (KB/s)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-64">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkRxSpeed"
|
||||
stroke={chartConfig.networkRx.color}
|
||||
strokeWidth={2}
|
||||
name="RX Speed (KB/s)"
|
||||
dot={{ r: 2 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkTxSpeed"
|
||||
stroke={chartConfig.networkTx.color}
|
||||
strokeWidth={2}
|
||||
name="TX Speed (KB/s)"
|
||||
dot={{ r: 2 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Container, Play, Square, AlertTriangle } from "lucide-react";
|
||||
import { DockerStats } from "@/types/docker.types";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
interface DockerStatsCardsProps {
|
||||
stats: DockerStats;
|
||||
}
|
||||
|
||||
export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Total Containers",
|
||||
value: stats.total,
|
||||
icon: Container,
|
||||
color: "text-blue-600",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(59, 130, 246, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #3b82f6 100%)"
|
||||
},
|
||||
{
|
||||
title: "Running",
|
||||
value: stats.running,
|
||||
icon: Play,
|
||||
color: "text-green-600",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(16, 185, 129, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #10b981 100%)"
|
||||
},
|
||||
{
|
||||
title: "Stopped",
|
||||
value: stats.stopped,
|
||||
icon: Square,
|
||||
color: "text-gray-600",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(107, 114, 128, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #6b7280 100%)"
|
||||
},
|
||||
{
|
||||
title: "Warning",
|
||||
value: stats.warning,
|
||||
icon: AlertTriangle,
|
||||
color: "text-amber-600",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(245, 158, 11, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #f59e0b 100%)"
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
|
||||
{cards.map((card) => {
|
||||
const IconComponent = card.icon;
|
||||
return (
|
||||
<Card
|
||||
key={card.title}
|
||||
className="border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative"
|
||||
style={{ background: card.gradient }}
|
||||
>
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 z-0 opacity-10">
|
||||
<div
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-white/70">
|
||||
{card.title}
|
||||
</CardTitle>
|
||||
<div className="p-2.5 rounded-xl bg-white/20 backdrop-blur-sm shadow-sm transition-all duration-300 group-hover:scale-110">
|
||||
<IconComponent className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{card.value}
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-mono font-bold px-2 py-1 rounded-md bg-white/20 backdrop-blur-sm text-white border border-white/30"
|
||||
>
|
||||
Containers
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface DockerStatusBadgeProps {
|
||||
status: 'running' | 'stopped' | 'warning';
|
||||
}
|
||||
|
||||
export const DockerStatusBadge = ({ status }: DockerStatusBadgeProps) => {
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
variant: 'default' as const,
|
||||
className: 'bg-emerald-100 text-emerald-800 border-emerald-200 hover:bg-emerald-200',
|
||||
label: 'Running'
|
||||
};
|
||||
case 'stopped':
|
||||
return {
|
||||
variant: 'secondary' as const,
|
||||
className: 'bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-200',
|
||||
label: 'Stopped'
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
variant: 'destructive' as const,
|
||||
className: 'bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-200',
|
||||
label: 'Warning'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
variant: 'outline' as const,
|
||||
className: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
label: 'Unknown'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig(status);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={config.variant}
|
||||
className={`${config.className} font-medium text-xs px-2 py-1`}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
|
||||
interface DockerEmptyStateProps {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
export const DockerEmptyState = ({ searchTerm }: DockerEmptyStateProps) => {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-12 text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="text-lg font-medium">
|
||||
{searchTerm ? "No containers found" : "No containers running"}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{searchTerm ? "Try adjusting your search terms." : "Start some containers to see them here."}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||
import { MoreHorizontal, Eye, Play, Pause, Square, Trash2, BarChart3, RefreshCw } from "lucide-react";
|
||||
import { DockerContainer } from "@/types/docker.types";
|
||||
|
||||
interface DockerRowActionsProps {
|
||||
container: DockerContainer;
|
||||
containerStatus: 'running' | 'stopped' | 'warning';
|
||||
onContainerAction: (action: string, containerId: string, containerName: string) => void;
|
||||
onViewMetrics: (container: DockerContainer) => void;
|
||||
}
|
||||
|
||||
export const DockerRowActions = ({ container, containerStatus, onContainerAction, onViewMetrics }: DockerRowActionsProps) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0 hover:bg-muted">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 bg-popover border-border shadow-md">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onViewMetrics(container)}
|
||||
className="cursor-pointer hover:bg-muted"
|
||||
>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
View Metrics
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onContainerAction('view-detail', container.id, container.name)}
|
||||
className="cursor-pointer hover:bg-muted"
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
import { TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
export const DockerTableHeader = () => {
|
||||
return (
|
||||
<TableHeader>
|
||||
<TableRow className="border-border bg-muted/30">
|
||||
<TableHead className="min-w-[200px] font-semibold">Container</TableHead>
|
||||
<TableHead className="min-w-[100px] font-semibold">Status</TableHead>
|
||||
<TableHead className="min-w-[140px] font-semibold">CPU Usage</TableHead>
|
||||
<TableHead className="min-w-[160px] font-semibold">Memory</TableHead>
|
||||
<TableHead className="min-w-[160px] font-semibold">Disk</TableHead>
|
||||
<TableHead className="min-w-[100px] font-semibold">Uptime</TableHead>
|
||||
<TableHead className="min-w-[160px] font-semibold">Last Checked</TableHead>
|
||||
<TableHead className="min-w-[80px] text-center font-semibold">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { DockerContainer } from "@/types/docker.types";
|
||||
import { DockerStatusBadge } from "../DockerStatusBadge";
|
||||
import { DockerRowActions } from "./DockerRowActions";
|
||||
import { dockerService } from "@/services/dockerService";
|
||||
|
||||
interface DockerTableRowProps {
|
||||
container: DockerContainer;
|
||||
onRowClick: (container: DockerContainer) => void;
|
||||
onContainerAction: (action: string, containerId: string, containerName: string) => void;
|
||||
onViewMetrics: (container: DockerContainer) => void;
|
||||
}
|
||||
|
||||
export const DockerTableRow = ({ container, onRowClick, onContainerAction, onViewMetrics }: DockerTableRowProps) => {
|
||||
const cpuPercentage = container.cpu_usage;
|
||||
const memoryPercentage = Math.round((container.ram_used / container.ram_total) * 100);
|
||||
const diskPercentage = Math.round((container.disk_used / container.disk_total) * 100);
|
||||
const containerStatus = dockerService.getStatusFromDockerStatus(container.status);
|
||||
|
||||
const formatPercentage = (used: number, total: number) => {
|
||||
if (total === 0) return "0%";
|
||||
return `${Math.round((used / total) * 100)}%`;
|
||||
};
|
||||
|
||||
const getUsageColor = (percentage: number) => {
|
||||
if (percentage >= 90) return "text-red-500";
|
||||
if (percentage >= 70) return "text-amber-500";
|
||||
return "text-emerald-500";
|
||||
};
|
||||
|
||||
const getProgressColor = (percentage: number) => {
|
||||
if (percentage >= 90) return "bg-red-500";
|
||||
if (percentage >= 70) return "bg-amber-500";
|
||||
return "bg-emerald-500";
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
className="hover:bg-muted/50 transition-colors border-border cursor-pointer"
|
||||
onClick={() => onRowClick(container)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold text-sm sm:text-base text-foreground">{container.name}</div>
|
||||
<div className="text-xs sm:text-sm text-muted-foreground">
|
||||
<div className="font-mono">{container.docker_id}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DockerStatusBadge status={containerStatus} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Progress
|
||||
value={cpuPercentage}
|
||||
className="flex-1 h-2 bg-muted/50"
|
||||
indicatorClassName={getProgressColor(cpuPercentage)}
|
||||
/>
|
||||
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(cpuPercentage)}`}>
|
||||
{cpuPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Progress
|
||||
value={memoryPercentage}
|
||||
className="flex-1 h-2 bg-muted/50"
|
||||
indicatorClassName={getProgressColor(memoryPercentage)}
|
||||
/>
|
||||
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(memoryPercentage)}`}>
|
||||
{formatPercentage(container.ram_used, container.ram_total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{dockerService.formatBytes(container.ram_used)} / {dockerService.formatBytes(container.ram_total)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Progress
|
||||
value={diskPercentage}
|
||||
className="flex-1 h-2 bg-muted/50"
|
||||
indicatorClassName={getProgressColor(diskPercentage)}
|
||||
/>
|
||||
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(diskPercentage)}`}>
|
||||
{formatPercentage(container.disk_used, container.disk_total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{dockerService.formatBytes(container.disk_used)} / {dockerService.formatBytes(container.disk_total)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs sm:text-sm font-medium font-mono">
|
||||
{dockerService.formatUptime(container.uptime)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground">
|
||||
{new Date(container.last_checked).toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<DockerRowActions
|
||||
container={container}
|
||||
containerStatus={containerStatus}
|
||||
onContainerAction={onContainerAction}
|
||||
onViewMetrics={onViewMetrics}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, RefreshCw } from "lucide-react";
|
||||
|
||||
interface DockerTableSearchProps {
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onRefresh: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const DockerTableSearch = ({ searchTerm, onSearchChange, onRefresh, isLoading }: DockerTableSearchProps) => {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
|
||||
<div className="relative flex-1 sm:flex-initial">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Search containers..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10 sm:w-64 bg-background border-border"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto bg-background border-border hover:bg-muted"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
<span className="sm:inline">Refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
export { DockerTableSearch } from './DockerTableSearch';
|
||||
export { DockerTableHeader } from './DockerTableHeader';
|
||||
export { DockerTableRow } from './DockerTableRow';
|
||||
export { DockerRowActions } from './DockerRowActions';
|
||||
export { DockerEmptyState } from './DockerEmptyState';
|
||||
@@ -0,0 +1,207 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, X, Server } from 'lucide-react';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { serviceService } from '@/services/serviceService';
|
||||
|
||||
interface ComponentsSelectorProps {
|
||||
selectedComponents: Partial<StatusPageComponentRecord>[];
|
||||
onComponentsChange: (components: Partial<StatusPageComponentRecord>[]) => void;
|
||||
onComponentDelete?: (componentId: string) => void;
|
||||
}
|
||||
|
||||
export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newComponent, setNewComponent] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
service_id: '',
|
||||
server_id: '',
|
||||
display_order: selectedComponents.length + 1,
|
||||
});
|
||||
|
||||
// Fetch uptime services for the dropdown
|
||||
const { data: services = [] } = useQuery({
|
||||
queryKey: ['services'],
|
||||
queryFn: serviceService.getServices,
|
||||
});
|
||||
|
||||
const addComponent = () => {
|
||||
if (!newComponent.name.trim()) return;
|
||||
|
||||
const component: Partial<StatusPageComponentRecord> = {
|
||||
...newComponent,
|
||||
operational_status_id: '', // Will be set when page is created
|
||||
};
|
||||
|
||||
onComponentsChange([...selectedComponents, component]);
|
||||
setNewComponent({
|
||||
name: '',
|
||||
description: '',
|
||||
service_id: '',
|
||||
server_id: '',
|
||||
display_order: selectedComponents.length + 2,
|
||||
});
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
const removeComponent = async (index: number) => {
|
||||
const component = selectedComponents[index];
|
||||
|
||||
// If component has an ID, it exists in database and needs to be deleted
|
||||
if (component.id && onComponentDelete) {
|
||||
await onComponentDelete(component.id);
|
||||
} else {
|
||||
// For new components not yet saved, just remove from local state
|
||||
const updated = selectedComponents.filter((_, i) => i !== index);
|
||||
onComponentsChange(updated);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5" />
|
||||
Status Page Components
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add monitoring components like uptime services, SSL certificates, and incident tracking
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedComponents.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Selected Components</Label>
|
||||
<div className="space-y-2">
|
||||
{selectedComponents.map((component, index) => (
|
||||
<div key={component.id || index} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{component.name}</div>
|
||||
{component.description && (
|
||||
<div className="text-sm text-muted-foreground">{component.description}</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-1">
|
||||
{component.service_id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Service: {services.find(s => s.id === component.service_id)?.name || component.service_id}
|
||||
</Badge>
|
||||
)}
|
||||
{component.server_id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Server: {component.server_id}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeComponent(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showAddForm ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Component
|
||||
</Button>
|
||||
) : (
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="component-name">Component Name</Label>
|
||||
<Input
|
||||
id="component-name"
|
||||
placeholder="e.g., Main Website"
|
||||
value={newComponent.name}
|
||||
onChange={(e) => setNewComponent({ ...newComponent, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="display-order">Display Order</Label>
|
||||
<Input
|
||||
id="display-order"
|
||||
type="number"
|
||||
value={newComponent.display_order}
|
||||
onChange={(e) => setNewComponent({ ...newComponent, display_order: parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="component-description">Description (Optional)</Label>
|
||||
<Textarea
|
||||
id="component-description"
|
||||
placeholder="Brief description of this component"
|
||||
value={newComponent.description}
|
||||
onChange={(e) => setNewComponent({ ...newComponent, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="service-id">Uptime Service (Optional)</Label>
|
||||
<Select onValueChange={(value) => setNewComponent({ ...newComponent, service_id: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an uptime service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{services.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
service.status === 'up' ? 'bg-green-500' :
|
||||
service.status === 'down' ? 'bg-red-500' :
|
||||
'bg-yellow-500'
|
||||
}`} />
|
||||
{service.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="server-id">Server ID (Optional)</Label>
|
||||
<Input
|
||||
id="server-id"
|
||||
placeholder="server_456"
|
||||
value={newComponent.server_id}
|
||||
onChange={(e) => setNewComponent({ ...newComponent, server_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={addComponent} disabled={!newComponent.name.trim()}>
|
||||
Add Component
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowAddForm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useCreateOperationalPage } from '@/hooks/useOperationalPage';
|
||||
import { useCreateStatusPageComponent } from '@/hooks/useStatusPageComponents';
|
||||
import { ComponentsSelector } from './ComponentsSelector';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
slug: z.string().min(1, 'Slug is required'),
|
||||
theme: z.string().min(1, 'Theme is required'),
|
||||
status: z.enum(['operational', 'degraded', 'maintenance', 'major_outage']),
|
||||
is_public: z.boolean(),
|
||||
logo_url: z.string().optional(),
|
||||
custom_domain: z.string().optional(),
|
||||
custom_css: z.string().optional(),
|
||||
page_style: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const CreateOperationalPageDialog = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
|
||||
const createMutation = useCreateOperationalPage();
|
||||
const createComponentMutation = useCreateStatusPageComponent();
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
slug: '',
|
||||
theme: 'default',
|
||||
status: 'operational',
|
||||
is_public: true,
|
||||
logo_url: '',
|
||||
custom_domain: '',
|
||||
custom_css: '',
|
||||
page_style: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
const payload = {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
slug: data.slug,
|
||||
theme: data.theme,
|
||||
status: data.status,
|
||||
is_public: data.is_public ? 'true' : 'false',
|
||||
logo_url: data.logo_url || '',
|
||||
custom_domain: data.custom_domain || '',
|
||||
custom_css: data.custom_css || '',
|
||||
page_style: data.page_style || '',
|
||||
};
|
||||
|
||||
// console.log('Creating operational page with payload:', payload);
|
||||
const createdPage = await createMutation.mutateAsync(payload);
|
||||
// console.log('Created page:', createdPage);
|
||||
|
||||
// Create components after page is created
|
||||
if (selectedComponents.length > 0) {
|
||||
// console.log('Creating components for page:', createdPage.id);
|
||||
for (const component of selectedComponents) {
|
||||
const componentPayload = {
|
||||
operational_status_id: createdPage.id,
|
||||
name: component.name || '',
|
||||
description: component.description || '',
|
||||
service_id: component.service_id || '',
|
||||
server_id: component.server_id || '',
|
||||
display_order: component.display_order || 1,
|
||||
};
|
||||
|
||||
// console.log('Creating component with payload:', componentPayload);
|
||||
await createComponentMutation.mutateAsync(componentPayload);
|
||||
}
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
setSelectedComponents([]);
|
||||
} catch (error) {
|
||||
// console.error('Error creating operational page:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Page
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Operational Page</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new operational status page to monitor your services and components.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Service Status" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-service-status" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="A brief description of your operational page"
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="theme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Theme</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select theme" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Initial Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="operational">Operational</SelectItem>
|
||||
<SelectItem value="degraded">Degraded Performance</SelectItem>
|
||||
<SelectItem value="maintenance">Under Maintenance</SelectItem>
|
||||
<SelectItem value="major_outage">Major Outage</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_public"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Public Page</FormLabel>
|
||||
<FormDescription>
|
||||
Make this page publicly accessible
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="custom_domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Custom Domain (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="status.yourdomain.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Custom domain for your status page
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ComponentsSelector
|
||||
selectedComponents={selectedComponents}
|
||||
onComponentsChange={setSelectedComponents}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || createComponentMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending || createComponentMutation.isPending ? 'Creating...' : 'Create Page'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useUpdateOperationalPage } from '@/hooks/useOperationalPage';
|
||||
import { useCreateStatusPageComponent, useStatusPageComponentsByOperationalId, useDeleteStatusPageComponent } from '@/hooks/useStatusPageComponents';
|
||||
import { ComponentsSelector } from './ComponentsSelector';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
slug: z.string().min(1, 'Slug is required'),
|
||||
theme: z.string().min(1, 'Theme is required'),
|
||||
status: z.enum(['operational', 'degraded', 'maintenance', 'major_outage']),
|
||||
is_public: z.boolean(),
|
||||
logo_url: z.string().optional(),
|
||||
custom_domain: z.string().optional(),
|
||||
custom_css: z.string().optional(),
|
||||
page_style: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
interface EditOperationalPageDialogProps {
|
||||
page: OperationalPageRecord | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOperationalPageDialogProps) => {
|
||||
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
|
||||
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
|
||||
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
||||
|
||||
const updateMutation = useUpdateOperationalPage();
|
||||
const createComponentMutation = useCreateStatusPageComponent();
|
||||
const deleteComponentMutation = useDeleteStatusPageComponent();
|
||||
|
||||
// Fetch existing components for this operational page
|
||||
const { data: components = [] } = useStatusPageComponentsByOperationalId(page?.id || '');
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
slug: '',
|
||||
theme: 'default',
|
||||
status: 'operational',
|
||||
is_public: true,
|
||||
logo_url: '',
|
||||
custom_domain: '',
|
||||
custom_css: '',
|
||||
page_style: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Memoize the form reset values to prevent unnecessary re-renders
|
||||
const formValues = useMemo(() => {
|
||||
if (!page) return null;
|
||||
return {
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
slug: page.slug,
|
||||
theme: page.theme,
|
||||
status: page.status,
|
||||
is_public: page.is_public === 'true',
|
||||
logo_url: page.logo_url || '',
|
||||
custom_domain: page.custom_domain || '',
|
||||
custom_css: page.custom_css || '',
|
||||
page_style: page.page_style || '',
|
||||
};
|
||||
}, [page?.id, page?.title, page?.description, page?.slug, page?.theme, page?.status, page?.is_public, page?.logo_url, page?.custom_domain, page?.custom_css, page?.page_style]);
|
||||
|
||||
// Reset form when page data changes
|
||||
useEffect(() => {
|
||||
if (formValues) {
|
||||
form.reset(formValues);
|
||||
}
|
||||
}, [formValues, form]);
|
||||
|
||||
// Convert components to selector format and initialize state - only when dialog opens and components change
|
||||
useEffect(() => {
|
||||
if (!open || !page?.id || !components) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update if components actually changed or haven't been loaded yet
|
||||
const componentIds = components.map(c => c.id).sort().join(',');
|
||||
const currentSelectedIds = selectedComponents.map(c => c.id).filter(Boolean).sort().join(',');
|
||||
|
||||
if (componentIds !== currentSelectedIds || !componentsLoaded) {
|
||||
// console.log('Loading existing components:', components);
|
||||
const existingComponentsForSelector = components.map(comp => ({
|
||||
id: comp.id,
|
||||
name: comp.name,
|
||||
description: comp.description,
|
||||
service_id: comp.service_id,
|
||||
server_id: comp.server_id,
|
||||
display_order: comp.display_order,
|
||||
operational_status_id: comp.operational_status_id,
|
||||
}));
|
||||
|
||||
setSelectedComponents(existingComponentsForSelector);
|
||||
setComponentsLoaded(true);
|
||||
}
|
||||
}, [open, page?.id, components, componentsLoaded, selectedComponents]);
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setComponentsLoaded(false);
|
||||
setSelectedComponents([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleComponentDelete = useCallback(async (componentId: string) => {
|
||||
try {
|
||||
// console.log('Deleting component:', componentId);
|
||||
await deleteComponentMutation.mutateAsync(componentId);
|
||||
|
||||
// Update local state to remove the deleted component
|
||||
setSelectedComponents(prev => prev.filter(comp => comp.id !== componentId));
|
||||
} catch (error) {
|
||||
// console.error('Error deleting component:', error);
|
||||
}
|
||||
}, [deleteComponentMutation]);
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
if (!page) return;
|
||||
|
||||
try {
|
||||
setIsFormSubmitting(true);
|
||||
|
||||
const payload = {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
slug: data.slug,
|
||||
theme: data.theme,
|
||||
status: data.status,
|
||||
is_public: data.is_public ? 'true' : 'false',
|
||||
logo_url: data.logo_url || '',
|
||||
custom_domain: data.custom_domain || '',
|
||||
custom_css: data.custom_css || '',
|
||||
page_style: data.page_style || '',
|
||||
};
|
||||
|
||||
// console.log('Updating operational page with payload:', payload);
|
||||
await updateMutation.mutateAsync({ id: page.id, data: payload });
|
||||
|
||||
// Handle component changes
|
||||
const currentComponentIds = components.map(c => c.id);
|
||||
const newComponentsToCreate = selectedComponents.filter(comp => !comp.id);
|
||||
const componentsToDelete = components.filter(comp => !selectedComponents.some(selected => selected.id === comp.id));
|
||||
|
||||
// Delete removed components
|
||||
for (const component of componentsToDelete) {
|
||||
// console.log('Deleting component during save:', component.id);
|
||||
await deleteComponentMutation.mutateAsync(component.id);
|
||||
}
|
||||
|
||||
// Create new components
|
||||
for (const component of newComponentsToCreate) {
|
||||
const componentPayload = {
|
||||
operational_status_id: page.id,
|
||||
name: component.name || '',
|
||||
description: component.description || '',
|
||||
service_id: component.service_id || '',
|
||||
server_id: component.server_id || '',
|
||||
display_order: component.display_order || 1,
|
||||
};
|
||||
|
||||
// console.log('Creating component with payload:', componentPayload);
|
||||
await createComponentMutation.mutateAsync(componentPayload);
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
// console.error('Error updating operational page:', error);
|
||||
} finally {
|
||||
setIsFormSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Operational Page</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update your operational status page settings and manage components.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Service Status" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-service-status" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="A brief description of your operational page"
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="theme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Theme</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select theme" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="operational">Operational</SelectItem>
|
||||
<SelectItem value="degraded">Degraded Performance</SelectItem>
|
||||
<SelectItem value="maintenance">Under Maintenance</SelectItem>
|
||||
<SelectItem value="major_outage">Major Outage</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_public"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Public Page</FormLabel>
|
||||
<FormDescription>
|
||||
Make this page publicly accessible
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="custom_domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Custom Domain (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="status.yourdomain.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Custom domain for your status page
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ComponentsSelector
|
||||
selectedComponents={selectedComponents}
|
||||
onComponentsChange={setSelectedComponents}
|
||||
onComponentDelete={handleComponentDelete}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending}
|
||||
>
|
||||
{isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending ? 'Updating...' : 'Update Page'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { Globe, ExternalLink, Eye, Settings, Trash2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface OperationalPageCardProps {
|
||||
page: OperationalPageRecord;
|
||||
onEdit?: (page: OperationalPageRecord) => void;
|
||||
onView?: (page: OperationalPageRecord) => void;
|
||||
onDelete?: (page: OperationalPageRecord) => void;
|
||||
}
|
||||
|
||||
export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: OperationalPageCardProps) => {
|
||||
return (
|
||||
<Card className="hover:shadow-lg transition-shadow duration-200">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg font-semibold mb-1">{page.title}</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
{page.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<StatusBadge status={page.status} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Slug:</span>
|
||||
<p className="mt-1">{page.slug}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Theme:</span>
|
||||
<p className="mt-1 capitalize">{page.theme}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Public:</span>
|
||||
<div className="mt-1">
|
||||
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
|
||||
{page.is_public === 'true' ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Updated:</span>
|
||||
<p className="mt-1">{format(new Date(page.updated), 'MMM dd, yyyy')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{page.custom_domain && (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{page.custom_domain}</span>
|
||||
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
{onView && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onView(page)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(page)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDelete(page)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
useOperationalPages,
|
||||
useDeleteOperationalPage,
|
||||
} from "@/hooks/useOperationalPage";
|
||||
import { CreateOperationalPageDialog } from "./CreateOperationalPageDialog";
|
||||
import { EditOperationalPageDialog } from "./EditOperationalPageDialog";
|
||||
import { OperationalPageCard } from "./OperationalPageCard";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { OperationalPageRecord } from "@/types/operational.types";
|
||||
import { Activity, Plus, RefreshCw } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
export const OperationalPageContent = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
data: pages,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
isRefetching,
|
||||
} = useOperationalPages();
|
||||
const deleteMutation = useDeleteOperationalPage();
|
||||
|
||||
const [editingPage, setEditingPage] = useState<OperationalPageRecord | null>(
|
||||
null
|
||||
);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [pageToDelete, setPageToDelete] =
|
||||
useState<OperationalPageRecord | null>(null);
|
||||
|
||||
const handleEdit = (page: OperationalPageRecord) => {
|
||||
setEditingPage(page);
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleView = (page: OperationalPageRecord) => {
|
||||
if (page.custom_domain) {
|
||||
window.open(`https://${page.custom_domain}`, "_blank");
|
||||
} else {
|
||||
// Navigate to the public status page route using the correct format
|
||||
window.open(`/public/${page.slug}`, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (page: OperationalPageRecord) => {
|
||||
setPageToDelete(page);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (pageToDelete) {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(pageToDelete.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setPageToDelete(null);
|
||||
} catch (error) {
|
||||
console.error("Error deleting page:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
Failed to load operational pages
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
There was an error loading your operational pages. Please try again.
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full container mx-auto px-4 py-8 ">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8 ">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||
Operational Pages
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your public status pages and monitor service health
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-4 sm:mt-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isRefetching}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${isRefetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<CreateOperationalPageDialog />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<div className="p-6">
|
||||
<Skeleton className="h-6 w-3/4 mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-4" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Skeleton className="h-8 flex-1" />
|
||||
<Skeleton className="h-8 flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && (!pages || pages.length === 0) && (
|
||||
<Card className="p-12">
|
||||
<CardContent className="text-center">
|
||||
<div className="mb-4">
|
||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
No operational pages found
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Create your first operational page to start monitoring your
|
||||
services and communicate status to your users.
|
||||
</p>
|
||||
<CreateOperationalPageDialog />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pages Grid */}
|
||||
{!isLoading && pages && pages.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{pages.map((page) => (
|
||||
<OperationalPageCard
|
||||
key={page.id}
|
||||
page={page}
|
||||
onEdit={handleEdit}
|
||||
onView={handleView}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Footer */}
|
||||
{!isLoading && pages && pages.length > 0 && (
|
||||
<div className="mt-8 pt-6 border-t">
|
||||
<div className="flex flex-wrap gap-6 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium">Total Pages:</span> {pages.length}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Public Pages:</span>{" "}
|
||||
{pages.filter((p) => p.is_public === "true").length}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Operational:</span>{" "}
|
||||
{pages.filter((p) => p.status === "operational").length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<EditOperationalPageDialog
|
||||
page={editingPage}
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Operational Page</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{pageToDelete?.title}"? This
|
||||
action cannot be undone and will permanently remove the
|
||||
operational page and all its components.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: OperationalPageRecord['status'];
|
||||
}
|
||||
|
||||
export const StatusBadge = ({ status }: StatusBadgeProps) => {
|
||||
const getStatusConfig = (status: OperationalPageRecord['status']) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return {
|
||||
label: 'Operational',
|
||||
className: 'bg-green-100 text-green-800 hover:bg-green-200',
|
||||
};
|
||||
case 'degraded':
|
||||
return {
|
||||
label: 'Degraded Performance',
|
||||
className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200',
|
||||
};
|
||||
case 'maintenance':
|
||||
return {
|
||||
label: 'Under Maintenance',
|
||||
className: 'bg-blue-100 text-blue-800 hover:bg-blue-200',
|
||||
};
|
||||
case 'major_outage':
|
||||
return {
|
||||
label: 'Major Outage',
|
||||
className: 'bg-red-100 text-red-800 hover:bg-red-200',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: 'Unknown',
|
||||
className: 'bg-gray-100 text-gray-800 hover:bg-gray-200',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig(status);
|
||||
|
||||
return (
|
||||
<Badge className={config.className}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -10,6 +9,7 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { authService } from "@/services/authService";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
// Password change form schema
|
||||
const passwordFormSchema = z.object({
|
||||
@@ -34,6 +34,7 @@ export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<PasswordFormValues>({
|
||||
resolver: zodResolver(passwordFormSchema),
|
||||
@@ -44,12 +45,35 @@ export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
|
||||
},
|
||||
});
|
||||
|
||||
// Function to determine which collection the user belongs to
|
||||
const getUserCollection = async (userId: string): Promise<string> => {
|
||||
try {
|
||||
// First try to find the user in the regular users collection
|
||||
await pb.collection('users').getOne(userId);
|
||||
return 'users';
|
||||
} catch (error) {
|
||||
try {
|
||||
// If not found, try the superadmin collection
|
||||
await pb.collection('_superusers').getOne(userId);
|
||||
return '_superusers';
|
||||
} catch (error) {
|
||||
throw new Error('User not found in any collection');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function onSubmit(data: PasswordFormValues) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
console.log("Starting password change for user:", userId);
|
||||
|
||||
// Determine which collection the user belongs to
|
||||
const collection = await getUserCollection(userId);
|
||||
console.log("User found in collection:", collection);
|
||||
|
||||
// PocketBase requires the old password along with the new one
|
||||
await pb.collection('users').update(userId, {
|
||||
await pb.collection(collection).update(userId, {
|
||||
oldPassword: data.currentPassword,
|
||||
password: data.newPassword,
|
||||
passwordConfirm: data.confirmPassword,
|
||||
@@ -60,17 +84,31 @@ export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
|
||||
|
||||
toast({
|
||||
title: "Password updated",
|
||||
description: "Your password has been changed successfully.",
|
||||
description: "Your password has been changed successfully. You will be logged out in 3 seconds for security.",
|
||||
});
|
||||
|
||||
// Reset the form
|
||||
form.reset();
|
||||
|
||||
// Auto logout after successful password change
|
||||
setTimeout(() => {
|
||||
console.log("Auto logout after password change");
|
||||
authService.logout();
|
||||
navigate("/login");
|
||||
}, 3000);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Password change error:", error);
|
||||
|
||||
let errorMessage = "Failed to update password. Please try again.";
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
if (error.message.includes("Failed to authenticate")) {
|
||||
errorMessage = "Current password is incorrect. Please try again.";
|
||||
} else if (error.message.includes("User not found")) {
|
||||
errorMessage = "User account not found. Please contact your administrator.";
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
@@ -188,4 +226,4 @@ export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { User } from "@/services/userService";
|
||||
import { UserProfileDetails } from "./UserProfileDetails";
|
||||
@@ -9,11 +9,19 @@ import { UpdateProfileForm } from "./UpdateProfileForm";
|
||||
|
||||
interface ProfileContentProps {
|
||||
currentUser: User | null;
|
||||
onUserUpdated?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ProfileContent({ currentUser }: ProfileContentProps) {
|
||||
export function ProfileContent({ currentUser, onUserUpdated }: ProfileContentProps) {
|
||||
const [activeTab, setActiveTab] = useState("details");
|
||||
|
||||
// When active tab changes, refresh user data if needed
|
||||
useEffect(() => {
|
||||
if (activeTab === "details" && onUserUpdated) {
|
||||
onUserUpdated();
|
||||
}
|
||||
}, [activeTab, onUserUpdated]);
|
||||
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -71,4 +79,4 @@ export function ProfileContent({ currentUser }: ProfileContentProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { authService } from "@/services/authService";
|
||||
import { AlertCircle, CheckCircle } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
// Profile update form schema
|
||||
const profileFormSchema = z.object({
|
||||
@@ -31,7 +34,10 @@ interface UpdateProfileFormProps {
|
||||
|
||||
export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
const [updateSuccess, setUpdateSuccess] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Initialize the form with current user data
|
||||
const form = useForm<ProfileFormValues>({
|
||||
@@ -45,21 +51,55 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
||||
|
||||
async function onSubmit(data: ProfileFormValues) {
|
||||
setIsSubmitting(true);
|
||||
setUpdateError(null);
|
||||
setUpdateSuccess(null);
|
||||
|
||||
try {
|
||||
await userService.updateUser(user.id, {
|
||||
console.log("Submitting profile update with data:", data);
|
||||
|
||||
// Detect if email is being changed
|
||||
const isEmailChanged = data.email !== user.email;
|
||||
|
||||
// Create update payload with all fields
|
||||
const updateData = {
|
||||
full_name: data.full_name,
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
});
|
||||
// Only include email if it's changed
|
||||
email: isEmailChanged ? data.email : undefined,
|
||||
// Always set emailVisibility to true if email is changing
|
||||
emailVisibility: isEmailChanged ? true : undefined
|
||||
};
|
||||
|
||||
// Refresh user data in auth context
|
||||
await authService.refreshUserData();
|
||||
console.log("Sending update payload:", updateData);
|
||||
|
||||
toast({
|
||||
title: "Profile updated",
|
||||
description: "Your profile information has been updated successfully.",
|
||||
});
|
||||
// Update user data using the userService
|
||||
await userService.updateUser(user.id, updateData);
|
||||
|
||||
// If email was changed, show success message and auto-logout
|
||||
if (isEmailChanged) {
|
||||
setUpdateSuccess("Email changed successfully! You will be logged out for security reasons. Please log in again with your new email.");
|
||||
|
||||
toast({
|
||||
title: "Email changed successfully",
|
||||
description: "You will be logged out for security reasons. Please log in again with your new email.",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
// Auto-logout after 3 seconds
|
||||
setTimeout(() => {
|
||||
authService.logout();
|
||||
navigate("/login");
|
||||
}, 3000);
|
||||
} else {
|
||||
// Refresh user data in auth context for other field changes
|
||||
await authService.refreshUserData();
|
||||
|
||||
setUpdateSuccess("Your profile information has been updated successfully.");
|
||||
toast({
|
||||
title: "Profile updated",
|
||||
description: "Your profile information has been updated successfully.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Profile update error:", error);
|
||||
|
||||
@@ -68,6 +108,8 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
setUpdateError(errorMessage);
|
||||
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: errorMessage,
|
||||
@@ -81,6 +123,22 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{updateError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{updateError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{updateSuccess && (
|
||||
<Alert className="bg-green-50 border-green-200 text-green-800">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription>
|
||||
{updateSuccess}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="full_name"
|
||||
@@ -119,6 +177,11 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
||||
<Input type="email" placeholder="your.email@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{field.value !== user.email && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Changing your email will log you out for security reasons. You will need to log in again with your new email.
|
||||
</p>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -129,4 +192,4 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ export function UserProfileDetails({ user }: UserProfileDetailsProps) {
|
||||
return user.username[0].toUpperCase();
|
||||
};
|
||||
|
||||
// Determine if the user is active based on the status field
|
||||
const isActive = user.status === "Active";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Avatar className="h-32 w-32">
|
||||
@@ -54,8 +57,8 @@ export function UserProfileDetails({ user }: UserProfileDetailsProps) {
|
||||
{user.role}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={user.isActive ? "default" : "outline"} className="px-2 py-1">
|
||||
{user.isActive ? "Active" : "Inactive"}
|
||||
<Badge variant={isActive ? "default" : "outline"} className="px-2 py-1">
|
||||
{user.status || (isActive ? "Active" : "Inactive")}
|
||||
</Badge>
|
||||
{user.verified && (
|
||||
<Badge className="bg-green-600 hover:bg-green-700 px-2 py-1">
|
||||
@@ -72,4 +75,4 @@ export function UserProfileDetails({ user }: UserProfileDetailsProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Server, CheckCircle, XCircle, AlertTriangle, Pause, Clock } from 'lucide-react';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { Service, UptimeData } from '@/types/service.types';
|
||||
import { UptimeHistoryRenderer } from './UptimeHistoryRenderer';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface ComponentsStatusSectionProps {
|
||||
components: StatusPageComponentRecord[];
|
||||
services: Service[];
|
||||
uptimeData: Record<string, UptimeData[]>;
|
||||
}
|
||||
|
||||
export const ComponentsStatusSection = ({ components, services, uptimeData }: ComponentsStatusSectionProps) => {
|
||||
const getServiceForComponent = (component: StatusPageComponentRecord) => {
|
||||
return services.find(service => service.id === component.service_id);
|
||||
};
|
||||
|
||||
const getComponentStatus = (component: StatusPageComponentRecord) => {
|
||||
const service = getServiceForComponent(component);
|
||||
return service?.status || 'unknown';
|
||||
};
|
||||
|
||||
const getUptimePercentage = (serviceId: string) => {
|
||||
const history = uptimeData[serviceId] || [];
|
||||
if (history.length === 0) return 100;
|
||||
|
||||
const upCount = history.filter(record => record.status === 'up').length;
|
||||
return Math.round((upCount / history.length) * 100 * 100) / 100;
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
case 'down':
|
||||
return <XCircle className="h-5 w-5 text-red-500" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
|
||||
case 'paused':
|
||||
return <Pause className="h-5 w-5 text-gray-500" />;
|
||||
default:
|
||||
return <Server className="h-5 w-5 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return (
|
||||
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 border-green-200 dark:border-green-800">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Operational
|
||||
</Badge>
|
||||
);
|
||||
case 'down':
|
||||
return (
|
||||
<Badge className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 border-red-200 dark:border-red-800">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
Down
|
||||
</Badge>
|
||||
);
|
||||
case 'warning':
|
||||
return (
|
||||
<Badge className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border-yellow-200 dark:border-yellow-800">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
Degraded
|
||||
</Badge>
|
||||
);
|
||||
case 'paused':
|
||||
return (
|
||||
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200 border-gray-200 dark:border-gray-800">
|
||||
<Pause className="h-3 w-3 mr-1" />
|
||||
Maintenance
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
Unknown
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusDotColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return 'bg-green-500';
|
||||
case 'down':
|
||||
return 'bg-red-500';
|
||||
case 'warning':
|
||||
return 'bg-yellow-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
if (components.length === 0) {
|
||||
return (
|
||||
<Card className="mb-8 bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-card-foreground">
|
||||
<Server className="h-5 w-5" />
|
||||
System Components
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ name: 'API Services', description: 'Core API endpoints and services', status: 'up' },
|
||||
{ name: 'Database', description: 'Primary database systems', status: 'up' },
|
||||
{ name: 'Authentication', description: 'User authentication services', status: 'up' },
|
||||
{ name: 'File Storage', description: 'Media and file hosting', status: 'up' }
|
||||
].map((component, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-4 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(component.status)}
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">{component.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{component.description}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-green-600 dark:text-green-400 font-medium">99.9% uptime</span>
|
||||
<span className="text-xs text-muted-foreground">•</span>
|
||||
<span className="text-xs text-muted-foreground">100ms response</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(component.status)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-8 bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-card-foreground">
|
||||
<Server className="h-5 w-5" />
|
||||
System Components
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Real-time status of all monitored components
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{components
|
||||
.sort((a, b) => a.display_order - b.display_order)
|
||||
.map((component) => {
|
||||
const service = getServiceForComponent(component);
|
||||
const status = getComponentStatus(component);
|
||||
const uptime = service?.id ? getUptimePercentage(component.service_id) : 100;
|
||||
|
||||
return (
|
||||
<div key={component.id} className="space-y-4">
|
||||
<div className="flex items-center justify-between p-5 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-all duration-200">
|
||||
<div className="flex items-center gap-4">
|
||||
{getStatusIcon(status)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-foreground text-lg">{component.name}</h3>
|
||||
{service?.responseTime && service.responseTime > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
|
||||
<Clock className="h-3 w-3" />
|
||||
{service.responseTime}ms
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{component.description && (
|
||||
<p className="text-sm text-muted-foreground mb-2">{component.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-2 w-2 rounded-full ${getStatusDotColor(status)}`}></div>
|
||||
<span className="font-medium">{uptime}% uptime (90 days)</span>
|
||||
</div>
|
||||
{service?.lastChecked && (
|
||||
<span>Last checked: {format(new Date(service.lastChecked), 'HH:mm:ss')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{getStatusBadge(status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{component.service_id && (
|
||||
<div className="ml-9">
|
||||
<div className="text-xs text-muted-foreground mb-2">90-day uptime history</div>
|
||||
<UptimeHistoryRenderer
|
||||
serviceId={component.service_id}
|
||||
uptimeData={uptimeData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Shield, Clock, CheckCircle, AlertTriangle, XCircle, Wrench } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { Service } from '@/types/service.types';
|
||||
|
||||
interface CurrentStatusSectionProps {
|
||||
page: OperationalPageRecord;
|
||||
components: StatusPageComponentRecord[];
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
const getActualStatus = (components: StatusPageComponentRecord[], services: Service[]) => {
|
||||
if (components.length === 0) {
|
||||
return 'operational'; // Default if no components
|
||||
}
|
||||
|
||||
let hasDown = false;
|
||||
let hasDegraded = false;
|
||||
let hasMaintenance = false;
|
||||
|
||||
components.forEach(component => {
|
||||
const service = services.find(s => s.id === component.service_id);
|
||||
if (service) {
|
||||
switch (service.status) {
|
||||
case 'down':
|
||||
hasDown = true;
|
||||
break;
|
||||
case 'warning':
|
||||
hasDegraded = true;
|
||||
break;
|
||||
case 'paused':
|
||||
hasMaintenance = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Priority: down > degraded > maintenance > operational
|
||||
if (hasDown) return 'major_outage';
|
||||
if (hasDegraded) return 'degraded';
|
||||
if (hasMaintenance) return 'maintenance';
|
||||
return 'operational';
|
||||
};
|
||||
|
||||
const getStatusMessage = (status: OperationalPageRecord['status']) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return 'All systems are operational';
|
||||
case 'degraded':
|
||||
return 'Some systems are experiencing degraded performance';
|
||||
case 'maintenance':
|
||||
return 'Systems are currently under maintenance';
|
||||
case 'major_outage':
|
||||
return 'We are experiencing a major service outage';
|
||||
default:
|
||||
return 'Status unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: OperationalPageRecord['status']) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
case 'degraded':
|
||||
return 'text-yellow-600 dark:text-yellow-400';
|
||||
case 'maintenance':
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
case 'major_outage':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: OperationalPageRecord['status']) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return <CheckCircle className="h-6 w-6 text-green-500" />;
|
||||
case 'degraded':
|
||||
return <AlertTriangle className="h-6 w-6 text-yellow-500" />;
|
||||
case 'maintenance':
|
||||
return <Wrench className="h-6 w-6 text-blue-500" />;
|
||||
case 'major_outage':
|
||||
return <XCircle className="h-6 w-6 text-red-500" />;
|
||||
default:
|
||||
return <Shield className="h-6 w-6 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBackground = (status: OperationalPageRecord['status']) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800';
|
||||
case 'degraded':
|
||||
return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800';
|
||||
case 'maintenance':
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800';
|
||||
case 'major_outage':
|
||||
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800';
|
||||
default:
|
||||
return 'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
export const CurrentStatusSection = ({ page, components, services }: CurrentStatusSectionProps) => {
|
||||
const actualStatus = getActualStatus(components, services);
|
||||
const displayStatus = actualStatus; // Use actual status for real-time accuracy
|
||||
|
||||
return (
|
||||
<Card className={`mb-8 border-2 ${getStatusBackground(displayStatus)}`}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3 text-card-foreground text-xl">
|
||||
<Shield className="h-6 w-6" />
|
||||
System Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className={`flex items-center justify-between p-6 rounded-lg border-2 ${getStatusBackground(displayStatus)}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
{getStatusIcon(displayStatus)}
|
||||
<div>
|
||||
<h3 className={`text-2xl font-bold ${getStatusColor(displayStatus)}`}>
|
||||
{getStatusMessage(displayStatus)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Status automatically updated based on component health
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`px-4 py-2 rounded-full text-sm font-medium ${
|
||||
displayStatus === 'operational' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
|
||||
displayStatus === 'degraded' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' :
|
||||
displayStatus === 'maintenance' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
|
||||
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}>
|
||||
{displayStatus === 'operational' ? 'All Systems Operational' :
|
||||
displayStatus === 'degraded' ? 'Degraded Performance' :
|
||||
displayStatus === 'maintenance' ? 'Under Maintenance' : 'Major Outage'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground border-t pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Last updated: {format(new Date(), 'MMM dd, yyyy HH:mm')} UTC</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span>Live status monitoring</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TrendingUp, Calendar, BarChart3 } from 'lucide-react';
|
||||
import { UptimeData } from '@/types/service.types';
|
||||
import { UptimeHistoryRenderer } from './UptimeHistoryRenderer';
|
||||
|
||||
interface OverallUptimeSectionProps {
|
||||
uptimeData: Record<string, UptimeData[]>;
|
||||
}
|
||||
|
||||
export const OverallUptimeSection = ({ uptimeData }: OverallUptimeSectionProps) => {
|
||||
const getOverallUptime = () => {
|
||||
const allHistories = Object.values(uptimeData);
|
||||
if (allHistories.length === 0) return 99.9;
|
||||
|
||||
let totalRecords = 0;
|
||||
let upRecords = 0;
|
||||
|
||||
allHistories.forEach(history => {
|
||||
totalRecords += history.length;
|
||||
upRecords += history.filter(record => record.status === 'up').length;
|
||||
});
|
||||
|
||||
if (totalRecords === 0) return 99.9;
|
||||
return Math.round((upRecords / totalRecords) * 100 * 100) / 100;
|
||||
};
|
||||
|
||||
const getUptimeTrend = () => {
|
||||
const uptime = getOverallUptime();
|
||||
if (uptime >= 99.9) return 'excellent';
|
||||
if (uptime >= 99.5) return 'good';
|
||||
if (uptime >= 95) return 'fair';
|
||||
return 'poor';
|
||||
};
|
||||
|
||||
const getIncidentCount = () => {
|
||||
const allHistories = Object.values(uptimeData);
|
||||
let incidents = 0;
|
||||
|
||||
allHistories.forEach(history => {
|
||||
let wasDown = false;
|
||||
history.forEach(record => {
|
||||
if (record.status === 'down' && !wasDown) {
|
||||
incidents++;
|
||||
wasDown = true;
|
||||
} else if (record.status === 'up') {
|
||||
wasDown = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return incidents;
|
||||
};
|
||||
|
||||
const getBadgeClassName = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'excellent':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
case 'good':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||
case 'fair':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||
default:
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendText = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'excellent':
|
||||
return 'Excellent';
|
||||
case 'good':
|
||||
return 'Good';
|
||||
case 'fair':
|
||||
return 'Fair';
|
||||
default:
|
||||
return 'Needs Improvement';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusMessage = (uptime: number) => {
|
||||
if (uptime >= 99.9) {
|
||||
return "All systems are performing excellently with minimal downtime.";
|
||||
} else if (uptime >= 99.5) {
|
||||
return "Systems are performing well with occasional minor issues.";
|
||||
} else if (uptime >= 95) {
|
||||
return "We're working to improve system reliability and reduce incidents.";
|
||||
} else {
|
||||
return "We apologize for recent service disruptions and are actively working on improvements.";
|
||||
}
|
||||
};
|
||||
|
||||
const overallUptime = getOverallUptime();
|
||||
const trend = getUptimeTrend();
|
||||
const incidentCount = getIncidentCount();
|
||||
|
||||
return (
|
||||
<Card className="mb-8 bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-card-foreground">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
Performance Metrics (Last 90 Days)
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Historical performance and reliability statistics
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-300">Overall Uptime</span>
|
||||
</div>
|
||||
<Badge className={getBadgeClassName(trend)}>
|
||||
{getTrendText(trend)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-green-600 dark:text-green-400">{overallUptime}%</div>
|
||||
<div className="text-xs text-green-700 dark:text-green-300 mt-1">
|
||||
Target: 99.9%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Calendar className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Incidents</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-blue-600 dark:text-blue-400">{incidentCount}</div>
|
||||
<div className="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
||||
Last 90 days
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<BarChart3 className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">Avg Response</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">100ms</div>
|
||||
<div className="text-xs text-purple-700 dark:text-purple-300 mt-1">
|
||||
Response time
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-foreground">Uptime History</h4>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 bg-green-500 rounded"></div>
|
||||
<span>Operational</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 bg-yellow-500 rounded"></div>
|
||||
<span>Degraded</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 bg-red-500 rounded"></div>
|
||||
<span>Down</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-background/50 rounded-lg border">
|
||||
{Object.keys(uptimeData).length > 0 ? (
|
||||
<UptimeHistoryRenderer serviceId={Object.keys(uptimeData)[0]} uptimeData={uptimeData} />
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-12 text-muted-foreground">
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: 90 }, (_, i) => (
|
||||
<div key={i} className="h-8 w-1 bg-green-500 rounded-sm"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>90 days ago</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/50 rounded-lg border">
|
||||
<div className="text-sm text-muted-foreground text-center">
|
||||
{getStatusMessage(overallUptime)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, AlertCircle } from 'lucide-react';
|
||||
import { usePublicStatusPageData } from './hooks/usePublicStatusPageData';
|
||||
import { StatusPageHeader } from './StatusPageHeader';
|
||||
import { CurrentStatusSection } from './CurrentStatusSection';
|
||||
import { ComponentsStatusSection } from './ComponentsStatusSection';
|
||||
import { OverallUptimeSection } from './OverallUptimeSection';
|
||||
import { PublicStatusPageFooter } from './PublicStatusPageFooter';
|
||||
|
||||
export const PublicStatusPage = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
// console.log('PublicStatusPage - slug from params:', slug);
|
||||
|
||||
const { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug);
|
||||
const [lastUpdated, setLastUpdated] = useState(new Date());
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setLastUpdated(new Date());
|
||||
// The usePublicStatusPageData hook handles data refetching
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Apply theme to document
|
||||
useEffect(() => {
|
||||
if (page) {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Remove any existing theme classes
|
||||
root.classList.remove('dark', 'light');
|
||||
|
||||
// Apply the selected theme
|
||||
if (page.theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else if (page.theme === 'light') {
|
||||
root.classList.add('light');
|
||||
}
|
||||
// For 'default' theme, don't add any class (uses system preference)
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark', 'light');
|
||||
};
|
||||
}, [page?.theme]);
|
||||
|
||||
// console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-medium text-foreground">Loading Status Page</p>
|
||||
<p className="text-sm text-muted-foreground">Fetching real-time system status...</p>
|
||||
<p className="text-xs text-muted-foreground">Slug: {slug || 'No slug provided'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !page) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center space-y-6 max-w-md">
|
||||
<div className="mx-auto h-16 w-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-foreground">Status Page Not Found</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{error || 'The requested status page could not be found or is not publicly accessible.'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Slug: {slug || 'No slug provided'}</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button onClick={() => window.history.back()} variant="outline">
|
||||
Go Back
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
{/* Header */}
|
||||
<StatusPageHeader page={page} />
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* Current Status */}
|
||||
<CurrentStatusSection page={page} components={components} services={services} />
|
||||
|
||||
{/* Components Status */}
|
||||
<ComponentsStatusSection
|
||||
components={components}
|
||||
services={services}
|
||||
uptimeData={uptimeData}
|
||||
/>
|
||||
|
||||
{/* Overall Uptime History */}
|
||||
<OverallUptimeSection uptimeData={uptimeData} />
|
||||
|
||||
{/* Footer */}
|
||||
<PublicStatusPageFooter page={page} />
|
||||
</main>
|
||||
|
||||
{/* Custom CSS */}
|
||||
{page.custom_css && (
|
||||
<style dangerouslySetInnerHTML={{ __html: page.custom_css }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { format } from 'date-fns';
|
||||
import { Clock, Shield, Zap, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface PublicStatusPageFooterProps {
|
||||
page: OperationalPageRecord;
|
||||
}
|
||||
|
||||
export const PublicStatusPageFooter = ({ page }: PublicStatusPageFooterProps) => {
|
||||
const handleRefresh = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="mt-12 pt-8 border-t border-border">
|
||||
<div className="space-y-6">
|
||||
{/* Status Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 p-6 bg-muted/30 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 bg-green-500/10 rounded-lg flex items-center justify-center">
|
||||
<Shield className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">Real-time Monitoring</div>
|
||||
<div className="text-sm text-muted-foreground">24/7 automated checks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 bg-blue-500/10 rounded-lg flex items-center justify-center">
|
||||
<Zap className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">Instant Updates</div>
|
||||
<div className="text-sm text-muted-foreground">Status changes in real-time</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 bg-purple-500/10 rounded-lg flex items-center justify-center">
|
||||
<Clock className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">Historical Data</div>
|
||||
<div className="text-sm text-muted-foreground">90-day performance history</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Last updated: {format(new Date(), 'MMM dd, yyyy HH:mm:ss')} UTC</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span>Monitoring active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh Status
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="text-center text-xs text-muted-foreground p-4 bg-muted/20 rounded-lg">
|
||||
<p>
|
||||
This status page provides real-time information about our systems and services.
|
||||
Historical data reflects the last 90 days of monitoring. For support inquiries, please contact our team.
|
||||
</p>
|
||||
{page.custom_domain && (
|
||||
<p className="mt-2">
|
||||
Powered by automated monitoring • Status page for {page.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { Shield, Globe, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface StatusPageHeaderProps {
|
||||
page: OperationalPageRecord;
|
||||
}
|
||||
|
||||
export const StatusPageHeader = ({ page }: StatusPageHeaderProps) => {
|
||||
return (
|
||||
<header className="bg-background border-b border-border">
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{page.logo_url ? (
|
||||
<img
|
||||
src={page.logo_url}
|
||||
alt={`${page.title} logo`}
|
||||
className="h-12 w-12 rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-12 w-12 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<Shield className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">{page.title}</h1>
|
||||
<p className="text-muted-foreground mt-1">{page.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{page.custom_domain && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={`https://${page.custom_domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
Visit Site
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="text-right text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="font-medium">Live Status</span>
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
Auto-updated every 30s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="mt-6 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>Status Page</span>
|
||||
<span>•</span>
|
||||
<span className="text-foreground font-medium">{page.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { format } from 'date-fns';
|
||||
import { UptimeData } from '@/types/service.types';
|
||||
|
||||
interface UptimeHistoryRendererProps {
|
||||
serviceId: string;
|
||||
uptimeData: Record<string, UptimeData[]>;
|
||||
}
|
||||
|
||||
export const UptimeHistoryRenderer = ({ serviceId, uptimeData }: UptimeHistoryRendererProps) => {
|
||||
const renderUptimeHistory = (serviceId: string) => {
|
||||
const history = uptimeData[serviceId] || [];
|
||||
|
||||
// Generate array for the last 90 days
|
||||
const days = Array.from({ length: 90 }, (_, i) => {
|
||||
const daysSinceToday = 90 - i - 1;
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - daysSinceToday);
|
||||
date.setHours(0, 0, 0, 0); // Set to start of day for comparison
|
||||
return date;
|
||||
});
|
||||
|
||||
if (history.length === 0) {
|
||||
// Generate mock data if no real data - showing mostly operational with some realistic incidents
|
||||
return days.map((date, i) => {
|
||||
// Simulate some realistic patterns - mostly up with occasional incidents
|
||||
const isUp = Math.random() > 0.02; // 98% uptime simulation
|
||||
const responseTime = isUp ? Math.floor(Math.random() * 200) + 50 : 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider key={i}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`h-8 w-1 rounded-sm cursor-pointer ${
|
||||
isUp ? 'bg-green-500 hover:bg-green-600' : 'bg-red-500 hover:bg-red-600'
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{format(date, 'MMM dd, yyyy')}</div>
|
||||
<div className="text-muted-foreground">
|
||||
Status: {isUp ? 'Operational' : 'Incident - Down'}
|
||||
</div>
|
||||
{isUp && (
|
||||
<div className="text-muted-foreground">
|
||||
Response: {responseTime}ms
|
||||
</div>
|
||||
)}
|
||||
{!isUp && (
|
||||
<div className="text-muted-foreground text-red-400">
|
||||
Service outage detected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Create a map of dates to status records for efficient lookup and incident tracking
|
||||
const dateToRecordMap = new Map();
|
||||
const incidentsByDate = new Map();
|
||||
|
||||
history.forEach(record => {
|
||||
const recordDate = new Date(record.timestamp);
|
||||
recordDate.setHours(0, 0, 0, 0); // Normalize to start of day
|
||||
const dateKey = recordDate.toDateString();
|
||||
|
||||
// Track incidents for this date
|
||||
if (!incidentsByDate.has(dateKey)) {
|
||||
incidentsByDate.set(dateKey, []);
|
||||
}
|
||||
incidentsByDate.get(dateKey).push(record);
|
||||
|
||||
// Keep the latest record for each day (or aggregate if needed)
|
||||
if (!dateToRecordMap.has(dateKey) ||
|
||||
new Date(record.timestamp) > new Date(dateToRecordMap.get(dateKey).timestamp)) {
|
||||
dateToRecordMap.set(dateKey, record);
|
||||
}
|
||||
});
|
||||
|
||||
// Use real uptime data mapped to the correct days with incident details
|
||||
return days.map((date, i) => {
|
||||
const dateKey = date.toDateString();
|
||||
const record = dateToRecordMap.get(dateKey);
|
||||
const incidents = incidentsByDate.get(dateKey) || [];
|
||||
|
||||
// Calculate uptime percentage for the day
|
||||
const uptimePercentage = incidents.length > 0 ?
|
||||
Math.round((incidents.filter(inc => inc.status === 'up').length / incidents.length) * 100) : 100;
|
||||
|
||||
// Determine color based on actual status and incident history
|
||||
const getStatusColor = (status: string, incidents: UptimeData[]) => {
|
||||
const downIncidents = incidents.filter(inc => inc.status === 'down').length;
|
||||
const warningIncidents = incidents.filter(inc => inc.status === 'warning').length;
|
||||
|
||||
if (downIncidents > 0) return 'bg-red-500 hover:bg-red-600';
|
||||
if (warningIncidents > 0) return 'bg-yellow-500 hover:bg-yellow-600';
|
||||
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return 'bg-green-500 hover:bg-green-600';
|
||||
case 'down':
|
||||
return 'bg-red-500 hover:bg-red-600';
|
||||
case 'paused':
|
||||
return 'bg-gray-400 hover:bg-gray-500';
|
||||
case 'warning':
|
||||
return 'bg-yellow-500 hover:bg-yellow-600';
|
||||
default:
|
||||
return 'bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor = record ? getStatusColor(record.status, incidents) : 'bg-gray-300 dark:bg-gray-600';
|
||||
const statusText = record ?
|
||||
record.status === 'up' ? (incidents.some(inc => inc.status === 'down') ? 'Recovered' : 'Operational') :
|
||||
record.status === 'down' ? 'Incident - Down' :
|
||||
record.status === 'paused' ? 'Paused' :
|
||||
record.status === 'warning' ? 'Incident - Degraded' : 'Unknown'
|
||||
: 'No Data';
|
||||
|
||||
return (
|
||||
<TooltipProvider key={i}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`h-8 w-1 rounded-sm cursor-pointer ${statusColor}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{format(date, 'MMM dd, yyyy')}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Status: {statusText}
|
||||
</div>
|
||||
{incidents.length > 0 && (
|
||||
<div className="text-muted-foreground">
|
||||
Uptime: {uptimePercentage}% ({incidents.length} checks)
|
||||
</div>
|
||||
)}
|
||||
{incidents.filter(inc => inc.status === 'down').length > 0 && (
|
||||
<div className="text-red-400 text-xs">
|
||||
{incidents.filter(inc => inc.status === 'down').length} incident(s) detected
|
||||
</div>
|
||||
)}
|
||||
{record && record.status === 'up' && record.responseTime > 0 && (
|
||||
<div className="text-muted-foreground">
|
||||
Response: {record.responseTime}ms
|
||||
</div>
|
||||
)}
|
||||
{record && (
|
||||
<div className="text-muted-foreground">
|
||||
Last Check: {format(new Date(record.timestamp), 'HH:mm')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ml-8 p-3 bg-background/30 rounded-lg border border-border/50">
|
||||
<div className="text-sm font-medium text-foreground mb-2">90-day uptime history</div>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
{renderUptimeHistory(serviceId)}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>90 days ago</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { Service, UptimeData } from '@/types/service.types';
|
||||
import { operationalPageService } from '@/services/operationalPageService';
|
||||
import { statusPageComponentsService } from '@/services/statusPageComponentsService';
|
||||
import { serviceService } from '@/services/serviceService';
|
||||
import { uptimeService } from '@/services/uptimeService';
|
||||
|
||||
export const usePublicStatusPageData = (slug: string | undefined) => {
|
||||
const [page, setPage] = useState<OperationalPageRecord | null>(null);
|
||||
const [components, setComponents] = useState<StatusPageComponentRecord[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [uptimeData, setUptimeData] = useState<Record<string, UptimeData[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPublicPage = async () => {
|
||||
if (!slug) {
|
||||
// console.log('No slug provided');
|
||||
setError('No status page slug provided');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// console.log('Fetching public status page for slug:', slug);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch operational page
|
||||
// console.log('Fetching operational pages...');
|
||||
const pages = await operationalPageService.getOperationalPages();
|
||||
// console.log('All pages:', pages);
|
||||
|
||||
const foundPage = pages.find(p => p.slug === slug && p.is_public === 'true');
|
||||
// console.log('Found page:', foundPage);
|
||||
|
||||
if (!foundPage) {
|
||||
// console.log('Page not found or not public');
|
||||
setError('Status page not found or not public');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPage(foundPage);
|
||||
// console.log('Page set successfully');
|
||||
|
||||
// Fetch components for this page
|
||||
// console.log('Fetching components for page:', foundPage.id);
|
||||
const pageComponents = await statusPageComponentsService.getStatusPageComponentsByOperationalId(foundPage.id);
|
||||
// console.log('Components found:', pageComponents);
|
||||
setComponents(pageComponents);
|
||||
|
||||
// Fetch all services
|
||||
// console.log('Fetching all services...');
|
||||
const allServices = await serviceService.getServices();
|
||||
// console.log('Services found:', allServices);
|
||||
setServices(allServices);
|
||||
|
||||
// Fetch uptime data for each component that has a service
|
||||
// console.log('Fetching uptime data...');
|
||||
const uptimePromises = pageComponents
|
||||
.filter(component => component.service_id)
|
||||
.map(async (component) => {
|
||||
try {
|
||||
// console.log('Fetching uptime for service:', component.service_id);
|
||||
const endDate = new Date();
|
||||
const startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // Last 90 days
|
||||
const history = await uptimeService.getUptimeHistory(component.service_id, 2000, startDate, endDate);
|
||||
// console.log(`Uptime history for ${component.service_id}:`, history.length, 'records');
|
||||
return { serviceId: component.service_id, history };
|
||||
} catch (error) {
|
||||
// console.error(`Error fetching uptime for service ${component.service_id}:`, error);
|
||||
return { serviceId: component.service_id, history: [] };
|
||||
}
|
||||
});
|
||||
|
||||
const uptimeResults = await Promise.all(uptimePromises);
|
||||
const uptimeMap: Record<string, UptimeData[]> = {};
|
||||
uptimeResults.forEach(result => {
|
||||
uptimeMap[result.serviceId] = result.history;
|
||||
});
|
||||
setUptimeData(uptimeMap);
|
||||
// console.log('Uptime data set successfully');
|
||||
|
||||
// console.log('All data fetched successfully');
|
||||
|
||||
} catch (err) {
|
||||
// console.error('Error fetching public page:', err);
|
||||
setError(`Failed to load status page: ${err}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPublicPage();
|
||||
}, [slug]);
|
||||
|
||||
return {
|
||||
page,
|
||||
components,
|
||||
services,
|
||||
uptimeData,
|
||||
loading,
|
||||
error
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,460 @@
|
||||
import React, { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Copy, Download, Terminal, CheckCircle, Zap, Play } from "lucide-react";
|
||||
import { regionalService } from "@/services/regionalService";
|
||||
import { InstallCommand } from "@/types/regional.types";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface AddRegionalAgentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAgentAdded: () => void;
|
||||
}
|
||||
|
||||
export const AddRegionalAgentDialog: React.FC<AddRegionalAgentDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAgentAdded
|
||||
}) => {
|
||||
const [step, setStep] = useState(1);
|
||||
const [regionName, setRegionName] = useState("");
|
||||
const [agentIp, setAgentIp] = useState("");
|
||||
const [installCommand, setInstallCommand] = useState<InstallCommand | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleCreateAgent = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!regionName.trim() || !agentIp.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await regionalService.createRegionalService({
|
||||
region_name: regionName,
|
||||
agent_ip_address: agentIp,
|
||||
});
|
||||
|
||||
setInstallCommand(result.installCommand);
|
||||
setStep(2);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to create regional agent configuration.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, description: string = "Content") => {
|
||||
try {
|
||||
// Try the modern clipboard API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast({
|
||||
title: "Copied!",
|
||||
description: `${description} copied to clipboard.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for older browsers or non-secure contexts (like localhost)
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
if (successful) {
|
||||
toast({
|
||||
title: "Copied!",
|
||||
description: `${description} copied to clipboard.`,
|
||||
});
|
||||
} else {
|
||||
throw new Error('execCommand failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
|
||||
// Show the text in a modal or alert as final fallback
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
|
||||
let errorMessage = "Failed to copy to clipboard.";
|
||||
if (isLocalhost) {
|
||||
errorMessage += " This is common in local development. Please manually copy the text.";
|
||||
} else if (userAgent.includes('chrome')) {
|
||||
errorMessage += " Try using HTTPS or enable clipboard permissions.";
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Copy failed",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
// As a last resort, select the text for manual copying
|
||||
try {
|
||||
const textarea = document.querySelector('textarea[readonly]') as HTMLTextAreaElement;
|
||||
if (textarea) {
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, 99999); // For mobile devices
|
||||
}
|
||||
} catch (selectError) {
|
||||
// console.error('Failed to select text:', selectError);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const downloadScript = () => {
|
||||
if (!installCommand) return;
|
||||
|
||||
const blob = new Blob([installCommand.bash_script], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `install-regional-agent-${installCommand.agent_id}.sh`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({
|
||||
title: "Downloaded!",
|
||||
description: "Installation script downloaded successfully.",
|
||||
});
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
onAgentAdded();
|
||||
setStep(1);
|
||||
setRegionName("");
|
||||
setAgentIp("");
|
||||
setInstallCommand(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const resetDialog = () => {
|
||||
setStep(1);
|
||||
setRegionName("");
|
||||
setAgentIp("");
|
||||
setInstallCommand(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Regional Monitoring Agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
Deploy a regional monitoring agent with automatic one-click installation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 1 && (
|
||||
<form onSubmit={handleCreateAgent} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="regionName">Region Name</Label>
|
||||
<Input
|
||||
id="regionName"
|
||||
placeholder="e.g., us-east-1, europe-west-1, asia-pacific-1"
|
||||
value={regionName}
|
||||
onChange={(e) => setRegionName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="agentIp">Agent Server IP Address</Label>
|
||||
<Input
|
||||
id="agentIp"
|
||||
placeholder="e.g., 192.168.1.100 or your-server.example.com"
|
||||
value={agentIp}
|
||||
onChange={(e) => setAgentIp(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Generating..." : "Generate Installation"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 2 && installCommand && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<CheckCircle className="h-12 w-12 text-green-600 mx-auto" />
|
||||
<h3 className="text-lg font-semibold">Agent Configuration Ready!</h3>
|
||||
<p className="text-muted-foreground">
|
||||
One-click installation script generated with automatic configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="oneclicK" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="oneclicK">One-Click Install</TabsTrigger>
|
||||
<TabsTrigger value="details">Agent Details</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual Install</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="oneclicK" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-yellow-500" />
|
||||
One-Click Automatic Installation
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Complete installation, configuration, and service startup in one command
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-green-800 font-medium mb-2">
|
||||
<Play className="h-4 w-4" />
|
||||
What this script does automatically:
|
||||
</div>
|
||||
<ul className="text-sm text-green-700 space-y-1 ml-6">
|
||||
<li>• Downloads the latest .deb package</li>
|
||||
<li>• Installs the regional monitoring agent</li>
|
||||
<li>• Creates configuration file with your settings</li>
|
||||
<li>• Starts and enables the service</li>
|
||||
<li>• Runs health checks</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Run this command on your target server:</Label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
readOnly
|
||||
value={`# One-click installation command:
|
||||
curl -fsSL https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/install-regional-agent.sh | sudo bash -s -- \\
|
||||
--region-name="${regionName}" \\
|
||||
--agent-id="${installCommand.agent_id}" \\
|
||||
--agent-ip="${agentIp}" \\
|
||||
--token="${installCommand.token}" \\
|
||||
--pocketbase-url="${installCommand.api_endpoint}"`}
|
||||
className="font-mono text-sm min-h-[120px] pr-12"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => copyToClipboard(`curl -fsSL https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/install-regional-agent.sh | sudo bash -s -- --region-name="${regionName}" --agent-id="${installCommand.agent_id}" --agent-ip="${agentIp}" --token="${installCommand.token}" --pocketbase-url="${installCommand.api_endpoint}"`, "Installation command")}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={downloadScript} variant="outline" className="flex-1">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download Complete Script
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => copyToClipboard(installCommand.bash_script, "Installation script")}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Full Script
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Local development notice */}
|
||||
{(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Local Development Notice:</strong> If clipboard copying fails, this is normal in local development.
|
||||
You can use the "Download Complete Script" button instead or manually copy the text.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Generated Agent Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
These values will be automatically configured during installation
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-foreground">Agent ID</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={installCommand.agent_id}
|
||||
readOnly
|
||||
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground pr-10"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
|
||||
onClick={() => copyToClipboard(installCommand.agent_id, "Agent ID")}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-foreground">Region Name</Label>
|
||||
<Input
|
||||
value={regionName}
|
||||
readOnly
|
||||
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-foreground">Server IP</Label>
|
||||
<Input
|
||||
value={agentIp}
|
||||
readOnly
|
||||
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-foreground">API Endpoint</Label>
|
||||
<Input
|
||||
value={installCommand.api_endpoint}
|
||||
readOnly
|
||||
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-foreground">Authentication Token</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={installCommand.token}
|
||||
readOnly
|
||||
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground pr-10"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
|
||||
onClick={() => copyToClipboard(installCommand.token, "Authentication token")}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Configuration file location:</strong> /etc/regional-check-agent/regional-check-agent.env
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5" />
|
||||
Manual Installation Steps
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Step-by-step manual installation process
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm space-y-4">
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<p className="font-medium">Step 1: Download Package</p>
|
||||
<code className="text-xs bg-muted p-2 rounded block mt-1">
|
||||
<p className="text-xs text-muted-foreground mt-1">✅ If you’re using the amd64 architecture, please download this package. </p> <br/>
|
||||
wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_amd64.deb
|
||||
</code>
|
||||
<code className="text-xs bg-muted p-2 rounded block mt-1">
|
||||
<p className="text-xs text-muted-foreground mt-1">✅ If you’re using the arm64 architecture, please download this package. </p> <br/>
|
||||
wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_arm64.deb
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<p className="font-medium">Step 2: Install Package</p>
|
||||
<code className="text-xs bg-muted p-2 rounded block mt-1">
|
||||
sudo dpkg -i distributed-regional-check-agent_1.0.0_amd64.deb<br/>
|
||||
sudo apt-get install -f
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<p className="font-medium">Step 3: Configure Agent</p>
|
||||
<code className="text-xs bg-muted p-2 rounded block mt-1">
|
||||
sudo nano /etc/regional-check-agent/regional-check-agent.env
|
||||
</code>
|
||||
<code className="text-xs bg-muted p-2 rounded block mt-1">
|
||||
Copy sample configuration from https://github.com/operacle/Distributed-Regional-Monitoring/blob/main/packaging/regional-check-agent.conf
|
||||
</code>
|
||||
<p className="text-xs text-muted-foreground mt-1">Add the configuration values shown in the Agent Details tab</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<p className="font-medium">Step 4: Start Service</p>
|
||||
<code className="text-xs bg-muted p-2 rounded block mt-1">
|
||||
sudo systemctl enable regional-check-agent<br/>
|
||||
sudo systemctl start regional-check-agent
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-green-500 pl-4">
|
||||
<p className="font-medium">Step 5: Verify Installation</p>
|
||||
<code className="text-xs bg-muted p-2 rounded block mt-1">
|
||||
sudo systemctl status regional-check-agent<br/>
|
||||
curl http://localhost:8091/health
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={resetDialog}>
|
||||
Add Another Agent
|
||||
</Button>
|
||||
<Button onClick={handleComplete}>
|
||||
Complete Setup
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { MapPin, Wifi, WifiOff, MoreVertical, Trash2, Terminal, Copy } from "lucide-react";
|
||||
import { RegionalService } from "@/types/regional.types";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface RegionalAgentCardProps {
|
||||
agent: RegionalService;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onDelete }) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
// Check if this is the default agent that cannot be removed
|
||||
const isDefaultAgent = agent.agent_id === "1" || agent.region_name === "Default";
|
||||
|
||||
const copyAgentId = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(agent.agent_id);
|
||||
toast({
|
||||
title: "Copied!",
|
||||
description: "Agent ID copied to clipboard.",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Copy failed",
|
||||
description: "Failed to copy agent ID.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionStatus = () => {
|
||||
if (agent.connection === 'online') {
|
||||
return {
|
||||
icon: <Wifi className="h-4 w-4" />,
|
||||
label: 'Online',
|
||||
variant: 'default' as const,
|
||||
className: 'bg-green-100 text-green-800 hover:bg-green-100'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
label: 'Offline',
|
||||
variant: 'secondary' as const,
|
||||
className: 'bg-red-100 text-red-800 hover:bg-red-100'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const connectionStatus = getConnectionStatus();
|
||||
|
||||
return (
|
||||
<Card className="relative">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<MapPin className="h-5 w-5 text-blue-600" />
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
{agent.region_name}
|
||||
{isDefaultAgent && (
|
||||
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-1 text-sm">
|
||||
{agent.agent_ip_address}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={copyAgentId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Agent ID
|
||||
</DropdownMenuItem>
|
||||
{!isDefaultAgent && (
|
||||
<DropdownMenuItem onClick={onDelete} className="text-red-600">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove Agent
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant={connectionStatus.variant}
|
||||
className={connectionStatus.className}
|
||||
>
|
||||
{connectionStatus.icon}
|
||||
<span className="ml-1">{connectionStatus.label}</span>
|
||||
</Badge>
|
||||
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{agent.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Agent ID:</span>
|
||||
<span className="font-mono text-xs">{agent.agent_id.substring(0, 12)}...</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Last Updated:</span>
|
||||
<span className="text-xs">
|
||||
{formatDistanceToNow(new Date(agent.updated), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agent.connection === 'online' && (
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex items-center text-xs text-green-600">
|
||||
<div className="w-2 h-2 bg-green-600 rounded-full mr-2 animate-pulse"></div>
|
||||
Active monitoring
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agent.connection === 'offline' && (
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex items-center text-xs text-red-600">
|
||||
<div className="w-2 h-2 bg-red-600 rounded-full mr-2"></div>
|
||||
Connection lost
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import React, { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Plus, MapPin, Activity, Wifi, WifiOff } from "lucide-react";
|
||||
import { regionalService } from "@/services/regionalService";
|
||||
import { RegionalService } from "@/types/regional.types";
|
||||
import { AddRegionalAgentDialog } from "./AddRegionalAgentDialog";
|
||||
import { RegionalAgentCard } from "./RegionalAgentCard";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export const RegionalMonitoringContent = () => {
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: regionalServices = [],
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["regional-services"],
|
||||
queryFn: regionalService.getRegionalServices,
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
});
|
||||
|
||||
const handleAgentAdded = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["regional-services"] });
|
||||
toast({
|
||||
title: "Regional Agent Added",
|
||||
description:
|
||||
"The regional monitoring agent has been successfully configured.",
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteAgent = async (id: string) => {
|
||||
try {
|
||||
await regionalService.deleteRegionalService(id);
|
||||
queryClient.invalidateQueries({ queryKey: ["regional-services"] });
|
||||
toast({
|
||||
title: "Agent Removed",
|
||||
description: "The regional monitoring agent has been removed.",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to remove the regional monitoring agent.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onlineAgents = regionalServices.filter(
|
||||
(agent) => agent.connection === "online"
|
||||
).length;
|
||||
const totalAgents = regionalServices.length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 ">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Regional Monitoring
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage distributed monitoring agents across different regions
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Regional Agent
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Agents</CardTitle>
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalAgents}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Regional monitoring agents
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Online Agents</CardTitle>
|
||||
<Wifi className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{onlineAgents}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Currently connected</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Offline Agents
|
||||
</CardTitle>
|
||||
<WifiOff className="h-4 w-4 text-red-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{totalAgents - onlineAgents}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Disconnected agents</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Agents List */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Regional Agents</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardHeader>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-gray-200 rounded"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-5/6"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : regionalServices.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<MapPin className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No Regional Agents</h3>
|
||||
<p className="text-muted-foreground text-center mb-4">
|
||||
Get started by adding your first regional monitoring agent to
|
||||
extend your monitoring coverage.
|
||||
</p>
|
||||
<Button onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add First Agent
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{regionalServices.map((agent) => (
|
||||
<RegionalAgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
onDelete={() => handleDeleteAgent(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddRegionalAgentDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={setAddDialogOpen}
|
||||
onAgentAdded={handleAgentAdded}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
export { RegionalMonitoringContent } from './RegionalMonitoringContent';
|
||||
export { AddRegionalAgentDialog } from './AddRegionalAgentDialog';
|
||||
export { RegionalAgentCard } from './RegionalAgentCard';
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
import React from 'react';
|
||||
import { IncidentManagementContainer } from './incident-management';
|
||||
|
||||
interface IncidentManagementTabProps {
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
export const IncidentManagementTab = React.memo(({ refreshTrigger = 0 }: IncidentManagementTabProps) => {
|
||||
return <IncidentManagementContainer refreshTrigger={refreshTrigger} />;
|
||||
});
|
||||
|
||||
IncidentManagementTab.displayName = 'IncidentManagementTab';
|
||||