Compare commits
300 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5a985ff1e | |||
| 4b27b85038 | |||
| 463299fa73 | |||
| 91ceddc489 | |||
| f2c338ced9 | |||
| 9dbd267a22 | |||
| 2394243cb9 | |||
| bc38dba859 | |||
| 8f84fbf562 | |||
| 6b8ee741b3 | |||
| 370f613080 | |||
| 4f367b92da | |||
| 6ab3be6fe4 | |||
| 4221c4751a | |||
| a39b326f0e | |||
| 84916f705a | |||
| e4e87abac5 | |||
| f528f6eaea | |||
| b8966fe743 | |||
| 2a4a6cea48 | |||
| 3a3dc020fd | |||
| ed5cff5026 | |||
| bf45f896bd | |||
| 00430b6cb7 | |||
| feb65edee5 | |||
| 04f125ac1e | |||
| 435694f694 | |||
| c33328f977 | |||
| 17e0d0a794 | |||
| d408658b69 | |||
| 682ce84ef7 | |||
| 7392580db8 | |||
| ee5e66eb44 | |||
| ed220b9a27 | |||
| b6ef4ff7a5 | |||
| a4364936e3 | |||
| f5a8d989cc | |||
| 2e2df30b85 | |||
| 498b26dbd5 | |||
| 79fda8105f | |||
| 8d8e871b9f | |||
| 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 |
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
server/
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.next
|
||||||
|
**/.turbo
|
||||||
|
**/.cache
|
||||||
|
**/coverage
|
||||||
|
**/*.log
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Build and Publish Frontend Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "develop", "feature/**" ]
|
||||||
|
paths:
|
||||||
|
- 'application/**'
|
||||||
|
- '.github/workflows/build-frontend-image.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/checkcle-frontend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: application
|
||||||
|
file: application/Dockerfile
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
provenance: false
|
||||||
|
|
||||||
@@ -22,5 +22,8 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
pocketbase.exe
|
||||||
application/src/lib/pocketbase.ts
|
application/src/lib/pocketbase.ts
|
||||||
|
server/pb_data/
|
||||||
Dockerfile
|
Dockerfile
|
||||||
|
application/src/lib/pocketbase-prod.ts
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fileignoreconfig:
|
||||||
|
- filename: application/package-lock.json
|
||||||
|
checksum: 3253a06ef27a634ec2b8a8fe9c156288b9fcf57024623c7ddc3d321f2568e498
|
||||||
|
version: "1.0"
|
||||||
@@ -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
|
# 🛠️ Contributing to CheckCle
|
||||||
|
|
||||||
@@ -46,7 +79,7 @@ Click "Fork" on [GitHub](https://github.com/operacle/checkcle) to create your ow
|
|||||||
|
|
||||||
### 2. Clone Your Fork
|
### 2. Clone Your Fork
|
||||||
```bash
|
```bash
|
||||||
git clone --branch develop https://github.com/operacle/checkcle.git
|
git clone https://github.com/yourusername/checkcle.git
|
||||||
cd checkcle
|
cd checkcle
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -61,16 +94,45 @@ npm install && npm run dev
|
|||||||
|
|
||||||
#Server Backend
|
#Server Backend
|
||||||
cd server
|
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 https://github.com/yourusername/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
|
## ✅ Pull Request Process
|
||||||
|
|
||||||
1. Ensure your code follows the existing style and naming conventions.
|
1. Ensure your code follows the existing style and naming conventions.
|
||||||
2. Write clear, concise commit messages.
|
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).
|
4. Provide a meaningful PR description (what/why/how).
|
||||||
5. Link related issues if applicable (e.g. `Closes #12`).
|
5. Link related issues if applicable (e.g. `Closes #12`).
|
||||||
6. Make sure all checks pass (e.g., linting, tests).
|
6. Make sure all checks pass (e.g., linting, tests).
|
||||||
|
|||||||
@@ -1,4 +1,43 @@
|
|||||||

|
## 🌐 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?
|
# 🚀 What is CheckCle?
|
||||||
|
|
||||||
@@ -6,19 +45,22 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
|
|||||||
|
|
||||||
## 🎯 Live Demo
|
## 🎯 Live Demo
|
||||||
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io)
|
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io)
|
||||||
|
User: admin@example.com | Passwd: Admin123456
|
||||||
|
|
||||||
## 🌟 Core Features
|
## 🌟 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 HTTP, DNS, and Ping protocols
|
||||||
- Monitor TCP-based, API services (e.g., FTP, SMTP, HTTP)
|
- Monitor TCP-based, API services (e.g., FTP, SMTP, HTTP)
|
||||||
- Track detail uptime, response times, and performance issues
|
- Track detail uptime, response times, and performance issues
|
||||||
|
- Distributed Regional Monitoring
|
||||||
- Incident History (UP/DOWN/WARNING/PAUSE)
|
- Incident History (UP/DOWN/WARNING/PAUSE)
|
||||||
- SSL & Domain Monitoring (Domain, Issuer, Expiration Date, Days Left, Status, Last Notified)
|
- 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.
|
- 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.
|
||||||
- Schedule Maintenance & Incident Management
|
- Schedule Maintenance & Incident Management
|
||||||
- Operational Status / Public Status Pages
|
- Operational Status / Public Status Pages
|
||||||
- Notifications via email, Telegram, Discord, and Slack
|
- Notifications via email, Telegram, Discord, Slack, Matrix, and more
|
||||||
- Reports & Analytics
|
- Reports & Analytics
|
||||||
- Settings Panel (User Management, Data Retention, Multi-language, Themes (Dark & Light Mode), Notification and channels and alert templates).
|
- Settings Panel (User Management, Data Retention, Multi-language, Themes (Dark & Light Mode), Notification and channels and alert templates).
|
||||||
|
|
||||||
@@ -28,26 +70,9 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
|
|||||||
* ✅ x86_64 PCs, laptops, servers (amd64)
|
* ✅ x86_64 PCs, laptops, servers (amd64)
|
||||||
* ✅ Modern Raspberry Pi 3/4/5 with (64-bit OS), Apple Silicon Macs (arm64)
|
* ✅ Modern Raspberry Pi 3/4/5 with (64-bit OS), Apple Silicon Macs (arm64)
|
||||||
|
|
||||||
### Install CheckCle using one of the options below.
|
### Install CheckCle using one of the following methods:
|
||||||
|
|
||||||
|
1. Install with Docker Compose Configuration (Recommended)
|
||||||
1. CheckCle One-Click Installation - Just copy and run on terminal
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://checkcle.io/install.sh | bash
|
|
||||||
|
|
||||||
```
|
|
||||||
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. Install with Docker compose Configuration.
|
|
||||||
```bash
|
```bash
|
||||||
|
|
||||||
version: '3.9'
|
version: '3.9'
|
||||||
@@ -67,6 +92,18 @@ services:
|
|||||||
hard: 8192
|
hard: 8192
|
||||||
|
|
||||||
```
|
```
|
||||||
|
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
|
3. Admin Web Management
|
||||||
|
|
||||||
Default URL: http://0.0.0.0:8090
|
Default URL: http://0.0.0.0:8090
|
||||||
@@ -76,31 +113,12 @@ services:
|
|||||||
4. Follow the Quick Start Guide at https://docs.checkcle.io
|
4. Follow the Quick Start Guide at https://docs.checkcle.io
|
||||||
|
|
||||||
###
|
###
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
## 📝 Development Roadmap
|
|
||||||
|
|
||||||
- ✅ Health check & uptime monitoring (HTTP)
|
|
||||||
- ✅ Dashboard UI with live stats
|
|
||||||
- ✅ Auth with Multi-users system (admin)
|
|
||||||
- ✅ Notifications (Telegram)
|
|
||||||
- ✅ Docker containerization
|
|
||||||
- ✅ CheckCle Website
|
|
||||||
- ✅ CheckCle Demo Server
|
|
||||||
- ✅ SSL & Domain Monitoring
|
|
||||||
- ✅ Schedule Maintenance
|
|
||||||
- ✅ Incident Management
|
|
||||||
- [ ] Uptime monitoring (PING - Inprogress)
|
|
||||||
- [ ] Infrastructure Server Monitoring
|
|
||||||
- ✅ Operational Status / Public Status Pages
|
|
||||||
- [ ] Uptime monitoring (TCP, PING, DNS)
|
|
||||||
- ✅ System Setting Panel and Mail Settings
|
|
||||||
- ✅ User Permission Roles
|
|
||||||
- [ ] Notifications (Email/Slack/Discord/Signal)
|
|
||||||
- ✅ Data Retention & Automate Strink (Muti Options to Shrink Data & Database )
|
|
||||||
- ✅ Open-source release with full documentation
|
|
||||||
|
|
||||||
## 🌟 CheckCle for Communities?
|
## 🌟 CheckCle for Communities?
|
||||||
- **Built with Passion**: Created by an open-source enthusiast for the community
|
- **Built with Passion**: Created by an open-source enthusiast for the community
|
||||||
@@ -109,6 +127,48 @@ services:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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>
|
||||||
|
</a>
|
||||||
|
<a href="https://www.jetbrains.com/">
|
||||||
|
<img src="https://cdn.checkcle.io/images/sponsor/jetbrains.png"
|
||||||
|
height="50"
|
||||||
|
alt="Jetbrains 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
|
## 🤝 Ways to Contribute
|
||||||
|
|
||||||
Here are some ways you can help improve CheckCle:
|
Here are some ways you can help improve CheckCle:
|
||||||
@@ -117,26 +177,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.
|
- 🌟 **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.
|
- 🛠 **Submit Pull Requests** – Improve the code, fix bugs, add features, or enhance the docs.
|
||||||
- 📝 **Improve Documentation** – Even a typo fix helps!
|
- 📝 **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
|
## 🌍 Stay Connected
|
||||||
- Website: [checkcle.io](https://checkcle.io)
|
- Website: [checkcle.io](https://checkcle.io)
|
||||||
- Documentation: [docs.checkcle.io](https://docs.checkcle.io)
|
- Documentation: [docs.checkcle.io](https://docs.checkcle.io) | Big thanks to [GitBook](https://github.com/gitbookio) for sponsoring the OSS site plan for CheckCle!
|
||||||
- GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git)
|
- Chat on Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
|
||||||
- Community Channels: Engage via discussions and issues!
|
- Follow us on X: [@asqrm_io](https://x.com/asqrm_io)
|
||||||
- Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
|
|
||||||
- X: [@tlengoss](https://x.com/tlengoss)
|
|
||||||
|
|
||||||
## 📜 License
|
## 📜 License
|
||||||
|
|
||||||
CheckCle is released under the MIT License.
|
CheckCle is released under the MIT License.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#operacle/checkcle&Date)
|
|
||||||
|
|
||||||
Stay informed, stay online, with CheckCle! 🌐
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 📦 Project: [checkcle](https://github.com/operacle/checkcle)
|
## 📦 Project: [checkcle](https://github.com/operacle/checkcle)
|
||||||
|
|
||||||
**checkcle** is a lightweight, TypeScript-based built for uptime and server monitoring tools.
|
**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.
|
We care about the security and privacy of users running this project in production environments.
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ We support the latest stable release of `checkcle`. Security patches may also be
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
|
| `develop` (latest) | ✅ Yes |
|
||||||
| `main` (latest) | ✅ Yes |
|
| `main` (latest) | ✅ Yes |
|
||||||
| Older versions | ⚠️ Best-effort |
|
| Older versions | ⚠️ Best-effort |
|
||||||
| Pre-release or forks | ❌ No |
|
| Pre-release or forks | ❌ No |
|
||||||
@@ -47,22 +48,21 @@ We support the latest stable release of `checkcle`. Security patches may also be
|
|||||||
|
|
||||||
## 🔍 Security Practices
|
## 🔍 Security Practices
|
||||||
|
|
||||||
This project adheres to the following practices to enhance security:
|
CheckCle follows these practices to improve overall security:
|
||||||
|
|
||||||
- 🔎 Regular vulnerability checks using `npm audit`
|
- 🔎 Regular vulnerability scanning (npm audit for JavaScript dependencies, govulncheck for Go modules)
|
||||||
- ⛓️ Dependency pinning via `package-lock.json`
|
- ⛓️ Dependency pinning (package-lock.json and Go modules)
|
||||||
- ✅ Type-safe code using `TypeScript`
|
- ✅ Type-safe code in TypeScript and memory-safe design in Go
|
||||||
- 🧪 Continuous testing and CI
|
- 🧪 Continuous testing and CI pipelines
|
||||||
- 🔐 No data is stored or transmitted unless explicitly configured by the user
|
- 🔐 No data is stored or transmitted unless explicitly configured by the user
|
||||||
- 🧑💻 All contributions are reviewed before being merged
|
- 🧑💻 All code contributions are reviewed before merging
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚠️ Known Security Limitations
|
## ⚠️ Known Security Limitations
|
||||||
|
|
||||||
- `checkcle` makes outbound HTTPS requests to check certificate details — avoid running in untrusted or high-risk environments without proper network policies.
|
- 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.
|
||||||
- Output may contain certificate metadata (e.g., CN, SANs, expiry dates) — avoid exposing this to public logs unless sanitized.
|
- 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.
|
||||||
- The data may be lost upon system restarts or crashes. Always ensure that backup and recovery mechanisms are in place in production environments.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.cache
|
||||||
|
*.log
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
FROM node:20-bullseye-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
ENV npm_config_fund=false \
|
||||||
|
npm_config_audit=false \
|
||||||
|
npm_config_progress=false
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --fund=false
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
# SPA fallback + asset caching
|
||||||
|
RUN printf 'server {\n listen 80;\n server_name _;\n root /usr/share/nginx/html;\n index index.html;\n location / { try_files $uri $uri/ /index.html; }\n location ~* \\.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ { expires 7d; add_header Cache-Control "public, max-age=604800, immutable"; try_files $uri =404; }\n}\n' > /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx","-g","daemon off;"]
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>CheckCle is an open-source monitoring stack</title>
|
<title>CheckCle</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="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="author" content="Tola Leng" />
|
||||||
|
|
||||||
@@ -14,13 +14,12 @@
|
|||||||
<meta property="og:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
|
<meta property="og:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
|
||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:site" content="@tlengoss" />
|
<meta name="twitter:site" content="@checkcle_oss" />
|
||||||
<meta name="twitter:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
|
<meta name="twitter:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<!-- IMPORTANT: DO NOT REMOVE THIS SCRIPT TAG OR THIS VERY COMMENT! -->
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
"jspdf-autotable": "^5.0.2",
|
"jspdf-autotable": "^5.0.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"pocketbase": "^0.19.0",
|
"pocketbase": "^0.26.8",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -82,4 +82,4 @@
|
|||||||
"typescript-eslint": "^8.0.1",
|
"typescript-eslint": "^8.0.1",
|
||||||
"vite": "^5.4.1"
|
"vite": "^5.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
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: 250 B |
|
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,63 +1,71 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
import React from 'react';
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { ThemeProvider } from '@/contexts/ThemeContext';
|
|
||||||
import { LanguageProvider } from '@/contexts/LanguageContext';
|
|
||||||
import { SidebarProvider } from '@/contexts/SidebarContext';
|
|
||||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
|
||||||
|
|
||||||
// Pages
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import Index from '@/pages/Index';
|
import { LanguageProvider } from './contexts/LanguageContext';
|
||||||
import Login from '@/pages/Login';
|
import { SidebarProvider } from './contexts/SidebarContext';
|
||||||
import Dashboard from '@/pages/Dashboard';
|
|
||||||
import ServiceDetail from '@/pages/ServiceDetail';
|
|
||||||
import Profile from '@/pages/Profile';
|
|
||||||
import Settings from '@/pages/Settings';
|
|
||||||
import OperationalPage from '@/pages/OperationalPage';
|
|
||||||
import ScheduleIncident from '@/pages/ScheduleIncident';
|
|
||||||
import SslDomain from '@/pages/SslDomain';
|
|
||||||
import PublicStatusPage from '@/pages/PublicStatusPage';
|
|
||||||
import NotFound from '@/pages/NotFound';
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
import Index from './pages/Index';
|
||||||
defaultOptions: {
|
import Login from './pages/Login';
|
||||||
queries: {
|
import Dashboard from './pages/Dashboard';
|
||||||
retry: 1,
|
import InstanceMonitoring from './pages/InstanceMonitoring';
|
||||||
refetchOnWindowFocus: false,
|
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';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [queryClient] = useState(() => new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<BrowserRouter>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<ErrorBoundary>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Router>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Index />} />
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
|
||||||
<Route path="/service/:id" element={<ServiceDetail />} />
|
|
||||||
<Route path="/profile" element={<Profile />} />
|
|
||||||
<Route path="/settings" element={<Settings />} />
|
|
||||||
<Route path="/operational-page" element={<OperationalPage />} />
|
|
||||||
<Route path="/schedule-incident" element={<ScheduleIncident />} />
|
|
||||||
<Route path="/ssl-domain" element={<SslDomain />} />
|
|
||||||
<Route path="/status/:slug" element={<PublicStatusPage />} />
|
|
||||||
<Route path="*" element={<NotFound />} />
|
|
||||||
</Routes>
|
|
||||||
</Router>
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ErrorBoundary>
|
<Routes>
|
||||||
|
{/* Public routes */}
|
||||||
|
<Route path="/public/:slug" element={<PublicStatusPage />} />
|
||||||
|
|
||||||
|
{/* Protected routes */}
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/" element={<ProtectedRoute><Index /></ProtectedRoute>} />
|
||||||
|
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||||
|
<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="/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" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</QueryClientProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,19 +11,19 @@ const api = {
|
|||||||
* Handle API requests
|
* Handle API requests
|
||||||
*/
|
*/
|
||||||
async handleRequest(path, method, body) {
|
async handleRequest(path, method, body) {
|
||||||
console.log(`API request: ${method} ${path}`, body);
|
// console.log(`API request: ${method} ${path}`, body);
|
||||||
|
|
||||||
// Route to the appropriate handler
|
// Route to the appropriate handler
|
||||||
if (path === '/api/realtime') {
|
if (path === '/api/realtime') {
|
||||||
console.log("Routing to realtime handler");
|
// console.log("Routing to realtime handler");
|
||||||
return await realtime(body);
|
return await realtime(body);
|
||||||
} else if (path === '/api/settings' || path.startsWith('/api/settings/')) {
|
} else if (path === '/api/settings' || path.startsWith('/api/settings/')) {
|
||||||
console.log("Routing to settings handler");
|
// console.log("Routing to settings handler");
|
||||||
return await settingsApi(body, path);
|
return await settingsApi(body, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return 404 for unknown routes
|
// Return 404 for unknown routes
|
||||||
console.error(`Endpoint not found: ${path}`);
|
// console.error(`Endpoint not found: ${path}`);
|
||||||
return {
|
return {
|
||||||
status: 404,
|
status: 404,
|
||||||
json: {
|
json: {
|
||||||
@@ -40,7 +40,7 @@ const originalFetch = window.fetch;
|
|||||||
window.fetch = async (url, options = {}) => {
|
window.fetch = async (url, options = {}) => {
|
||||||
// Check if this is an API request to our mock endpoints
|
// Check if this is an API request to our mock endpoints
|
||||||
if (typeof url === 'string' && url.startsWith('/api/')) {
|
if (typeof url === 'string' && url.startsWith('/api/')) {
|
||||||
console.log('Intercepting API request:', url, options);
|
// console.log('Intercepting API request:', url, options);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let body = {};
|
let body = {};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// This file handles realtime notifications in a client-side environment
|
// This file handles realtime notifications in a client-side environment
|
||||||
// In a production app, this would be a server-side endpoint
|
// 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
|
// Simple implementation that simulates sending notifications
|
||||||
export default async function handler(req) {
|
export default async function handler(req) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getAuthHeaders, getBaseUrl, validateEmail } from '../utils';
|
|||||||
import { SettingsApiResponse } from '../types';
|
import { SettingsApiResponse } from '../types';
|
||||||
|
|
||||||
const createEmailTemplate = (template: string, data: any): { subject: string; htmlBody: string } => {
|
const createEmailTemplate = (template: string, data: any): { subject: string; htmlBody: string } => {
|
||||||
let subject = 'Test Email from ReamStack';
|
let subject = 'Test Email from CheckCle';
|
||||||
let htmlBody = `
|
let htmlBody = `
|
||||||
<html>
|
<html>
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
@@ -13,7 +13,7 @@ const createEmailTemplate = (template: string, data: any): { subject: string; ht
|
|||||||
<p>If you received this email, your SMTP configuration is working correctly.</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;">
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
<p style="font-size: 12px; color: #666;">
|
<p style="font-size: 12px; color: #666;">
|
||||||
Sent from ReamStack Monitoring System<br>
|
Sent from CheckCle Monitoring System<br>
|
||||||
Template: ${template}<br>
|
Template: ${template}<br>
|
||||||
${data.collection ? `Collection: ${data.collection}` : ''}
|
${data.collection ? `Collection: ${data.collection}` : ''}
|
||||||
</p>
|
</p>
|
||||||
@@ -24,7 +24,7 @@ const createEmailTemplate = (template: string, data: any): { subject: string; ht
|
|||||||
|
|
||||||
switch (template) {
|
switch (template) {
|
||||||
case 'verification':
|
case 'verification':
|
||||||
subject = 'Email Verification Test - ReamStack';
|
subject = 'Email Verification Test - CheckCle';
|
||||||
htmlBody = `
|
htmlBody = `
|
||||||
<html>
|
<html>
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
@@ -37,14 +37,14 @@ const createEmailTemplate = (template: string, data: any): { subject: string; ht
|
|||||||
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||||
</div>
|
</div>
|
||||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
break;
|
break;
|
||||||
case 'password-reset':
|
case 'password-reset':
|
||||||
subject = 'Password Reset Test - ReamStack';
|
subject = 'Password Reset Test - CheckCle';
|
||||||
htmlBody = `
|
htmlBody = `
|
||||||
<html>
|
<html>
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
@@ -57,14 +57,14 @@ const createEmailTemplate = (template: string, data: any): { subject: string; ht
|
|||||||
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||||
</div>
|
</div>
|
||||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
break;
|
break;
|
||||||
case 'email-change':
|
case 'email-change':
|
||||||
subject = 'Email Change Confirmation Test - ReamStack';
|
subject = 'Email Change Confirmation Test - CheckCle';
|
||||||
htmlBody = `
|
htmlBody = `
|
||||||
<html>
|
<html>
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
@@ -76,7 +76,7 @@ const createEmailTemplate = (template: string, data: any): { subject: string; ht
|
|||||||
<p><strong>Template:</strong> Email Change Confirmation</p>
|
<p><strong>Template:</strong> Email Change Confirmation</p>
|
||||||
</div>
|
</div>
|
||||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -178,10 +178,6 @@ export const sendTestEmail = async (data: any): Promise<SettingsApiResponse> =>
|
|||||||
smtpPort: smtpSettings.port || 587
|
smtpPort: smtpSettings.port || 587
|
||||||
});
|
});
|
||||||
|
|
||||||
// For now, we'll simulate a successful email send
|
|
||||||
// In a real implementation, you would integrate with your email service here
|
|
||||||
// This could be nodemailer, SendGrid, or your PocketBase email system
|
|
||||||
|
|
||||||
// Simulate processing time
|
// Simulate processing time
|
||||||
console.log('Simulating email send...');
|
console.log('Simulating email send...');
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getAuthHeaders, getBaseUrl, validateEmail } from '../utils';
|
|||||||
import { SettingsApiResponse } from '../types';
|
import { SettingsApiResponse } from '../types';
|
||||||
|
|
||||||
const createEmailTemplate = (template: string, data: any): { subject: string; htmlBody: string } => {
|
const createEmailTemplate = (template: string, data: any): { subject: string; htmlBody: string } => {
|
||||||
let subject = 'Test Email from ReamStack';
|
let subject = 'Test Email from CheckCle';
|
||||||
let htmlBody = `
|
let htmlBody = `
|
||||||
<html>
|
<html>
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
@@ -12,7 +12,7 @@ const createEmailTemplate = (template: string, data: any): { subject: string; ht
|
|||||||
<p>If you received this email, your SMTP configuration is working correctly.</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;">
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
<p style="font-size: 12px; color: #666;">
|
<p style="font-size: 12px; color: #666;">
|
||||||
Sent from ReamStack Monitoring System<br>
|
Sent from CheckCle Monitoring System<br>
|
||||||
Template: ${template}<br>
|
Template: ${template}<br>
|
||||||
${data.collection ? `Collection: ${data.collection}` : ''}
|
${data.collection ? `Collection: ${data.collection}` : ''}
|
||||||
</p>
|
</p>
|
||||||
@@ -23,7 +23,7 @@ const createEmailTemplate = (template: string, data: any): { subject: string; ht
|
|||||||
|
|
||||||
switch (template) {
|
switch (template) {
|
||||||
case 'verification':
|
case 'verification':
|
||||||
subject = 'Email Verification Test - ReamStack';
|
subject = 'Email Verification Test - CheckCle';
|
||||||
htmlBody = `
|
htmlBody = `
|
||||||
<html>
|
<html>
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
@@ -36,14 +36,14 @@ const createEmailTemplate = (template: string, data: any): { subject: string; ht
|
|||||||
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||||
</div>
|
</div>
|
||||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
break;
|
break;
|
||||||
case 'password-reset':
|
case 'password-reset':
|
||||||
subject = 'Password Reset Test - ReamStack';
|
subject = 'Password Reset Test - CheckCle';
|
||||||
htmlBody = `
|
htmlBody = `
|
||||||
<html>
|
<html>
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
@@ -56,14 +56,14 @@ const createEmailTemplate = (template: string, data: any): { subject: string; ht
|
|||||||
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||||
</div>
|
</div>
|
||||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
break;
|
break;
|
||||||
case 'email-change':
|
case 'email-change':
|
||||||
subject = 'Email Change Confirmation Test - ReamStack';
|
subject = 'Email Change Confirmation Test - CheckCle';
|
||||||
htmlBody = `
|
htmlBody = `
|
||||||
<html>
|
<html>
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
@@ -75,7 +75,7 @@ const createEmailTemplate = (template: string, data: any): { subject: string; ht
|
|||||||
<p><strong>Template:</strong> Email Change Confirmation</p>
|
<p><strong>Template:</strong> Email Change Confirmation</p>
|
||||||
</div>
|
</div>
|
||||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { testEmail } from './actions/testEmail';
|
|||||||
* Settings API handler
|
* Settings API handler
|
||||||
*/
|
*/
|
||||||
const settingsApi = async (body: any, path?: string) => {
|
const settingsApi = async (body: any, path?: string) => {
|
||||||
console.log('Settings API called with path:', path, 'body:', body);
|
// console.log('Settings API called with path:', path, 'body:', body);
|
||||||
|
|
||||||
// Handle test email endpoint specifically
|
// Handle test email endpoint specifically
|
||||||
if (path === '/api/settings/test/email') {
|
if (path === '/api/settings/test/email') {
|
||||||
@@ -18,7 +18,7 @@ const settingsApi = async (body: any, path?: string) => {
|
|||||||
|
|
||||||
// Handle regular settings API with action-based routing
|
// Handle regular settings API with action-based routing
|
||||||
const action = body?.action;
|
const action = body?.action;
|
||||||
console.log('Settings API called with action:', action, 'data:', body?.data);
|
// console.log('Settings API called with action:', action, 'data:', body?.data);
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'getSettings':
|
case 'getSettings':
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import { StatusCards } from "./StatusCards";
|
|||||||
import { ServiceFilters } from "./ServiceFilters";
|
import { ServiceFilters } from "./ServiceFilters";
|
||||||
import { ServicesTable } from "./ServicesTable";
|
import { ServicesTable } from "./ServicesTable";
|
||||||
import { AddServiceDialog } from "@/components/services/AddServiceDialog";
|
import { AddServiceDialog } from "@/components/services/AddServiceDialog";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
interface DashboardContentProps {
|
interface DashboardContentProps {
|
||||||
services: Service[];
|
services: Service[];
|
||||||
@@ -15,6 +16,7 @@ interface DashboardContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DashboardContent = ({ services, isLoading, error }: DashboardContentProps) => {
|
export const DashboardContent = ({ services, isLoading, error }: DashboardContentProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const [filter, setFilter] = useState<string>("all");
|
const [filter, setFilter] = useState<string>("all");
|
||||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState<boolean>(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState<boolean>(false);
|
||||||
@@ -31,7 +33,7 @@ export const DashboardContent = ({ services, isLoading, error }: DashboardConten
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-foreground">
|
<div className="flex flex-col items-center justify-center h-full gap-4 text-foreground">
|
||||||
<p>Error loading service data.</p>
|
<p>Error loading service data.</p>
|
||||||
<Button onClick={() => window.location.reload()}>Retry</Button>
|
<Button onClick={() => window.location.reload()}>{t('retry')}</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -40,12 +42,12 @@ export const DashboardContent = ({ services, isLoading, error }: DashboardConten
|
|||||||
<main className="flex-1 flex flex-col overflow-auto bg-background p-6 pb-0">
|
<main className="flex-1 flex flex-col overflow-auto bg-background p-6 pb-0">
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h2 className="text-2xl font-bold text-foreground">Overview</h2>
|
<h2 className="text-2xl font-bold text-foreground">{t('overview')}</h2>
|
||||||
<Button
|
<Button
|
||||||
className="text-primary-foreground"
|
className="text-primary-foreground"
|
||||||
onClick={() => setIsAddDialogOpen(true)}
|
onClick={() => setIsAddDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" /> New Service
|
<Plus className="w-4 h-4 mr-2" /> {t('newService')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,78 +1,59 @@
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AuthUser } from "@/services/authService";
|
import { AuthUser } from "@/services/authService";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
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, Menu, X
|
||||||
|
} from "lucide-react";
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
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 { useEffect, useState } from "react";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||||
import QuickActionsDialog from "./QuickActionsDialog";
|
import { useSidebar } from "@/contexts/SidebarContext";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
currentUser: AuthUser | null;
|
currentUser: AuthUser | null;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
sidebarCollapsed: boolean;
|
|
||||||
toggleSidebar: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Header = ({
|
export const Header = ({
|
||||||
currentUser,
|
currentUser,
|
||||||
onLogout,
|
onLogout,
|
||||||
sidebarCollapsed,
|
|
||||||
toggleSidebar
|
|
||||||
}: HeaderProps) => {
|
}: HeaderProps) => {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const { language, setLanguage, t } = useLanguage();
|
const { language, setLanguage, t } = useLanguage();
|
||||||
|
const { sidebarCollapsed, toggleSidebar, isMobileOpen, toggleMobileMenu } = useSidebar();
|
||||||
const [greeting, setGreeting] = useState<string>("");
|
const [greeting, setGreeting] = useState<string>("");
|
||||||
const { systemName } = useSystemSettings();
|
const { systemName } = useSystemSettings();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [quickActionsOpen, setQuickActionsOpen] = useState(false);
|
|
||||||
|
|
||||||
// Set greeting based on time of day
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateGreeting = () => {
|
const updateGreeting = () => {
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
if (hour >= 5 && hour < 12) {
|
if (hour >= 5 && hour < 12) setGreeting(t("goodMorning"));
|
||||||
setGreeting(t("goodMorning"));
|
else if (hour >= 12 && hour < 18) setGreeting(t("goodAfternoon"));
|
||||||
} else if (hour >= 12 && hour < 18) {
|
else setGreeting(t("goodEvening"));
|
||||||
setGreeting(t("goodAfternoon"));
|
|
||||||
} else {
|
|
||||||
setGreeting(t("goodEvening"));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
updateGreeting();
|
updateGreeting();
|
||||||
// Update greeting if language changes
|
|
||||||
|
|
||||||
// You could add a timer to update the greeting throughout the day
|
|
||||||
// but that's typically unnecessary since most sessions aren't active across time periods
|
|
||||||
}, [language, t]);
|
}, [language, t]);
|
||||||
|
|
||||||
// Log avatar data for debugging
|
const avatarUrl = currentUser?.avatar || '';
|
||||||
useEffect(() => {
|
|
||||||
if (currentUser) {
|
|
||||||
console.log("Avatar URL in Header:", currentUser.avatar);
|
|
||||||
}
|
|
||||||
}, [currentUser]);
|
|
||||||
|
|
||||||
// Prepare avatar URL - ensure it displays correctly if it's a local profile image
|
const openExternalLink = (url: string) => {
|
||||||
let avatarUrl = '';
|
window.open(url, '_blank');
|
||||||
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/')) {
|
|
||||||
avatarUrl = currentUser.avatar;
|
|
||||||
} else {
|
|
||||||
avatarUrl = currentUser.avatar;
|
|
||||||
}
|
|
||||||
console.log("Final avatar URL:", avatarUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="relative bg-background border-b border-border px-6 flex justify-between items-center py-[12px] overflow-hidden">
|
<header className="relative bg-background border-b border-border px-4 lg:px-6 flex justify-between items-center h-16 shrink-0 z-30 overflow-hidden">
|
||||||
{/* Grid Pattern Overlay - Similar to StatusCards */}
|
{/* Grid Pattern Overlay */}
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0 pointer-events-none opacity-20">
|
||||||
<div
|
<div
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
style={{
|
style={{
|
||||||
@@ -85,29 +66,74 @@ export const Header = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header Content */}
|
<div className="flex items-center gap-2 lg:gap-4 z-10">
|
||||||
<div className="flex items-center gap-4 z-10">
|
{/* Mobile Toggle */}
|
||||||
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2">
|
<Button variant="ghost" size="icon" onClick={toggleMobileMenu} className="lg:hidden h-9 w-9">
|
||||||
|
{isMobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Desktop Toggle */}
|
||||||
|
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="hidden lg:flex h-9 w-9">
|
||||||
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
|
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Quick Actions Button */}
|
<div className="flex items-center">
|
||||||
<Button
|
<h1 className="text-sm lg:text-lg font-medium truncate max-w-[150px] lg:max-w-none">
|
||||||
variant="ghost"
|
{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 ✨
|
||||||
size="icon"
|
</h1>
|
||||||
onClick={() => setQuickActionsOpen(true)}
|
|
||||||
className="mr-2"
|
|
||||||
>
|
|
||||||
<Grid3x3 className="h-5 w-5 text-green-500" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<h1 className="text-lg font-medium">{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 ✨</h1>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4 z-10">
|
<div className="flex items-center space-x-1 lg:space-x-3 z-10">
|
||||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border" onClick={toggleTheme}>
|
{/* External Links - Hidden on small screens */}
|
||||||
|
<div className="hidden md:flex items-center space-x-1 mr-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
onClick={() => openExternalLink('https://docs.checkcle.io')}
|
||||||
|
title={t("documentation")}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
onClick={() => openExternalLink('https://github.com/operacle/checkcle')}
|
||||||
|
title="GitHub"
|
||||||
|
>
|
||||||
|
<Github className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
onClick={() => openExternalLink('https://x.com/checkcle_oss')}
|
||||||
|
title="X (Twitter)"
|
||||||
|
>
|
||||||
|
<Twitter className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
onClick={() => openExternalLink('https://discord.gg/xs9gbubGwX')}
|
||||||
|
title="Discord"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
title={t("notifications")}
|
||||||
|
>
|
||||||
|
<Bell className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border hidden sm:flex" onClick={toggleTheme}>
|
||||||
<span className="sr-only">Toggle theme</span>
|
<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>
|
</Button>
|
||||||
@@ -119,7 +145,7 @@ export const Header = ({
|
|||||||
<Globe className="w-4 h-4" />
|
<Globe className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
<DropdownMenuItem onClick={() => setLanguage("en")} className={language === "en" ? "bg-accent" : ""}>
|
<DropdownMenuItem onClick={() => setLanguage("en")} className={language === "en" ? "bg-accent" : ""}>
|
||||||
{t("english")}
|
{t("english")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -127,79 +153,35 @@ export const Header = ({
|
|||||||
{t("khmer")}
|
{t("khmer")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setLanguage("de")} className={language === "de" ? "bg-accent" : ""}>
|
<DropdownMenuItem onClick={() => setLanguage("de")} className={language === "de" ? "bg-accent" : ""}>
|
||||||
{t("Deutsch")}
|
{t("german")}
|
||||||
|
</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>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Documentation */}
|
<div className="h-8 w-px bg-border mx-1 hidden sm:block" />
|
||||||
<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"
|
|
||||||
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"
|
|
||||||
onClick={() => window.open("https://x.com/tlengoss", "_blank")}
|
|
||||||
>
|
|
||||||
<span className="sr-only">X (Twitter)</span>
|
|
||||||
<Twitter className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 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">
|
|
||||||
<span className="sr-only">{t("notifications")}</span>
|
|
||||||
<Bell className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* User Profile Dropdown */}
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Avatar className="h-8 w-8 cursor-pointer ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground">
|
<Avatar className="h-8 w-8 cursor-pointer border hover:ring-2 hover:ring-primary/20 transition-all">
|
||||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} /> : <AvatarFallback className="bg-primary/20 text-primary">
|
{avatarUrl ? <AvatarImage src={avatarUrl} alt="User" /> : <AvatarFallback className="bg-primary/20 text-primary">{currentUser?.name?.[0] || 'U'}</AvatarFallback>}
|
||||||
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
|
|
||||||
</AvatarFallback>}
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<div className="flex items-center gap-3 p-2">
|
<div className="flex items-center gap-3 p-2">
|
||||||
<Avatar className="h-10 w-10">
|
<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">
|
{avatarUrl ? <AvatarImage src={avatarUrl} alt="User" /> : <AvatarFallback className="bg-primary/20 text-primary">{currentUser?.name?.[0] || 'U'}</AvatarFallback>}
|
||||||
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
|
|
||||||
</AvatarFallback>}
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-0.5">
|
||||||
<span className="font-medium">{currentUser?.name || 'User'}</span>
|
<span className="text-sm font-medium truncate">{currentUser?.name || 'User'}</span>
|
||||||
<span className="text-xs text-muted-foreground truncate">{currentUser?.email}</span>
|
<span className="text-xs text-muted-foreground truncate">{currentUser?.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,9 +202,6 @@ export const Header = ({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions Dialog */}
|
|
||||||
<QuickActionsDialog isOpen={quickActionsOpen} setIsOpen={setQuickActionsOpen} />
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogClose
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
|
||||||
|
|
||||||
interface QuickActionsDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
setIsOpen: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const QuickActionsDialog: React.FC<QuickActionsDialogProps> = ({ isOpen, setIsOpen }) => {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogContent className="sm:max-w-[800px] bg-background max-h-[90vh] overflow-y-auto">
|
|
||||||
<DialogClose className="absolute right-4 top-4">
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogClose>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold">{t('quickActions')}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t('quickActionsDescription')}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
|
||||||
{/* Website Monitoring Card */}
|
|
||||||
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
|
||||||
<h3 className="font-semibold text-lg mb-2">{t('monitorWebsite')}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
{t('monitorWebsiteDesc')}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
|
||||||
<ul className="list-disc pl-5 text-sm space-y-1">
|
|
||||||
<li>{t('monitorMultipleEndpoints')}</li>
|
|
||||||
<li>{t('trackResponseTimes')}</li>
|
|
||||||
<li>{t('setCustomAlerts')}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Server Monitoring Card */}
|
|
||||||
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
|
||||||
<h3 className="font-semibold text-lg mb-2">{t('monitorServer')}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
{t('monitorServerDesc')}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
|
||||||
<ul className="list-disc pl-5 text-sm space-y-1">
|
|
||||||
<li>{t('monitorCPURam')}</li>
|
|
||||||
<li>{t('trackNetworkMetrics')}</li>
|
|
||||||
<li>{t('viewProcesses')}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SSL Certificate Card */}
|
|
||||||
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
|
||||||
<h3 className="font-semibold text-lg mb-2">{t('sslCertificate')}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
{t('sslCertificateDesc')}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
|
||||||
<ul className="list-disc pl-5 text-sm space-y-1">
|
|
||||||
<li>{t('trackSSLExpiration')}</li>
|
|
||||||
<li>{t('getEarlyRenewal')}</li>
|
|
||||||
<li>{t('monitorMultipleDomains')}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Incidents Management Card */}
|
|
||||||
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
|
||||||
<h3 className="font-semibold text-lg mb-2">{t('incidentsManagement')}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
{t('incidentsManagementDesc')}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
|
||||||
<ul className="list-disc pl-5 text-sm space-y-1">
|
|
||||||
<li>{t('createTrackIncidents')}</li>
|
|
||||||
<li>{t('assignIncidents')}</li>
|
|
||||||
<li>{t('monitorResolution')}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Operational Status Card */}
|
|
||||||
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
|
||||||
<h3 className="font-semibold text-lg mb-2">{t('operationalStatus')}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
{t('operationalStatusDesc')}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
|
||||||
<ul className="list-disc pl-5 text-sm space-y-1">
|
|
||||||
<li>{t('createCustomStatus')}</li>
|
|
||||||
<li>{t('displayRealTime')}</li>
|
|
||||||
<li>{t('shareIncidentUpdates')}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Reports & Analytics Card */}
|
|
||||||
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
|
||||||
<h3 className="font-semibold text-lg mb-2">{t('reportsAnalytics')}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
{t('reportsAnalyticsDesc')}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
|
||||||
<ul className="list-disc pl-5 text-sm space-y-1">
|
|
||||||
<li>{t('generateUptimeReports')}</li>
|
|
||||||
<li>{t('analyzePerformance')}</li>
|
|
||||||
<li>{t('exportMonitoringData')}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end mt-6">
|
|
||||||
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
|
||||||
{t('close')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default QuickActionsDialog;
|
|
||||||
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
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 { Plus } from "lucide-react";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
interface ServiceFiltersProps {
|
interface ServiceFiltersProps {
|
||||||
filter: string;
|
filter: string;
|
||||||
@@ -19,10 +20,11 @@ export const ServiceFilters = ({
|
|||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
servicesCount
|
servicesCount
|
||||||
}: ServiceFiltersProps) => {
|
}: ServiceFiltersProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 flex justify-between items-center">
|
<div className="mb-6 flex justify-between items-center">
|
||||||
<div className="flex 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">
|
<span className="bg-secondary text-secondary-foreground px-2 py-0.5 rounded text-sm">
|
||||||
{servicesCount}
|
{servicesCount}
|
||||||
</span>
|
</span>
|
||||||
@@ -30,10 +32,10 @@ export const ServiceFilters = ({
|
|||||||
<div className="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
<Select value={filter} onValueChange={setFilter}>
|
<Select value={filter} onValueChange={setFilter}>
|
||||||
<SelectTrigger className="w-40 bg-card border-border">
|
<SelectTrigger className="w-40 bg-card border-border">
|
||||||
<SelectValue placeholder="All Types" />
|
<SelectValue placeholder={t('allTypes')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Types</SelectItem>
|
<SelectItem value="all">{t('allTypes')}</SelectItem>
|
||||||
<SelectItem value="HTTP">HTTP</SelectItem>
|
<SelectItem value="HTTP">HTTP</SelectItem>
|
||||||
<SelectItem value="PING">PING</SelectItem>
|
<SelectItem value="PING">PING</SelectItem>
|
||||||
<SelectItem value="TCP">TCP</SelectItem>
|
<SelectItem value="TCP">TCP</SelectItem>
|
||||||
@@ -43,7 +45,7 @@ export const ServiceFilters = ({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
className="w-72 bg-card border-border"
|
className="w-72 bg-card border-border"
|
||||||
placeholder="Search"
|
placeholder={t('search')}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,22 +1,44 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { SidebarHeader } from "./sidebar/SidebarHeader";
|
import { SidebarHeader } from "./sidebar/SidebarHeader";
|
||||||
import { MainNavigation } from "./sidebar/MainNavigation";
|
import { MainNavigation } from "./sidebar/MainNavigation";
|
||||||
import { SettingsPanel } from "./sidebar/SettingsPanel";
|
import { SettingsPanel } from "./sidebar/SettingsPanel";
|
||||||
|
import { useSidebar } from "@/contexts/SidebarContext";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface SidebarProps {
|
export const Sidebar = () => {
|
||||||
collapsed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Sidebar = ({ collapsed }: SidebarProps) => {
|
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const { sidebarCollapsed, isMobileOpen, toggleMobileMenu } = useSidebar();
|
||||||
|
|
||||||
return (
|
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`}>
|
<>
|
||||||
<SidebarHeader collapsed={collapsed} />
|
{/* Mobile Overlay */}
|
||||||
<MainNavigation collapsed={collapsed} />
|
{isMobileOpen && (
|
||||||
<SettingsPanel collapsed={collapsed} />
|
<div
|
||||||
</div>
|
className="fixed inset-0 bg-black/60 z-40 lg:hidden backdrop-blur-sm transition-opacity animate-in fade-in"
|
||||||
|
onClick={toggleMobileMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar Container */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 left-0 z-50 lg:static lg:block transition-all duration-300 ease-in-out border-r flex flex-col h-full",
|
||||||
|
theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border',
|
||||||
|
// Mobile state
|
||||||
|
isMobileOpen ? "translate-x-0 w-64" : "-translate-x-full lg:translate-x-0",
|
||||||
|
// Desktop state (collapsed/expanded)
|
||||||
|
!isMobileOpen && sidebarCollapsed ? "lg:w-16" : "lg:w-64"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SidebarHeader collapsed={!isMobileOpen && sidebarCollapsed} />
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar">
|
||||||
|
<MainNavigation collapsed={!isMobileOpen && sidebarCollapsed} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsPanel collapsed={!isMobileOpen && sidebarCollapsed} />
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -3,12 +3,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { ArrowUp, ArrowDown, Pause, AlertTriangle } from "lucide-react";
|
import { ArrowUp, ArrowDown, Pause, AlertTriangle } from "lucide-react";
|
||||||
import { Service } from "@/services/serviceService";
|
import { Service } from "@/services/serviceService";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import {useLanguage} from "@/contexts/LanguageContext.tsx";
|
||||||
|
|
||||||
interface StatusCardsProps {
|
interface StatusCardsProps {
|
||||||
services: Service[];
|
services: Service[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StatusCards = ({ services }: StatusCardsProps) => {
|
export const StatusCards = ({ services }: StatusCardsProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
// Count services by status
|
// Count services by status
|
||||||
const upServices = services.filter(s => s.status === "up").length;
|
const upServices = services.filter(s => s.status === "up").length;
|
||||||
const downServices = services.filter(s => s.status === "down").length;
|
const downServices = services.filter(s => s.status === "down").length;
|
||||||
@@ -27,22 +30,22 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
|
|||||||
} relative z-10`}
|
} relative z-10`}
|
||||||
style={{
|
style={{
|
||||||
background: theme === 'dark'
|
background: theme === 'dark'
|
||||||
? "linear-gradient(135deg, rgba(67, 160, 71, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)"
|
? "linear-gradient(135deg, rgba(65, 59, 55, 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%, #66bb6a 100%)"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Grid Pattern Overlay */}
|
{/* Grid Pattern Overlay */}
|
||||||
<div className="absolute inset-0 z-0 opacity-10">
|
<div className="absolute inset-0 z-0 opacity-10">
|
||||||
<div className="w-full h-full"
|
<div className="w-full h-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
|
backgroundImage: `linear-gradient(#000 1px, transparent 1px),
|
||||||
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||||
backgroundSize: '20px 20px'
|
backgroundSize: '20px 20px'
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="pb-2 relative z-10">
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="flex items-center justify-between relative z-10">
|
<CardContent className="flex items-center justify-between relative z-10">
|
||||||
<span className="text-5xl font-bold text-white">{upServices}</span>
|
<span className="text-5xl font-bold text-white">{upServices}</span>
|
||||||
@@ -59,22 +62,22 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
|
|||||||
} relative z-10`}
|
} relative z-10`}
|
||||||
style={{
|
style={{
|
||||||
background: theme === 'dark'
|
background: theme === 'dark'
|
||||||
? "linear-gradient(135deg, rgba(229, 57, 53, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)"
|
? "linear-gradient(135deg, rgba(65, 59, 55, 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%, #ef5350 100%)"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Grid Pattern Overlay */}
|
{/* Grid Pattern Overlay */}
|
||||||
<div className="absolute inset-0 z-0 opacity-10">
|
<div className="absolute inset-0 z-0 opacity-10">
|
||||||
<div className="w-full h-full"
|
<div className="w-full h-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
|
backgroundImage: `linear-gradient(#000 1px, transparent 1px),
|
||||||
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||||
backgroundSize: '20px 20px'
|
backgroundSize: '20px 20px'
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="pb-2 relative z-10">
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="flex items-center justify-between relative z-10">
|
<CardContent className="flex items-center justify-between relative z-10">
|
||||||
<span className="text-5xl font-bold text-white">{downServices}</span>
|
<span className="text-5xl font-bold text-white">{downServices}</span>
|
||||||
@@ -91,22 +94,22 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
|
|||||||
} relative z-10`}
|
} relative z-10`}
|
||||||
style={{
|
style={{
|
||||||
background: theme === 'dark'
|
background: theme === 'dark'
|
||||||
? "linear-gradient(135deg, rgba(25, 118, 210, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
|
? "linear-gradient(135deg, rgba(65, 59, 55, 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%, #42a5f5 100%)"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Grid Pattern Overlay */}
|
{/* Grid Pattern Overlay */}
|
||||||
<div className="absolute inset-0 z-0 opacity-10">
|
<div className="absolute inset-0 z-0 opacity-10">
|
||||||
<div className="w-full h-full"
|
<div className="w-full h-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
|
backgroundImage: `linear-gradient(#000 1px, transparent 1px),
|
||||||
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||||
backgroundSize: '20px 20px'
|
backgroundSize: '20px 20px'
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="pb-2 relative z-10">
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="flex items-center justify-between relative z-10">
|
<CardContent className="flex items-center justify-between relative z-10">
|
||||||
<span className="text-5xl font-bold text-white">{pausedServices}</span>
|
<span className="text-5xl font-bold text-white">{pausedServices}</span>
|
||||||
@@ -123,22 +126,22 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
|
|||||||
} relative z-10`}
|
} relative z-10`}
|
||||||
style={{
|
style={{
|
||||||
background: theme === 'dark'
|
background: theme === 'dark'
|
||||||
? "linear-gradient(135deg, rgba(255, 152, 0, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)"
|
? "linear-gradient(135deg, rgba(65, 59, 55, 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%, #ffb74d 100%)"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Grid Pattern Overlay */}
|
{/* Grid Pattern Overlay */}
|
||||||
<div className="absolute inset-0 z-0 opacity-10">
|
<div className="absolute inset-0 z-0 opacity-10">
|
||||||
<div className="w-full h-full"
|
<div className="w-full h-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
|
backgroundImage: `linear-gradient(#000 1px, transparent 1px),
|
||||||
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||||
backgroundSize: '20px 20px'
|
backgroundSize: '20px 20px'
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="pb-2 relative z-10">
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="flex items-center justify-between relative z-10">
|
<CardContent className="flex items-center justify-between relative z-10">
|
||||||
<span className="text-5xl font-bold text-white">{warningServices}</span>
|
<span className="text-5xl font-bold text-white">{warningServices}</span>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
import { LucideIcon } from "lucide-react";
|
import { LucideIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface MenuItemProps {
|
interface MenuItemProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,7 +16,6 @@ interface MenuItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MenuItem: React.FC<MenuItemProps> = ({
|
export const MenuItem: React.FC<MenuItemProps> = ({
|
||||||
id,
|
|
||||||
path,
|
path,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
translationKey,
|
translationKey,
|
||||||
@@ -29,26 +28,39 @@ export const MenuItem: React.FC<MenuItemProps> = ({
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const isActive = path && location.pathname === path;
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (hasNavigation && path) {
|
if (hasNavigation && path) {
|
||||||
// Use navigate instead of window.location to prevent full page reload
|
navigate(path);
|
||||||
navigate(path, { replace: false });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isActive = path && location.pathname === path;
|
|
||||||
const mainIconSize = "h-6 w-6";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${isActive ? 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 cursor-pointer`}
|
className={cn(
|
||||||
|
"group relative flex items-center mb-1 rounded-lg cursor-pointer transition-all duration-200",
|
||||||
|
collapsed ? "p-3 justify-center" : "p-2.5 px-4",
|
||||||
|
isActive
|
||||||
|
? (theme === 'dark' ? 'bg-zinc-800 text-white' : 'bg-primary/10 text-primary')
|
||||||
|
: (theme === 'dark' ? 'text-zinc-400 hover:bg-zinc-800/50 hover:text-white' : 'text-zinc-600 hover:bg-zinc-100')
|
||||||
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
title={collapsed ? t(translationKey) : ""}
|
||||||
>
|
>
|
||||||
<Icon className={`${mainIconSize} ${color}`} />
|
<Icon className={cn("h-5 w-5 shrink-0 transition-colors", color, isActive ? "opacity-100" : "opacity-80 group-hover:opacity-100")} />
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t(translationKey)}</span>}
|
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="ml-3 font-medium tracking-wide text-sm truncate">
|
||||||
|
{t(translationKey)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Small Indicator for active state */}
|
||||||
|
{isActive && !collapsed && (
|
||||||
|
<div className="absolute left-0 w-1 h-6 bg-primary rounded-r-full" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -11,8 +11,12 @@ export const SidebarHeader: React.FC<SidebarHeaderProps> = ({ collapsed }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
|
<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">
|
<div className="h-8 w-8 bg-gray-600 rounded flex items-center justify-center mr-2">
|
||||||
<span className="text-white font-bold">C</span>
|
<img
|
||||||
|
src="/favicon_sidebar.ico"
|
||||||
|
alt="CheckCle"
|
||||||
|
className="h-6 w-6"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
|
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { Globe, Boxes, Radar, Calendar, BarChart2, LineChart, FileText, Settings, User, Bell, Database, Info, BookOpen } from "lucide-react";
|
import { Globe, Boxes, Layers, Calendar, BarChart2, LineChart, MapPin, Settings, User, Bell, Database, Info, BookOpen } from "lucide-react";
|
||||||
|
|
||||||
export const mainMenuItems = [
|
export const mainMenuItems = [
|
||||||
{
|
{
|
||||||
@@ -12,16 +12,16 @@ export const mainMenuItems = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'instance-monitoring',
|
id: 'instance-monitoring',
|
||||||
path: null,
|
path: '/instance-monitoring',
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
translationKey: 'instanceMonitoring',
|
translationKey: 'instanceMonitoring',
|
||||||
color: 'text-blue-400',
|
color: 'text-blue-400',
|
||||||
hasNavigation: false
|
hasNavigation: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ssl-domain',
|
id: 'ssl-domain',
|
||||||
path: '/ssl-domain',
|
path: '/ssl-domain',
|
||||||
icon: Radar,
|
icon: Layers,
|
||||||
translationKey: 'sslDomain',
|
translationKey: 'sslDomain',
|
||||||
color: 'text-cyan-400',
|
color: 'text-cyan-400',
|
||||||
hasNavigation: true
|
hasNavigation: true
|
||||||
@@ -42,6 +42,14 @@ export const mainMenuItems = [
|
|||||||
color: 'text-amber-400',
|
color: 'text-amber-400',
|
||||||
hasNavigation: true
|
hasNavigation: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'regional-monitoring',
|
||||||
|
path: '/regional-monitoring',
|
||||||
|
icon: MapPin,
|
||||||
|
translationKey: 'regionalMonitoring',
|
||||||
|
color: 'text-indigo-400',
|
||||||
|
hasNavigation: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'reports',
|
id: 'reports',
|
||||||
path: null,
|
path: null,
|
||||||
@@ -49,14 +57,6 @@ export const mainMenuItems = [
|
|||||||
translationKey: 'reports',
|
translationKey: 'reports',
|
||||||
color: 'text-rose-400',
|
color: 'text-rose-400',
|
||||||
hasNavigation: false
|
hasNavigation: false
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'api-documentation',
|
|
||||||
path: null,
|
|
||||||
icon: FileText,
|
|
||||||
translationKey: 'apiDocumentation',
|
|
||||||
color: 'text-indigo-400',
|
|
||||||
hasNavigation: false
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
|
||||||
|
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 { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
import {
|
||||||
|
DockerTableSearch,
|
||||||
|
DockerTableHeader,
|
||||||
|
DockerTableRow,
|
||||||
|
DockerEmptyState
|
||||||
|
} from "./table";
|
||||||
|
|
||||||
|
interface DockerContainersTableProps {
|
||||||
|
containers: DockerContainer[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockerContainersTable = ({ containers, isLoading, onRefresh }: DockerContainersTableProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
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">{t('dockerContainers', 'docker')}</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,697 @@
|
|||||||
|
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";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
|
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: 'minutes60', hours: 1 },
|
||||||
|
{ value: '1d' as TimeRange, label: 'day1', hours: 24 },
|
||||||
|
{ value: '7d' as TimeRange, label: 'days7', hours: 24 * 7 },
|
||||||
|
{ value: '1m' as TimeRange, label: 'month1', hours: 24 * 30 },
|
||||||
|
{ value: '3m' as TimeRange, label: 'months3', hours: 24 * 90 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMetricsDialogProps) => {
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
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>
|
||||||
|
{t('containerMetricsTitle', 'docker', { name: 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}>
|
||||||
|
{t(option.label, 'docker')}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('dockerId', 'docker')}: {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">{t('loadingMetrics', 'docker')}</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center h-96 text-muted-foreground">
|
||||||
|
<p>{t('errorLoadingMetrics', 'docker')}: {String((error as any)?.message ?? '')}</p>
|
||||||
|
</div>
|
||||||
|
) : chartData.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-96 text-muted-foreground">
|
||||||
|
<p>{t('noMetricsAvailable', 'docker')}</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={t('cpu', 'docker')}
|
||||||
|
used={`${latestMetric.cpuUsage}%`}
|
||||||
|
total={`${latestMetric.cpuCores} cores`}
|
||||||
|
free={`${(100 - latestMetric.cpuUsage).toFixed(1)}%`}
|
||||||
|
percentage={latestMetric.cpuUsage}
|
||||||
|
icon={Cpu}
|
||||||
|
color={chartConfig.cpuUsage.color}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title={t('memoryTitle', 'docker')}
|
||||||
|
used={latestMetric.ramUsed}
|
||||||
|
total={latestMetric.ramTotal}
|
||||||
|
free={latestMetric.ramFree}
|
||||||
|
percentage={latestMetric.ramUsagePercent}
|
||||||
|
icon={MemoryStick}
|
||||||
|
color={chartConfig.ramUsagePercent.color}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title={t('diskTitle', 'docker')}
|
||||||
|
used={latestMetric.diskUsed}
|
||||||
|
total={latestMetric.diskTotal}
|
||||||
|
free={latestMetric.diskFree}
|
||||||
|
percentage={latestMetric.diskUsagePercent}
|
||||||
|
icon={HardDrive}
|
||||||
|
color={chartConfig.diskUsagePercent.color}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title={t('network', 'docker')}
|
||||||
|
used={`RX: ${latestMetric.networkRx}`}
|
||||||
|
total={`TX: ${latestMetric.networkTx}`}
|
||||||
|
free={`${t('networkSpeedKbs', 'docker').replace('(KB/s)', '')}: ${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" />
|
||||||
|
{t('cpu', 'docker')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="memory" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||||
|
<MemoryStick className="h-4 w-4" />
|
||||||
|
{t('memoryTitle', 'docker')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="disk" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||||
|
<HardDrive className="h-4 w-4" />
|
||||||
|
{t('diskTitle', 'docker')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="network" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||||
|
<Network className="h-4 w-4" />
|
||||||
|
{t('network', 'docker')}
|
||||||
|
</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">{t('cpuUsagePct', 'docker')}</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={t('cpuUsagePct', 'docker')}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground">{t('cpuUsageVsAvailable', 'docker')}</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={t('cpuAvailablePct', 'docker')}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cpuUsage"
|
||||||
|
stackId="1"
|
||||||
|
stroke={chartConfig.cpuUsage.color}
|
||||||
|
fill={chartConfig.cpuUsage.color}
|
||||||
|
fillOpacity={0.6}
|
||||||
|
name={t('cpuUsagePct', 'docker')}
|
||||||
|
/>
|
||||||
|
</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">{t('ramUsagePct', 'docker')}</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">{t('memoryUsageBytes', 'docker')}</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 === t('usedMemory', 'docker') ? formatBytes(Number(value)) :
|
||||||
|
name === t('totalMemory', 'docker') ? 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={t('totalMemory', 'docker')}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="ramUsedBytes"
|
||||||
|
stackId="1"
|
||||||
|
stroke={chartConfig.ramUsagePercent.color}
|
||||||
|
fill={chartConfig.ramUsagePercent.color}
|
||||||
|
fillOpacity={0.6}
|
||||||
|
name={t('usedMemory', 'docker')}
|
||||||
|
/>
|
||||||
|
</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">{t('diskUsagePct', 'docker')}</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={t('diskUsagePct', 'docker')}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground">{t('diskUsageBytes', 'docker')}</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 === t('usedDisk', 'docker') ? formatBytes(Number(value)) :
|
||||||
|
name === t('totalDisk', 'docker') ? 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={t('totalDisk', 'docker')}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="diskUsedBytes"
|
||||||
|
stackId="1"
|
||||||
|
stroke={chartConfig.diskUsagePercent.color}
|
||||||
|
fill={chartConfig.diskUsagePercent.color}
|
||||||
|
fillOpacity={0.6}
|
||||||
|
name={t('usedDisk', 'docker')}
|
||||||
|
/>
|
||||||
|
</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">{t('networkTraffic', 'docker')}</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={t('rxBytes', 'docker')}
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="networkTxBytes"
|
||||||
|
stroke={chartConfig.networkTx.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
name={t('txBytes', 'docker')}
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground">{t('networkSpeedKbs', 'docker')}</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={t('rxSpeedKbs', 'docker')}
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="networkTxSpeed"
|
||||||
|
stroke={chartConfig.networkTx.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
name={t('txSpeedKbs', 'docker')}
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
|
||||||
|
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";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
|
interface DockerStatsCardsProps {
|
||||||
|
stats: DockerStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
title: t('totalContainers', 'docker'),
|
||||||
|
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: t('running', 'docker'),
|
||||||
|
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: t('stopped', 'docker'),
|
||||||
|
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: t('warning', 'docker'),
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{t('containersLabel', 'docker')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
|
interface DockerStatusBadgeProps {
|
||||||
|
status: 'running' | 'stopped' | 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockerStatusBadge = ({ status }: DockerStatusBadgeProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
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: t('running', 'docker')
|
||||||
|
};
|
||||||
|
case 'stopped':
|
||||||
|
return {
|
||||||
|
variant: 'secondary' as const,
|
||||||
|
className: 'bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-200',
|
||||||
|
label: t('stopped', 'docker')
|
||||||
|
};
|
||||||
|
case 'warning':
|
||||||
|
return {
|
||||||
|
variant: 'destructive' as const,
|
||||||
|
className: 'bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-200',
|
||||||
|
label: t('warning', 'docker')
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
variant: 'outline' as const,
|
||||||
|
className: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||||
|
label: t('unknown', 'docker')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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,25 @@
|
|||||||
|
|
||||||
|
import { TableCell, TableRow } from "@/components/ui/table";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
|
interface DockerEmptyStateProps {
|
||||||
|
searchTerm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockerEmptyState = ({ searchTerm }: DockerEmptyStateProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
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 ? t('noContainersFound', 'docker') : t('noContainersRunning', 'docker')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{searchTerm ? t('tryAdjustSearch', 'docker') : t('startSomeContainers', 'docker')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
|
||||||
|
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";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0 hover:bg-muted">
|
||||||
|
<span className="sr-only">{t('openMenu', 'docker')}</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" />
|
||||||
|
{t('viewMetrics', 'docker')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onContainerAction('view-detail', container.id, container.name)}
|
||||||
|
className="cursor-pointer hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
{t('viewDetails', 'docker')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
import { TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
|
export const DockerTableHeader = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
return (
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-border bg-muted/30">
|
||||||
|
<TableHead className="min-w-[200px] font-semibold">{t('container', 'docker')}</TableHead>
|
||||||
|
<TableHead className="min-w-[100px] font-semibold">{t('status', 'docker')}</TableHead>
|
||||||
|
<TableHead className="min-w-[140px] font-semibold">{t('cpuUsage', 'docker')}</TableHead>
|
||||||
|
<TableHead className="min-w-[160px] font-semibold">{t('memory', 'docker')}</TableHead>
|
||||||
|
<TableHead className="min-w-[160px] font-semibold">{t('disk', 'docker')}</TableHead>
|
||||||
|
<TableHead className="min-w-[100px] font-semibold">{t('uptime', 'docker')}</TableHead>
|
||||||
|
<TableHead className="min-w-[160px] font-semibold">{t('lastChecked', 'docker')}</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-center font-semibold">{t('actions', 'docker')}</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,39 @@
|
|||||||
|
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Search, RefreshCw } from "lucide-react";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
|
interface DockerTableSearchProps {
|
||||||
|
searchTerm: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockerTableSearch = ({ searchTerm, onSearchChange, onRefresh, isLoading }: DockerTableSearchProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
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={t('searchContainersPlaceholder', 'docker')}
|
||||||
|
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">{t('refresh', 'docker')}</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';
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -6,10 +7,11 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Plus, X, Server, Shield, AlertTriangle } from 'lucide-react';
|
import { Plus, X, Server } from 'lucide-react';
|
||||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { serviceService } from '@/services/serviceService';
|
import { serviceService } from '@/services/serviceService';
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
interface ComponentsSelectorProps {
|
interface ComponentsSelectorProps {
|
||||||
selectedComponents: Partial<StatusPageComponentRecord>[];
|
selectedComponents: Partial<StatusPageComponentRecord>[];
|
||||||
@@ -17,13 +19,8 @@ interface ComponentsSelectorProps {
|
|||||||
onComponentDelete?: (componentId: string) => void;
|
onComponentDelete?: (componentId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentTypes = [
|
|
||||||
{ value: 'uptime', label: 'Uptime Service', icon: Server },
|
|
||||||
{ value: 'ssl', label: 'SSL Certificate', icon: Shield },
|
|
||||||
{ value: 'incident', label: 'Incident Monitoring', icon: AlertTriangle },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => {
|
export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [newComponent, setNewComponent] = useState({
|
const [newComponent, setNewComponent] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -76,16 +73,16 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Server className="h-5 w-5" />
|
<Server className="h-5 w-5" />
|
||||||
Status Page Components
|
{t('statusPageComponents')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Add monitoring components like uptime services, SSL certificates, and incident tracking
|
{t('addMonitoringComponentsDesc')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{selectedComponents.length > 0 && (
|
{selectedComponents.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Selected Components</Label>
|
<Label>{t('selectedComponents')}</Label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{selectedComponents.map((component, index) => (
|
{selectedComponents.map((component, index) => (
|
||||||
<div key={component.id || index} className="flex items-center justify-between p-3 border rounded-lg">
|
<div key={component.id || index} className="flex items-center justify-between p-3 border rounded-lg">
|
||||||
@@ -97,12 +94,12 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
|
|||||||
<div className="flex gap-2 mt-1">
|
<div className="flex gap-2 mt-1">
|
||||||
{component.service_id && (
|
{component.service_id && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
Service: {services.find(s => s.id === component.service_id)?.name || component.service_id}
|
{t('service')}: {services.find(s => s.id === component.service_id)?.name || component.service_id}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{component.server_id && (
|
{component.server_id && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
Server: {component.server_id}
|
{t('server')}: {component.server_id}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -127,22 +124,22 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add Component
|
{t('addComponent')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className="border rounded-lg p-4 space-y-4">
|
<div className="border rounded-lg p-4 space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="component-name">Component Name</Label>
|
<Label htmlFor="component-name">{t('componentName')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="component-name"
|
id="component-name"
|
||||||
placeholder="e.g., Main Website"
|
placeholder={t('componentNamePlaceholder')}
|
||||||
value={newComponent.name}
|
value={newComponent.name}
|
||||||
onChange={(e) => setNewComponent({ ...newComponent, name: e.target.value })}
|
onChange={(e) => setNewComponent({ ...newComponent, name: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="display-order">Display Order</Label>
|
<Label htmlFor="display-order">{t('displayOrder')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="display-order"
|
id="display-order"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -153,10 +150,10 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="component-description">Description (Optional)</Label>
|
<Label htmlFor="component-description">{t('descriptionOptional')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="component-description"
|
id="component-description"
|
||||||
placeholder="Brief description of this component"
|
placeholder={t('descriptionPlaceholder')}
|
||||||
value={newComponent.description}
|
value={newComponent.description}
|
||||||
onChange={(e) => setNewComponent({ ...newComponent, description: e.target.value })}
|
onChange={(e) => setNewComponent({ ...newComponent, description: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@@ -164,12 +161,12 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="service-id">Uptime Service (Optional)</Label>
|
<Label htmlFor="service-id">{t('uptimeServiceOptional')}</Label>
|
||||||
<Select onValueChange={(value) => setNewComponent({ ...newComponent, service_id: value })}>
|
<Select onValueChange={(value) => setNewComponent({ ...newComponent, service_id: value })}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select an uptime service" />
|
<SelectValue placeholder={t('selectUptimeService')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-50 bg-white border shadow-lg">
|
<SelectContent>
|
||||||
{services.map((service) => (
|
{services.map((service) => (
|
||||||
<SelectItem key={service.id} value={service.id}>
|
<SelectItem key={service.id} value={service.id}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -186,10 +183,10 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="server-id">Server ID (Optional)</Label>
|
<Label htmlFor="server-id">{t('serverIdOptional')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="server-id"
|
id="server-id"
|
||||||
placeholder="server_456"
|
placeholder={t('serverIdPlaceholder')}
|
||||||
value={newComponent.server_id}
|
value={newComponent.server_id}
|
||||||
onChange={(e) => setNewComponent({ ...newComponent, server_id: e.target.value })}
|
onChange={(e) => setNewComponent({ ...newComponent, server_id: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@@ -198,45 +195,14 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
|
|||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={addComponent} disabled={!newComponent.name.trim()}>
|
<Button onClick={addComponent} disabled={!newComponent.name.trim()}>
|
||||||
Add Component
|
{t('addComponent')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setShowAddForm(false)}>
|
<Button variant="outline" onClick={() => setShowAddForm(false)}>
|
||||||
Cancel
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<Label>Quick Add Templates</Label>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-2">
|
|
||||||
{componentTypes.map((type) => {
|
|
||||||
const Icon = type.icon;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={type.value}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const component: Partial<StatusPageComponentRecord> = {
|
|
||||||
name: type.label,
|
|
||||||
description: `Monitor ${type.label.toLowerCase()}`,
|
|
||||||
service_id: '',
|
|
||||||
server_id: '',
|
|
||||||
display_order: selectedComponents.length + 1,
|
|
||||||
operational_status_id: '',
|
|
||||||
};
|
|
||||||
onComponentsChange([...selectedComponents, component]);
|
|
||||||
}}
|
|
||||||
className="justify-start"
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4 mr-2" />
|
|
||||||
{type.label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useCreateStatusPageComponent } from '@/hooks/useStatusPageComponents';
|
|||||||
import { ComponentsSelector } from './ComponentsSelector';
|
import { ComponentsSelector } from './ComponentsSelector';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
title: z.string().min(1, 'Title is required'),
|
title: z.string().min(1, 'Title is required'),
|
||||||
@@ -31,6 +32,7 @@ const formSchema = z.object({
|
|||||||
type FormData = z.infer<typeof formSchema>;
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
export const CreateOperationalPageDialog = () => {
|
export const CreateOperationalPageDialog = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
|
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
|
||||||
const createMutation = useCreateOperationalPage();
|
const createMutation = useCreateOperationalPage();
|
||||||
@@ -67,13 +69,13 @@ export const CreateOperationalPageDialog = () => {
|
|||||||
page_style: data.page_style || '',
|
page_style: data.page_style || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Creating operational page with payload:', payload);
|
// console.log('Creating operational page with payload:', payload);
|
||||||
const createdPage = await createMutation.mutateAsync(payload);
|
const createdPage = await createMutation.mutateAsync(payload);
|
||||||
console.log('Created page:', createdPage);
|
// console.log('Created page:', createdPage);
|
||||||
|
|
||||||
// Create components after page is created
|
// Create components after page is created
|
||||||
if (selectedComponents.length > 0) {
|
if (selectedComponents.length > 0) {
|
||||||
console.log('Creating components for page:', createdPage.id);
|
// console.log('Creating components for page:', createdPage.id);
|
||||||
for (const component of selectedComponents) {
|
for (const component of selectedComponents) {
|
||||||
const componentPayload = {
|
const componentPayload = {
|
||||||
operational_status_id: createdPage.id,
|
operational_status_id: createdPage.id,
|
||||||
@@ -84,7 +86,7 @@ export const CreateOperationalPageDialog = () => {
|
|||||||
display_order: component.display_order || 1,
|
display_order: component.display_order || 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Creating component with payload:', componentPayload);
|
// console.log('Creating component with payload:', componentPayload);
|
||||||
await createComponentMutation.mutateAsync(componentPayload);
|
await createComponentMutation.mutateAsync(componentPayload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +95,7 @@ export const CreateOperationalPageDialog = () => {
|
|||||||
form.reset();
|
form.reset();
|
||||||
setSelectedComponents([]);
|
setSelectedComponents([]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating operational page:', error);
|
// console.error('Error creating operational page:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,14 +104,14 @@ export const CreateOperationalPageDialog = () => {
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="gap-2">
|
<Button className="gap-2">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Create Page
|
{t('createPage')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create Operational Page</DialogTitle>
|
<DialogTitle>{t('createOperationalPage')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Create a new operational status page to monitor your services and components.
|
{t('createOperationalPageDesc')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -121,9 +123,9 @@ export const CreateOperationalPageDialog = () => {
|
|||||||
name="title"
|
name="title"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Title</FormLabel>
|
<FormLabel>{t('title')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="My Service Status" {...field} />
|
<Input placeholder={t('myServiceStatusPlaceholder')} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -135,9 +137,9 @@ export const CreateOperationalPageDialog = () => {
|
|||||||
name="slug"
|
name="slug"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Slug</FormLabel>
|
<FormLabel>{t('slug')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="my-service-status" {...field} />
|
<Input placeholder={t('myServiceStatusSlugPlaceholder')} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -150,10 +152,10 @@ export const CreateOperationalPageDialog = () => {
|
|||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Description</FormLabel>
|
<FormLabel>{t('description')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="A brief description of your operational page"
|
placeholder={t('operationalPageDescriptionPlaceholder')}
|
||||||
className="min-h-[80px]"
|
className="min-h-[80px]"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@@ -169,17 +171,17 @@ export const CreateOperationalPageDialog = () => {
|
|||||||
name="theme"
|
name="theme"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Theme</FormLabel>
|
<FormLabel>{t('theme')}</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select theme" />
|
<SelectValue placeholder={t('selectTheme')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="default">Default</SelectItem>
|
<SelectItem value="default">{t('themeDefault')}</SelectItem>
|
||||||
<SelectItem value="dark">Dark</SelectItem>
|
<SelectItem value="dark">{t('themeDark')}</SelectItem>
|
||||||
<SelectItem value="light">Light</SelectItem>
|
<SelectItem value="light">{t('themeLight')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -192,18 +194,18 @@ export const CreateOperationalPageDialog = () => {
|
|||||||
name="status"
|
name="status"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Initial Status</FormLabel>
|
<FormLabel>{t('initialStatus')}</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select status" />
|
<SelectValue placeholder={t('selectStatus')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="operational">Operational</SelectItem>
|
<SelectItem value="operational">{t('statusOperational')}</SelectItem>
|
||||||
<SelectItem value="degraded">Degraded Performance</SelectItem>
|
<SelectItem value="degraded">{t('statusDegraded')}</SelectItem>
|
||||||
<SelectItem value="maintenance">Under Maintenance</SelectItem>
|
<SelectItem value="maintenance">{t('statusMaintenance')}</SelectItem>
|
||||||
<SelectItem value="major_outage">Major Outage</SelectItem>
|
<SelectItem value="major_outage">{t('statusMajorOutage')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -218,9 +220,9 @@ export const CreateOperationalPageDialog = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Public Page</FormLabel>
|
<FormLabel>{t('publicPage')}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Make this page publicly accessible
|
{t('makePagePublic')}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -238,12 +240,12 @@ export const CreateOperationalPageDialog = () => {
|
|||||||
name="custom_domain"
|
name="custom_domain"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Custom Domain (Optional)</FormLabel>
|
<FormLabel>{t('customDomainOptional')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="status.yourdomain.com" {...field} />
|
<Input placeholder={t('customDomainPlaceholder')} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Custom domain for your status page
|
{t('customDomainDescription')}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -261,13 +263,13 @@ export const CreateOperationalPageDialog = () => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={createMutation.isPending || createComponentMutation.isPending}
|
disabled={createMutation.isPending || createComponentMutation.isPending}
|
||||||
>
|
>
|
||||||
{createMutation.isPending || createComponentMutation.isPending ? 'Creating...' : 'Create Page'}
|
{createMutation.isPending || createComponentMutation.isPending ? t('creating') : t('createPage')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -14,6 +14,7 @@ import { useCreateStatusPageComponent, useStatusPageComponentsByOperationalId, u
|
|||||||
import { ComponentsSelector } from './ComponentsSelector';
|
import { ComponentsSelector } from './ComponentsSelector';
|
||||||
import { OperationalPageRecord } from '@/types/operational.types';
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
title: z.string().min(1, 'Title is required'),
|
title: z.string().min(1, 'Title is required'),
|
||||||
@@ -37,9 +38,11 @@ interface EditOperationalPageDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOperationalPageDialogProps) => {
|
export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOperationalPageDialogProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
|
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
|
||||||
const [existingComponents, setExistingComponents] = useState<StatusPageComponentRecord[]>([]);
|
|
||||||
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
|
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
|
||||||
|
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
||||||
|
|
||||||
const updateMutation = useUpdateOperationalPage();
|
const updateMutation = useUpdateOperationalPage();
|
||||||
const createComponentMutation = useCreateStatusPageComponent();
|
const createComponentMutation = useCreateStatusPageComponent();
|
||||||
const deleteComponentMutation = useDeleteStatusPageComponent();
|
const deleteComponentMutation = useDeleteStatusPageComponent();
|
||||||
@@ -63,28 +66,42 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
// Memoize the form reset values to prevent unnecessary re-renders
|
||||||
if (page) {
|
const formValues = useMemo(() => {
|
||||||
form.reset({
|
if (!page) return null;
|
||||||
title: page.title,
|
return {
|
||||||
description: page.description,
|
title: page.title,
|
||||||
slug: page.slug,
|
description: page.description,
|
||||||
theme: page.theme,
|
slug: page.slug,
|
||||||
status: page.status,
|
theme: page.theme,
|
||||||
is_public: page.is_public === 'true',
|
status: page.status,
|
||||||
logo_url: page.logo_url || '',
|
is_public: page.is_public === 'true',
|
||||||
custom_domain: page.custom_domain || '',
|
logo_url: page.logo_url || '',
|
||||||
custom_css: page.custom_css || '',
|
custom_domain: page.custom_domain || '',
|
||||||
page_style: page.page_style || '',
|
custom_css: page.custom_css || '',
|
||||||
});
|
page_style: page.page_style || '',
|
||||||
}
|
};
|
||||||
}, [page, form]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (components && components.length > 0) {
|
if (formValues) {
|
||||||
console.log('Loading existing components:', components);
|
form.reset(formValues);
|
||||||
setExistingComponents(components);
|
}
|
||||||
// Convert existing components to the format expected by ComponentsSelector
|
}, [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 => ({
|
const existingComponentsForSelector = components.map(comp => ({
|
||||||
id: comp.id,
|
id: comp.id,
|
||||||
name: comp.name,
|
name: comp.name,
|
||||||
@@ -94,25 +111,31 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
|
|||||||
display_order: comp.display_order,
|
display_order: comp.display_order,
|
||||||
operational_status_id: comp.operational_status_id,
|
operational_status_id: comp.operational_status_id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setSelectedComponents(existingComponentsForSelector);
|
setSelectedComponents(existingComponentsForSelector);
|
||||||
} else {
|
setComponentsLoaded(true);
|
||||||
setExistingComponents([]);
|
}
|
||||||
|
}, [open, page?.id, components, componentsLoaded, selectedComponents]);
|
||||||
|
|
||||||
|
// Reset state when dialog closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setComponentsLoaded(false);
|
||||||
setSelectedComponents([]);
|
setSelectedComponents([]);
|
||||||
}
|
}
|
||||||
}, [components]);
|
}, [open]);
|
||||||
|
|
||||||
const handleComponentDelete = async (componentId: string) => {
|
const handleComponentDelete = useCallback(async (componentId: string) => {
|
||||||
try {
|
try {
|
||||||
console.log('Deleting component:', componentId);
|
// console.log('Deleting component:', componentId);
|
||||||
await deleteComponentMutation.mutateAsync(componentId);
|
await deleteComponentMutation.mutateAsync(componentId);
|
||||||
|
|
||||||
// Update local state to remove the deleted component
|
// Update local state to remove the deleted component
|
||||||
setSelectedComponents(prev => prev.filter(comp => comp.id !== componentId));
|
setSelectedComponents(prev => prev.filter(comp => comp.id !== componentId));
|
||||||
setExistingComponents(prev => prev.filter(comp => comp.id !== componentId));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting component:', error);
|
// console.error('Error deleting component:', error);
|
||||||
}
|
}
|
||||||
};
|
}, [deleteComponentMutation]);
|
||||||
|
|
||||||
const onSubmit = async (data: FormData) => {
|
const onSubmit = async (data: FormData) => {
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
@@ -133,21 +156,17 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
|
|||||||
page_style: data.page_style || '',
|
page_style: data.page_style || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Updating operational page with payload:', payload);
|
// console.log('Updating operational page with payload:', payload);
|
||||||
await updateMutation.mutateAsync({ id: page.id, data: payload });
|
await updateMutation.mutateAsync({ id: page.id, data: payload });
|
||||||
|
|
||||||
// Handle component changes
|
// Handle component changes
|
||||||
const currentComponentIds = existingComponents.map(c => c.id);
|
const currentComponentIds = components.map(c => c.id);
|
||||||
const newComponentsToCreate = selectedComponents.filter(comp => !comp.id);
|
const newComponentsToCreate = selectedComponents.filter(comp => !comp.id);
|
||||||
const componentsToKeep = selectedComponents.filter(comp => comp.id && currentComponentIds.includes(comp.id));
|
const componentsToDelete = components.filter(comp => !selectedComponents.some(selected => selected.id === comp.id));
|
||||||
const componentsToDelete = existingComponents.filter(comp => !selectedComponents.some(selected => selected.id === comp.id));
|
|
||||||
|
|
||||||
// Delete removed components (only if not already deleted via handleComponentDelete)
|
// Delete removed components
|
||||||
for (const component of componentsToDelete) {
|
for (const component of componentsToDelete) {
|
||||||
if (selectedComponents.some(selected => selected.id === component.id)) {
|
// console.log('Deleting component during save:', component.id);
|
||||||
continue; // Skip if already handled by handleComponentDelete
|
|
||||||
}
|
|
||||||
console.log('Deleting component during save:', component.id);
|
|
||||||
await deleteComponentMutation.mutateAsync(component.id);
|
await deleteComponentMutation.mutateAsync(component.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,13 +181,13 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
|
|||||||
display_order: component.display_order || 1,
|
display_order: component.display_order || 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Creating component with payload:', componentPayload);
|
// console.log('Creating component with payload:', componentPayload);
|
||||||
await createComponentMutation.mutateAsync(componentPayload);
|
await createComponentMutation.mutateAsync(componentPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating operational page:', error);
|
// console.error('Error updating operational page:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsFormSubmitting(false);
|
setIsFormSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -178,9 +197,9 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Operational Page</DialogTitle>
|
<DialogTitle>{t('editOperationalPage')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update your operational status page settings and manage components.
|
{t('updateYourOperationalPage')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -192,9 +211,9 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
|
|||||||
name="title"
|
name="title"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Title</FormLabel>
|
<FormLabel>{t('title')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="My Service Status" {...field} />
|
<Input placeholder={t('myServiceStatusPlaceholder')} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -206,9 +225,9 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
|
|||||||
name="slug"
|
name="slug"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Slug</FormLabel>
|
<FormLabel>{t('slug')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="my-service-status" {...field} />
|
<Input placeholder={t('myServiceStatusSlugPlaceholder')} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -221,10 +240,10 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
|
|||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Description</FormLabel>
|
<FormLabel>{t('description')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="A brief description of your operational page"
|
placeholder={t('operationalPageDescriptionPlaceholder')}
|
||||||
className="min-h-[80px]"
|
className="min-h-[80px]"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@@ -240,17 +259,17 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
|
|||||||
name="theme"
|
name="theme"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Theme</FormLabel>
|
<FormLabel>{t('theme')}</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select theme" />
|
<SelectValue placeholder={t('selectTheme')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="default">Default</SelectItem>
|
<SelectItem value="default">{t('themeDefault')}</SelectItem>
|
||||||
<SelectItem value="dark">Dark</SelectItem>
|
<SelectItem value="dark">{t('themeDark')}</SelectItem>
|
||||||
<SelectItem value="light">Light</SelectItem>
|
<SelectItem value="light">{t('themeLight')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -263,18 +282,18 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
|
|||||||
name="status"
|
name="status"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Status</FormLabel>
|
<FormLabel>{t('status')}</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select status" />
|
<SelectValue placeholder={t('selectStatus')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="operational">Operational</SelectItem>
|
<SelectItem value="operational">{t('statusOperational')}</SelectItem>
|
||||||
<SelectItem value="degraded">Degraded Performance</SelectItem>
|
<SelectItem value="degraded">{t('statusDegraded')}</SelectItem>
|
||||||
<SelectItem value="maintenance">Under Maintenance</SelectItem>
|
<SelectItem value="maintenance">{t('statusMaintenance')}</SelectItem>
|
||||||
<SelectItem value="major_outage">Major Outage</SelectItem>
|
<SelectItem value="major_outage">{t('statusMajorOutage')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -289,9 +308,9 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Public Page</FormLabel>
|
<FormLabel>{t('publicPage')}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Make this page publicly accessible
|
{t('makePagePublic')}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -309,12 +328,12 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
|
|||||||
name="custom_domain"
|
name="custom_domain"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Custom Domain (Optional)</FormLabel>
|
<FormLabel>{t('customDomainOptional')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="status.yourdomain.com" {...field} />
|
<Input placeholder={t('customDomainPlaceholder')} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Custom domain for your status page
|
{t('customDomainDescription')}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -333,13 +352,13 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending}
|
disabled={isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending}
|
||||||
>
|
>
|
||||||
{isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending ? 'Updating...' : 'Update Page'}
|
{isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending ? t('updating') : t('updatePage')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { OperationalPageRecord } from '@/types/operational.types';
|
|||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
import { Globe, ExternalLink, Eye, Settings, Trash2 } from 'lucide-react';
|
import { Globe, ExternalLink, Eye, Settings, Trash2 } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
interface OperationalPageCardProps {
|
interface OperationalPageCardProps {
|
||||||
page: OperationalPageRecord;
|
page: OperationalPageRecord;
|
||||||
@@ -15,6 +16,8 @@ interface OperationalPageCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: OperationalPageCardProps) => {
|
export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: OperationalPageCardProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="hover:shadow-lg transition-shadow duration-200">
|
<Card className="hover:shadow-lg transition-shadow duration-200">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -32,23 +35,23 @@ export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: Operatio
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-muted-foreground">Slug:</span>
|
<span className="font-medium text-muted-foreground">{t('slug')}:</span>
|
||||||
<p className="mt-1">{page.slug}</p>
|
<p className="mt-1">{page.slug}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-muted-foreground">Theme:</span>
|
<span className="font-medium text-muted-foreground">{t('theme')}:</span>
|
||||||
<p className="mt-1 capitalize">{page.theme}</p>
|
<p className="mt-1 capitalize">{page.theme}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-muted-foreground">Public:</span>
|
<span className="font-medium text-muted-foreground">{t('public')}:</span>
|
||||||
<p className="mt-1">
|
<div className="mt-1">
|
||||||
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
|
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
|
||||||
{page.is_public === 'true' ? 'Yes' : 'No'}
|
{page.is_public === 'true' ? t('yes') : t('no')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-muted-foreground">Updated:</span>
|
<span className="font-medium text-muted-foreground">{t('updated')}:</span>
|
||||||
<p className="mt-1">{format(new Date(page.updated), 'MMM dd, yyyy')}</p>
|
<p className="mt-1">{format(new Date(page.updated), 'MMM dd, yyyy')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,7 +73,7 @@ export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: Operatio
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4 mr-2" />
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
View
|
{t('view')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
@@ -81,7 +84,7 @@ export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: Operatio
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
Edit
|
{t('edit')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { OperationalPageRecord } from '@/types/operational.types';
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
import { Activity, Plus, RefreshCw } from 'lucide-react';
|
import { Activity, Plus, RefreshCw } from 'lucide-react';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
export const OperationalPageContent = () => {
|
export const OperationalPageContent = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: pages, isLoading, error, refetch, isRefetching } = useOperationalPages();
|
const { data: pages, isLoading, error, refetch, isRefetching } = useOperationalPages();
|
||||||
const deleteMutation = useDeleteOperationalPage();
|
const deleteMutation = useDeleteOperationalPage();
|
||||||
@@ -39,8 +42,8 @@ export const OperationalPageContent = () => {
|
|||||||
if (page.custom_domain) {
|
if (page.custom_domain) {
|
||||||
window.open(`https://${page.custom_domain}`, '_blank');
|
window.open(`https://${page.custom_domain}`, '_blank');
|
||||||
} else {
|
} else {
|
||||||
// Navigate to the public status page route
|
// Navigate to the public status page route using the correct format
|
||||||
window.open(`/status/${page.slug}`, '_blank');
|
window.open(`/public/${page.slug}`, '_blank');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,13 +71,13 @@ export const OperationalPageContent = () => {
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2">Failed to load operational pages</h3>
|
<h3 className="text-lg font-semibold mb-2">{t('failedToLoadOperationalPages')}</h3>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
There was an error loading your operational pages. Please try again.
|
{t('loadingoperationalPages')}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => refetch()} variant="outline">
|
<Button onClick={() => refetch()} variant="outline">
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Try Again
|
{t('tryagain')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,9 +89,9 @@ export const OperationalPageContent = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Operational Pages</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2"> {t('operationalPages')}</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Manage your public status pages and monitor service health
|
{t('describeOperation')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,7 +103,7 @@ export const OperationalPageContent = () => {
|
|||||||
disabled={isRefetching}
|
disabled={isRefetching}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefetching ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 mr-2 ${isRefetching ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
{t('refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
<CreateOperationalPageDialog />
|
<CreateOperationalPageDialog />
|
||||||
</div>
|
</div>
|
||||||
@@ -135,9 +138,9 @@ export const OperationalPageContent = () => {
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2">No operational pages found</h3>
|
<h3 className="text-lg font-semibold mb-2">{t('noOperationalPagesFound')}</h3>
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-6">
|
||||||
Create your first operational page to start monitoring your services and communicate status to your users.
|
{t('createYourFirstOperationalPage')}
|
||||||
</p>
|
</p>
|
||||||
<CreateOperationalPageDialog />
|
<CreateOperationalPageDialog />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -164,14 +167,14 @@ export const OperationalPageContent = () => {
|
|||||||
<div className="mt-8 pt-6 border-t">
|
<div className="mt-8 pt-6 border-t">
|
||||||
<div className="flex flex-wrap gap-6 text-sm text-muted-foreground">
|
<div className="flex flex-wrap gap-6 text-sm text-muted-foreground">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Total Pages:</span> {pages.length}
|
<span className="font-medium">{t('totalPages')}:</span> {pages.length}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Public Pages:</span>{' '}
|
<span className="font-medium">{t('totalPages')}:</span>{' '}
|
||||||
{pages.filter(p => p.is_public === 'true').length}
|
{pages.filter(p => p.is_public === 'true').length}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Operational:</span>{' '}
|
<span className="font-medium">{t('operational')}:</span>{' '}
|
||||||
{pages.filter(p => p.status === 'operational').length}
|
{pages.filter(p => p.status === 'operational').length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,19 +192,19 @@ export const OperationalPageContent = () => {
|
|||||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete Operational Page</AlertDialogTitle>
|
<AlertDialogTitle>{t('deleteOperationalPage')}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<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.
|
{t('deleteOperationalPageConfirm').replace('{title}', pageToDelete?.title ?? '')}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={confirmDelete}
|
onClick={confirmDelete}
|
||||||
className="bg-red-600 hover:bg-red-700"
|
className="bg-red-600 hover:bg-red-700"
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
{deleteMutation.isPending ? t('deleting') : t('delete')}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { format } from 'date-fns';
|
|||||||
import { OperationalPageRecord } from '@/types/operational.types';
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
import { Service } from '@/types/service.types';
|
import { Service } from '@/types/service.types';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
interface CurrentStatusSectionProps {
|
interface CurrentStatusSectionProps {
|
||||||
page: OperationalPageRecord;
|
page: OperationalPageRecord;
|
||||||
@@ -45,18 +46,18 @@ const getActualStatus = (components: StatusPageComponentRecord[], services: Serv
|
|||||||
return 'operational';
|
return 'operational';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusMessage = (status: OperationalPageRecord['status']) => {
|
const getStatusMessage = (status: OperationalPageRecord['status'], t: (k: string, m?: string) => string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'operational':
|
case 'operational':
|
||||||
return 'All systems are operational';
|
return t('allOperational', 'public');
|
||||||
case 'degraded':
|
case 'degraded':
|
||||||
return 'Some systems are experiencing degraded performance';
|
return t('degradedPerformance', 'public');
|
||||||
case 'maintenance':
|
case 'maintenance':
|
||||||
return 'Systems are currently under maintenance';
|
return t('underMaintenance', 'public');
|
||||||
case 'major_outage':
|
case 'major_outage':
|
||||||
return 'We are experiencing a major service outage';
|
return t('majorOutage', 'public');
|
||||||
default:
|
default:
|
||||||
return 'Status unknown';
|
return t('statusUnknown', 'public');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,6 +107,7 @@ const getStatusBackground = (status: OperationalPageRecord['status']) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CurrentStatusSection = ({ page, components, services }: CurrentStatusSectionProps) => {
|
export const CurrentStatusSection = ({ page, components, services }: CurrentStatusSectionProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const actualStatus = getActualStatus(components, services);
|
const actualStatus = getActualStatus(components, services);
|
||||||
const displayStatus = actualStatus; // Use actual status for real-time accuracy
|
const displayStatus = actualStatus; // Use actual status for real-time accuracy
|
||||||
|
|
||||||
@@ -114,7 +116,7 @@ export const CurrentStatusSection = ({ page, components, services }: CurrentStat
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-3 text-card-foreground text-xl">
|
<CardTitle className="flex items-center gap-3 text-card-foreground text-xl">
|
||||||
<Shield className="h-6 w-6" />
|
<Shield className="h-6 w-6" />
|
||||||
System Status
|
{t('systemStatus', 'public')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
@@ -123,10 +125,10 @@ export const CurrentStatusSection = ({ page, components, services }: CurrentStat
|
|||||||
{getStatusIcon(displayStatus)}
|
{getStatusIcon(displayStatus)}
|
||||||
<div>
|
<div>
|
||||||
<h3 className={`text-2xl font-bold ${getStatusColor(displayStatus)}`}>
|
<h3 className={`text-2xl font-bold ${getStatusColor(displayStatus)}`}>
|
||||||
{getStatusMessage(displayStatus)}
|
{getStatusMessage(displayStatus, t)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Status automatically updated based on component health
|
{t('autoUpdatedByHealth', 'public')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,20 +138,20 @@ export const CurrentStatusSection = ({ page, components, services }: CurrentStat
|
|||||||
displayStatus === 'maintenance' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-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'
|
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||||
}`}>
|
}`}>
|
||||||
{displayStatus === 'operational' ? 'All Systems Operational' :
|
{displayStatus === 'operational' ? t('allOperational', 'public') :
|
||||||
displayStatus === 'degraded' ? 'Degraded Performance' :
|
displayStatus === 'degraded' ? t('degradedPerformance', 'public') :
|
||||||
displayStatus === 'maintenance' ? 'Under Maintenance' : 'Major Outage'}
|
displayStatus === 'maintenance' ? t('underMaintenance', 'public') : t('majorOutage', 'public')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground border-t pt-4">
|
<div className="flex items-center justify-between text-sm text-muted-foreground border-t pt-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
<span>Last updated: {format(new Date(), 'MMM dd, yyyy HH:mm')} UTC</span>
|
<span>{t('lastUpdatedAt', 'public', { time: format(new Date(), 'MMM dd, yyyy HH:mm') })}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
<span>Live status monitoring</span>
|
<span>{t('liveStatusMonitoring', 'public')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import { CurrentStatusSection } from './CurrentStatusSection';
|
|||||||
import { ComponentsStatusSection } from './ComponentsStatusSection';
|
import { ComponentsStatusSection } from './ComponentsStatusSection';
|
||||||
import { OverallUptimeSection } from './OverallUptimeSection';
|
import { OverallUptimeSection } from './OverallUptimeSection';
|
||||||
import { PublicStatusPageFooter } from './PublicStatusPageFooter';
|
import { PublicStatusPageFooter } from './PublicStatusPageFooter';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
export const PublicStatusPage = () => {
|
export const PublicStatusPage = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
console.log('PublicStatusPage - slug from params:', slug);
|
// console.log('PublicStatusPage - slug from params:', slug);
|
||||||
|
|
||||||
const { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug);
|
const { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug);
|
||||||
const [lastUpdated, setLastUpdated] = useState(new Date());
|
const [lastUpdated, setLastUpdated] = useState(new Date());
|
||||||
@@ -51,7 +53,7 @@ export const PublicStatusPage = () => {
|
|||||||
};
|
};
|
||||||
}, [page?.theme]);
|
}, [page?.theme]);
|
||||||
|
|
||||||
console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length });
|
// console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length });
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -59,9 +61,9 @@ export const PublicStatusPage = () => {
|
|||||||
<div className="text-center space-y-4">
|
<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="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-lg font-medium text-foreground">Loading Status Page</p>
|
<p className="text-lg font-medium text-foreground">{t('loadingStatusPage', 'public')}</p>
|
||||||
<p className="text-sm text-muted-foreground">Fetching real-time system status...</p>
|
<p className="text-sm text-muted-foreground">{t('fetchingRealtimeStatus', 'public')}</p>
|
||||||
<p className="text-xs text-muted-foreground">Slug: {slug || 'No slug provided'}</p>
|
<p className="text-xs text-muted-foreground">{t('slugLabel', 'public')}: {slug || 'No slug provided'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,19 +78,19 @@ export const PublicStatusPage = () => {
|
|||||||
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
|
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="text-2xl font-bold text-foreground">Status Page Not Found</h1>
|
<h1 className="text-2xl font-bold text-foreground">{t('statusPageNotFound', 'public')}</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{error || 'The requested status page could not be found or is not publicly accessible.'}
|
{error || t('notFoundDescription', 'public')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">Slug: {slug || 'No slug provided'}</p>
|
<p className="text-xs text-muted-foreground">{t('slugLabel', 'public')}: {slug || 'No slug provided'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex gap-3 justify-center">
|
||||||
<Button onClick={() => window.history.back()} variant="outline">
|
<Button onClick={() => window.history.back()} variant="outline">
|
||||||
Go Back
|
{t('goBack', 'public')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => window.location.reload()} className="gap-2">
|
<Button onClick={() => window.location.reload()} className="gap-2">
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
Retry
|
{t('retry', 'public')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,61 +19,61 @@ export const usePublicStatusPageData = (slug: string | undefined) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPublicPage = async () => {
|
const fetchPublicPage = async () => {
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
console.log('No slug provided');
|
// console.log('No slug provided');
|
||||||
setError('No status page slug provided');
|
setError('No status page slug provided');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Fetching public status page for slug:', slug);
|
// console.log('Fetching public status page for slug:', slug);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Fetch operational page
|
// Fetch operational page
|
||||||
console.log('Fetching operational pages...');
|
// console.log('Fetching operational pages...');
|
||||||
const pages = await operationalPageService.getOperationalPages();
|
const pages = await operationalPageService.getOperationalPages();
|
||||||
console.log('All pages:', pages);
|
// console.log('All pages:', pages);
|
||||||
|
|
||||||
const foundPage = pages.find(p => p.slug === slug && p.is_public === 'true');
|
const foundPage = pages.find(p => p.slug === slug && p.is_public === 'true');
|
||||||
console.log('Found page:', foundPage);
|
// console.log('Found page:', foundPage);
|
||||||
|
|
||||||
if (!foundPage) {
|
if (!foundPage) {
|
||||||
console.log('Page not found or not public');
|
// console.log('Page not found or not public');
|
||||||
setError('Status page not found or not public');
|
setError('Status page not found or not public');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPage(foundPage);
|
setPage(foundPage);
|
||||||
console.log('Page set successfully');
|
// console.log('Page set successfully');
|
||||||
|
|
||||||
// Fetch components for this page
|
// Fetch components for this page
|
||||||
console.log('Fetching components for page:', foundPage.id);
|
// console.log('Fetching components for page:', foundPage.id);
|
||||||
const pageComponents = await statusPageComponentsService.getStatusPageComponentsByOperationalId(foundPage.id);
|
const pageComponents = await statusPageComponentsService.getStatusPageComponentsByOperationalId(foundPage.id);
|
||||||
console.log('Components found:', pageComponents);
|
// console.log('Components found:', pageComponents);
|
||||||
setComponents(pageComponents);
|
setComponents(pageComponents);
|
||||||
|
|
||||||
// Fetch all services
|
// Fetch all services
|
||||||
console.log('Fetching all services...');
|
// console.log('Fetching all services...');
|
||||||
const allServices = await serviceService.getServices();
|
const allServices = await serviceService.getServices();
|
||||||
console.log('Services found:', allServices);
|
// console.log('Services found:', allServices);
|
||||||
setServices(allServices);
|
setServices(allServices);
|
||||||
|
|
||||||
// Fetch uptime data for each component that has a service
|
// Fetch uptime data for each component that has a service
|
||||||
console.log('Fetching uptime data...');
|
// console.log('Fetching uptime data...');
|
||||||
const uptimePromises = pageComponents
|
const uptimePromises = pageComponents
|
||||||
.filter(component => component.service_id)
|
.filter(component => component.service_id)
|
||||||
.map(async (component) => {
|
.map(async (component) => {
|
||||||
try {
|
try {
|
||||||
console.log('Fetching uptime for service:', component.service_id);
|
// console.log('Fetching uptime for service:', component.service_id);
|
||||||
const endDate = new Date();
|
const endDate = new Date();
|
||||||
const startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // Last 90 days
|
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);
|
const history = await uptimeService.getUptimeHistory(component.service_id, 2000, startDate, endDate);
|
||||||
console.log(`Uptime history for ${component.service_id}:`, history.length, 'records');
|
// console.log(`Uptime history for ${component.service_id}:`, history.length, 'records');
|
||||||
return { serviceId: component.service_id, history };
|
return { serviceId: component.service_id, history };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching uptime for service ${component.service_id}:`, error);
|
// console.error(`Error fetching uptime for service ${component.service_id}:`, error);
|
||||||
return { serviceId: component.service_id, history: [] };
|
return { serviceId: component.service_id, history: [] };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -84,12 +84,12 @@ export const usePublicStatusPageData = (slug: string | undefined) => {
|
|||||||
uptimeMap[result.serviceId] = result.history;
|
uptimeMap[result.serviceId] = result.history;
|
||||||
});
|
});
|
||||||
setUptimeData(uptimeMap);
|
setUptimeData(uptimeMap);
|
||||||
console.log('Uptime data set successfully');
|
// console.log('Uptime data set successfully');
|
||||||
|
|
||||||
console.log('All data fetched successfully');
|
// console.log('All data fetched successfully');
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching public page:', err);
|
// console.error('Error fetching public page:', err);
|
||||||
setError(`Failed to load status page: ${err}`);
|
setError(`Failed to load status page: ${err}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
import { getCurrentEndpoint } from "@/lib/pocketbase";
|
||||||
|
import { RegionalAgentConfigForm } from "./RegionalAgentConfigForm";
|
||||||
|
import { RegionalOneClickTab } from "./RegionalOneClickTab";
|
||||||
|
import { RegionalManualTab } from "./RegionalManualTab";
|
||||||
|
|
||||||
|
interface AddRegionalAgentDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onAgentAdded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddRegionalAgentDialog: React.FC<AddRegionalAgentDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onAgentAdded
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [activeTab, setActiveTab] = useState("configure");
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
regionName: "",
|
||||||
|
agentIp: "",
|
||||||
|
});
|
||||||
|
const [agentToken, setAgentToken] = useState("");
|
||||||
|
const [agentId, setAgentId] = useState("");
|
||||||
|
const [currentPocketBaseUrl, setCurrentPocketBaseUrl] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Generate new credentials when dialog opens or after successful creation
|
||||||
|
const generateNewCredentials = () => {
|
||||||
|
const newToken = `rgn_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`;
|
||||||
|
const newAgentId = `regional_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
setAgentToken(newToken);
|
||||||
|
setAgentId(newAgentId);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const endpoint = getCurrentEndpoint();
|
||||||
|
setCurrentPocketBaseUrl(endpoint);
|
||||||
|
generateNewCredentials();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formData.regionName.trim() || !formData.agentIp.trim()) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('success'),
|
||||||
|
description: t('agentCreatedSuccessfully'),
|
||||||
|
});
|
||||||
|
setActiveTab("one-click");
|
||||||
|
generateNewCredentials();
|
||||||
|
|
||||||
|
onAgentAdded();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('failedToCreateAgent'),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
setFormData({
|
||||||
|
regionName: "",
|
||||||
|
agentIp: "",
|
||||||
|
});
|
||||||
|
setActiveTab("configure");
|
||||||
|
generateNewCredentials();
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||||
|
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('addRegionalMonitoringAgent')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('deployRegionalMonitoringAgent')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="configure">{t('configureAgent')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="one-click">{t('oneClickInstallTab')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="manual">{t('manualInstallTab')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="configure" className="space-y-6">
|
||||||
|
<RegionalAgentConfigForm
|
||||||
|
formData={formData}
|
||||||
|
setFormData={setFormData}
|
||||||
|
agentId={agentId}
|
||||||
|
agentToken={agentToken}
|
||||||
|
currentPocketBaseUrl={currentPocketBaseUrl}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="one-click" className="space-y-6">
|
||||||
|
<RegionalOneClickTab
|
||||||
|
agentToken={agentToken}
|
||||||
|
currentPocketBaseUrl={currentPocketBaseUrl}
|
||||||
|
formData={formData}
|
||||||
|
agentId={agentId}
|
||||||
|
onDialogClose={handleDialogClose}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="manual" className="space-y-6">
|
||||||
|
<RegionalManualTab
|
||||||
|
agentToken={agentToken}
|
||||||
|
currentPocketBaseUrl={currentPocketBaseUrl}
|
||||||
|
formData={formData}
|
||||||
|
agentId={agentId}
|
||||||
|
onDialogClose={handleDialogClose}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
|
||||||
|
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";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
|
interface RegionalAgentCardProps {
|
||||||
|
agent: RegionalService;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onDelete }) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
// 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: t('copied'),
|
||||||
|
description: t('copiedDescription'),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t('copyFailed'),
|
||||||
|
description: t('copyFailedDescription'),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConnectionStatus = () => {
|
||||||
|
if (agent.connection === 'online') {
|
||||||
|
return {
|
||||||
|
icon: <Wifi className="h-4 w-4" />,
|
||||||
|
label: t('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: t('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">
|
||||||
|
{t('defaultBadge')}
|
||||||
|
</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" />
|
||||||
|
{t('copyAgentId')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{!isDefaultAgent && (
|
||||||
|
<DropdownMenuItem onClick={onDelete} className="text-red-600">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{t('removeAgent')}
|
||||||
|
</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">{t('agentId')}</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">{t('lastUpdated')}</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>
|
||||||
|
{t('activeMonitoring')}
|
||||||
|
</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>
|
||||||
|
{t('connectionLost')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Copy } from "lucide-react";
|
||||||
|
import { copyToClipboard } from "@/utils/copyUtils";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
|
interface RegionalAgentConfigFormProps {
|
||||||
|
formData: {
|
||||||
|
regionName: string;
|
||||||
|
agentIp: string;
|
||||||
|
};
|
||||||
|
setFormData: (data: { regionName: string; agentIp: string }) => void;
|
||||||
|
agentId: string;
|
||||||
|
agentToken: string;
|
||||||
|
currentPocketBaseUrl: string;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RegionalAgentConfigForm: React.FC<RegionalAgentConfigFormProps> = ({
|
||||||
|
formData,
|
||||||
|
setFormData,
|
||||||
|
agentId,
|
||||||
|
agentToken,
|
||||||
|
currentPocketBaseUrl,
|
||||||
|
isSubmitting,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="regionName">{t('regionName')}</Label>
|
||||||
|
<Input
|
||||||
|
id="regionName"
|
||||||
|
placeholder={t('regionNamePlaceholder')}
|
||||||
|
value={formData.regionName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, regionName: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="agentIp">{t('agentServerIpAddress')}</Label>
|
||||||
|
<Input
|
||||||
|
id="agentIp"
|
||||||
|
placeholder={t('agentIpPlaceholder')}
|
||||||
|
value={formData.agentIp}
|
||||||
|
onChange={(e) => setFormData({ ...formData, agentIp: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">{t('agentId')}</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={agentId}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 pr-10"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="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(agentId)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">{t('apiEndpoint')}</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={currentPocketBaseUrl}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 pr-10"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="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(currentPocketBaseUrl)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">{t('authenticationToken')}</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={agentToken}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 pr-10"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="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(agentToken)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? t('creating') : t('createAgent')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Copy, Terminal } from "lucide-react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { copyToClipboard } from "@/utils/copyUtils";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
|
interface RegionalManualTabProps {
|
||||||
|
agentToken: string;
|
||||||
|
currentPocketBaseUrl: string;
|
||||||
|
formData: {
|
||||||
|
regionName: string;
|
||||||
|
agentIp: string;
|
||||||
|
};
|
||||||
|
agentId: string;
|
||||||
|
onDialogClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RegionalManualTab: React.FC<RegionalManualTabProps> = ({
|
||||||
|
agentToken,
|
||||||
|
currentPocketBaseUrl,
|
||||||
|
formData,
|
||||||
|
agentId,
|
||||||
|
onDialogClose,
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const getManualInstallSteps = () => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: t('step1DownloadPackage'),
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
label: t('downloadAmd64Notice'),
|
||||||
|
command: 'wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_amd64.deb'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('downloadArm64Notice'),
|
||||||
|
command: 'wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_arm64.deb'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('step2InstallPackage'),
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
label: '',
|
||||||
|
command: 'sudo dpkg -i distributed-regional-check-agent_1.0.0_amd64.deb\nsudo apt-get install -f'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('step3ConfigureAgent'),
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
label: '',
|
||||||
|
command: 'sudo nano /etc/regional-check-agent/regional-check-agent.env'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('addConfigurationValues'),
|
||||||
|
command: `POCKETBASE_URL=${currentPocketBaseUrl}
|
||||||
|
AGENT_TOKEN=${agentToken}
|
||||||
|
AGENT_ID=${agentId}
|
||||||
|
REGION_NAME=${formData.regionName}
|
||||||
|
AGENT_IP=${formData.agentIp}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('step4StartService'),
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
label: '',
|
||||||
|
command: 'sudo systemctl enable regional-check-agent\nsudo systemctl start regional-check-agent'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('step5VerifyInstallation'),
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
label: '',
|
||||||
|
command: 'sudo systemctl status regional-check-agent\ncurl http://localhost:8091/health'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Terminal className="h-5 w-5" />
|
||||||
|
{t('manualInstallationSteps')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('stepByStepManualInstallation')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{getManualInstallSteps().map((step, stepIndex) => (
|
||||||
|
<div key={stepIndex} className="border-l-4 border-blue-500 pl-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-sm font-medium">
|
||||||
|
{stepIndex + 1}
|
||||||
|
</span>
|
||||||
|
<p className="font-medium">{step.title}</p>
|
||||||
|
</div>
|
||||||
|
{step.commands.map((cmd, cmdIndex) => (
|
||||||
|
<div key={cmdIndex} className="space-y-1">
|
||||||
|
{cmd.label && (
|
||||||
|
<p className="text-xs text-muted-foreground ml-8">{cmd.label}</p>
|
||||||
|
)}
|
||||||
|
<div className="ml-8 relative">
|
||||||
|
<pre className="bg-muted p-3 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
|
<code>{cmd.command}</code>
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
onClick={() => copyToClipboard(cmd.command)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 mr-1" />
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-md p-4">
|
||||||
|
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">Prerequisites:</h4>
|
||||||
|
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1 list-disc list-inside mb-3">
|
||||||
|
<li>Ensure you have root/sudo access on the target server</li>
|
||||||
|
<li>Make sure wget or curl is installed for downloading files</li>
|
||||||
|
<li>Internet connection required for downloading packages</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">After Installation:</h4>
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
The agent will start automatically and appear in your dashboard within a few minutes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button onClick={onDialogClose}>
|
||||||
|
{t('done')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
|
||||||
|
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";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
|
export const RegionalMonitoringContent = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
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: t('regionalAgentAdded'),
|
||||||
|
description: t('regionalAgentAddedDesc'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAgent = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await regionalService.deleteRegionalService(id);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['regional-services'] });
|
||||||
|
toast({
|
||||||
|
title: t('agentRemoved'),
|
||||||
|
description: t('agentRemovedDesc'),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('failedToRemoveAgent'),
|
||||||
|
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">{t('regionalmonitoring')}</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t('descriptRegionPage')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setAddDialogOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t('addRegionalAgent')}
|
||||||
|
</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">{t('totalAgents')}</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">
|
||||||
|
{t('regionalMonitoringAgents')}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{t('onlineAgents')}</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">
|
||||||
|
{t('currentlyConnected')}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{t('offlineAgents')}</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">
|
||||||
|
{t('disconnectedAgents')}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agents List */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">{t('regionalAgents')}</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">{t('noRegionalAgents')}</h3>
|
||||||
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
|
{t('getStartedAddAgent')}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setAddDialogOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t('addFirstAgent')}
|
||||||
|
</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,126 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Copy, Download, Zap, Play } from "lucide-react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { copyToClipboard } from "@/utils/copyUtils";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface RegionalOneClickTabProps {
|
||||||
|
agentToken: string;
|
||||||
|
currentPocketBaseUrl: string;
|
||||||
|
formData: {
|
||||||
|
regionName: string;
|
||||||
|
agentIp: string;
|
||||||
|
};
|
||||||
|
agentId: string;
|
||||||
|
onDialogClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RegionalOneClickTab: React.FC<RegionalOneClickTabProps> = ({
|
||||||
|
agentToken,
|
||||||
|
currentPocketBaseUrl,
|
||||||
|
formData,
|
||||||
|
agentId,
|
||||||
|
onDialogClose,
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const getOneClickCommand = () => {
|
||||||
|
return `curl -fsSL https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/install-regional-agent.sh | sudo bash -s -- \\
|
||||||
|
--region-name="${formData.regionName}" \\
|
||||||
|
--agent-id="${agentId}" \\
|
||||||
|
--agent-ip="${formData.agentIp}" \\
|
||||||
|
--token="${agentToken}" \\
|
||||||
|
--pocketbase-url="${currentPocketBaseUrl}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadScript = () => {
|
||||||
|
const scriptContent = getOneClickCommand();
|
||||||
|
const blob = new Blob([scriptContent], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `install-regional-agent-${agentId}.sh`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('downloaded'),
|
||||||
|
description: t('downloadedDescription'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-green-500/20 bg-green-50/50 dark:bg-green-950/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-green-700 dark:text-green-400">
|
||||||
|
<Zap className="h-5 w-5" />
|
||||||
|
{t('oneClickAutomaticInstallation')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-green-600 dark:text-green-300">
|
||||||
|
{t('completeInstallationDescription')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4 dark:bg-green-950/50 dark:border-green-800">
|
||||||
|
<div className="flex items-center gap-2 text-green-800 dark:text-green-200 font-medium mb-2">
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
{t('whatThisScriptDoes')}
|
||||||
|
</div>
|
||||||
|
<ul className="text-sm text-green-700 dark:text-green-300 space-y-1 ml-6">
|
||||||
|
<li>• {t('scriptActionDownload')}</li>
|
||||||
|
<li>• {t('scriptActionInstall')}</li>
|
||||||
|
<li>• {t('scriptActionConfigure')}</li>
|
||||||
|
<li>• {t('scriptActionStart')}</li>
|
||||||
|
<li>• {t('scriptActionHealthChecks')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-green-700 dark:text-green-400">{t('runCommandOnServer')}</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
readOnly
|
||||||
|
value={getOneClickCommand()}
|
||||||
|
className="font-mono text-sm min-h-[120px] pr-12 bg-muted/50 border-green-200 dark:border-green-800"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="absolute top-2 right-2 bg-green-50 dark:bg-green-950/50 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900"
|
||||||
|
onClick={() => copyToClipboard(getOneClickCommand())}
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
{t('downloadCompleteScript')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 dark:bg-yellow-950/20 dark:border-yellow-800">
|
||||||
|
<p className="text-sm text-yellow-800 dark:text-yellow-200" dangerouslySetInnerHTML={{ __html: t('localDevelopmentNotice') }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button onClick={onDialogClose}>
|
||||||
|
{t('done')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
export { RegionalMonitoringContent } from './RegionalMonitoringContent';
|
||||||
|
export { AddRegionalAgentDialog } from './AddRegionalAgentDialog';
|
||||||
|
export { RegionalAgentCard } from './RegionalAgentCard';
|
||||||
@@ -22,12 +22,12 @@ export const ScheduleIncidentContent = () => {
|
|||||||
|
|
||||||
// Initialize maintenance notifications when the component mounts
|
// Initialize maintenance notifications when the component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("Initializing maintenance notifications");
|
// console.log("Initializing maintenance notifications");
|
||||||
initMaintenanceNotifications();
|
initMaintenanceNotifications();
|
||||||
|
|
||||||
// Clean up when the component unmounts
|
// Clean up when the component unmounts
|
||||||
return () => {
|
return () => {
|
||||||
console.log("Cleaning up maintenance notifications");
|
// console.log("Cleaning up maintenance notifications");
|
||||||
stopMaintenanceNotifications();
|
stopMaintenanceNotifications();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -43,7 +43,7 @@ export const ScheduleIncidentContent = () => {
|
|||||||
const handleMaintenanceCreated = () => {
|
const handleMaintenanceCreated = () => {
|
||||||
// Refresh data by incrementing the refresh trigger
|
// Refresh data by incrementing the refresh trigger
|
||||||
const newTriggerValue = refreshTrigger + 1;
|
const newTriggerValue = refreshTrigger + 1;
|
||||||
console.log("Maintenance created, refreshing data with new trigger value:", newTriggerValue);
|
// console.log("Maintenance created, refreshing data with new trigger value:", newTriggerValue);
|
||||||
setRefreshTrigger(newTriggerValue);
|
setRefreshTrigger(newTriggerValue);
|
||||||
|
|
||||||
// Show success toast
|
// Show success toast
|
||||||
@@ -56,7 +56,7 @@ export const ScheduleIncidentContent = () => {
|
|||||||
const handleIncidentCreated = () => {
|
const handleIncidentCreated = () => {
|
||||||
// Refresh data by incrementing the refresh trigger
|
// Refresh data by incrementing the refresh trigger
|
||||||
const newTriggerValue = incidentRefreshTrigger + 1;
|
const newTriggerValue = incidentRefreshTrigger + 1;
|
||||||
console.log("Incident created, refreshing data with new trigger value:", newTriggerValue);
|
// console.log("Incident created, refreshing data with new trigger value:", newTriggerValue);
|
||||||
setIncidentRefreshTrigger(newTriggerValue);
|
setIncidentRefreshTrigger(newTriggerValue);
|
||||||
|
|
||||||
// Show success toast
|
// Show success toast
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useLanguage } from '@/contexts/LanguageContext';
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { MaintenanceTable, MaintenanceStatusChecker } from './maintenance';
|
import { MaintenanceTable, MaintenanceStatusChecker } from './maintenance';
|
||||||
import { EmptyMaintenanceState } from './maintenance/EmptyMaintenanceState';
|
import { LoadingState } from '@/components/services/LoadingState';
|
||||||
import { OverviewCard } from './common/OverviewCard';
|
|
||||||
import { Calendar, Clock, CheckCircle } from 'lucide-react';
|
import { Calendar, Clock, CheckCircle } from 'lucide-react';
|
||||||
import { useMaintenanceData } from './hooks/useMaintenanceData';
|
import { useMaintenanceData } from './hooks/useMaintenanceData';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { LoadingState } from '@/components/services/LoadingState';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
|
||||||
interface ScheduledMaintenanceTabProps {
|
interface ScheduledMaintenanceTabProps {
|
||||||
refreshTrigger?: number;
|
refreshTrigger?: number;
|
||||||
@@ -18,25 +15,24 @@ interface ScheduledMaintenanceTabProps {
|
|||||||
|
|
||||||
export const ScheduledMaintenanceTab = ({ refreshTrigger = 0 }: ScheduledMaintenanceTabProps) => {
|
export const ScheduledMaintenanceTab = ({ refreshTrigger = 0 }: ScheduledMaintenanceTabProps) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const { theme } = useTheme();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [manualRefresh, setManualRefresh] = React.useState(0);
|
const [manualRefresh, setManualRefresh] = React.useState(0);
|
||||||
|
|
||||||
// Combine the external refresh trigger with our internal one
|
|
||||||
const combinedRefreshTrigger = refreshTrigger + manualRefresh;
|
const combinedRefreshTrigger = refreshTrigger + manualRefresh;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
filter,
|
filter,
|
||||||
setFilter,
|
setFilter,
|
||||||
maintenanceData,
|
maintenanceData,
|
||||||
overviewStats,
|
overviewStats,
|
||||||
fetchMaintenanceData,
|
fetchMaintenanceData,
|
||||||
isEmpty,
|
isEmpty,
|
||||||
error,
|
error,
|
||||||
initialized
|
initialized,
|
||||||
} = useMaintenanceData({ refreshTrigger: combinedRefreshTrigger });
|
} = useMaintenanceData({ refreshTrigger: combinedRefreshTrigger });
|
||||||
|
|
||||||
// Display toast when error occurs
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
toast({
|
toast({
|
||||||
@@ -47,66 +43,91 @@ export const ScheduledMaintenanceTab = ({ refreshTrigger = 0 }: ScheduledMainten
|
|||||||
}
|
}
|
||||||
}, [error, toast, t]);
|
}, [error, toast, t]);
|
||||||
|
|
||||||
// Force fetch data on initial load
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("ScheduledMaintenanceTab is mounted, fetching data");
|
|
||||||
fetchMaintenanceData(true);
|
fetchMaintenanceData(true);
|
||||||
}, [fetchMaintenanceData]);
|
}, [fetchMaintenanceData]);
|
||||||
|
|
||||||
// Handle maintenance updates
|
|
||||||
const handleMaintenanceUpdated = () => {
|
const handleMaintenanceUpdated = () => {
|
||||||
console.log("Maintenance updated, refreshing data");
|
setManualRefresh((prev) => prev + 1);
|
||||||
setManualRefresh(prev => prev + 1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle tab changes
|
|
||||||
const handleTabChange = (value: string) => {
|
const handleTabChange = (value: string) => {
|
||||||
setFilter(value);
|
setFilter(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show full-page loading state during initial load
|
|
||||||
if (loading && !initialized) {
|
if (loading && !initialized) {
|
||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gradientCard = (
|
||||||
|
title: string,
|
||||||
|
value: string | number,
|
||||||
|
icon: JSX.Element,
|
||||||
|
gradient: string
|
||||||
|
) => (
|
||||||
|
<Card
|
||||||
|
className="border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative z-10"
|
||||||
|
style={{
|
||||||
|
background: theme === 'dark'
|
||||||
|
? `linear-gradient(135deg, rgba(65,59,55,0.8) 0%, ${gradient})`
|
||||||
|
: `linear-gradient(135deg, rgba(65,59,55,0.8) 0%, ${gradient})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 z-0 opacity-10">
|
||||||
|
<div
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(#000 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '20px 20px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CardHeader className="pb-2 relative z-10">
|
||||||
|
<CardTitle className="text-sm font-medium text-white">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center justify-between relative z-10">
|
||||||
|
<span className="text-4xl font-bold text-white">{value}</span>
|
||||||
|
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Status checker for automatic updates */}
|
<MaintenanceStatusChecker
|
||||||
<MaintenanceStatusChecker
|
|
||||||
maintenanceData={maintenanceData}
|
maintenanceData={maintenanceData}
|
||||||
onStatusUpdated={handleMaintenanceUpdated}
|
onStatusUpdated={handleMaintenanceUpdated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Overview Cards */}
|
{/* Overview Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 w-full">
|
||||||
<OverviewCard
|
{gradientCard(
|
||||||
title={t('upcomingMaintenance')}
|
t('upcomingMaintenance'),
|
||||||
value={loading ? '...' : overviewStats.upcoming}
|
loading ? '...' : overviewStats.upcoming,
|
||||||
icon={<Calendar className="h-5 w-5 text-white" />}
|
<Calendar className="h-6 w-6 text-white" />,
|
||||||
isLoading={loading}
|
'rgba(59,130,246,0.6)'
|
||||||
color="blue"
|
)}
|
||||||
/>
|
{gradientCard(
|
||||||
<OverviewCard
|
t('ongoingMaintenance'),
|
||||||
title={t('ongoingMaintenance')}
|
loading ? '...' : overviewStats.ongoing,
|
||||||
value={loading ? '...' : overviewStats.ongoing}
|
<Clock className="h-6 w-6 text-white" />,
|
||||||
icon={<Clock className="h-5 w-5 text-white" />}
|
'rgba(251,191,36,0.6)'
|
||||||
isLoading={loading}
|
)}
|
||||||
color="amber"
|
{gradientCard(
|
||||||
/>
|
t('completedMaintenance'),
|
||||||
<OverviewCard
|
loading ? '...' : overviewStats.completed,
|
||||||
title={t('completedMaintenance')}
|
<CheckCircle className="h-6 w-6 text-white" />,
|
||||||
value={loading ? '...' : overviewStats.completed}
|
'rgba(34,197,94,0.6)'
|
||||||
icon={<CheckCircle className="h-5 w-5 text-white" />}
|
)}
|
||||||
isLoading={loading}
|
{gradientCard(
|
||||||
color="green"
|
t('totalScheduledHours'),
|
||||||
/>
|
loading ? '...' : `${overviewStats.totalDuration}h`,
|
||||||
<OverviewCard
|
<Clock className="h-6 w-6 text-white" />,
|
||||||
title={t('totalScheduledHours')}
|
'rgba(139,92,246,0.6)'
|
||||||
value={loading ? '...' : `${overviewStats.totalDuration}h`}
|
)}
|
||||||
icon={<Clock className="h-5 w-5 text-white" />}
|
|
||||||
isLoading={loading}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -117,10 +138,10 @@ export const ScheduledMaintenanceTab = ({ refreshTrigger = 0 }: ScheduledMainten
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="upcoming"
|
defaultValue="upcoming"
|
||||||
value={filter}
|
value={filter}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onValueChange={handleTabChange}
|
onValueChange={handleTabChange}
|
||||||
>
|
>
|
||||||
<TabsList className="mb-6">
|
<TabsList className="mb-6">
|
||||||
@@ -129,24 +150,24 @@ export const ScheduledMaintenanceTab = ({ refreshTrigger = 0 }: ScheduledMainten
|
|||||||
<TabsTrigger value="completed">{t('completedMaintenance')}</TabsTrigger>
|
<TabsTrigger value="completed">{t('completedMaintenance')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="upcoming" className="space-y-4">
|
<TabsContent value="upcoming">
|
||||||
<MaintenanceTable
|
<MaintenanceTable
|
||||||
data={maintenanceData}
|
data={maintenanceData}
|
||||||
isLoading={loading && initialized}
|
isLoading={loading && initialized}
|
||||||
onMaintenanceUpdated={handleMaintenanceUpdated}
|
onMaintenanceUpdated={handleMaintenanceUpdated}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="ongoing" className="space-y-4">
|
<TabsContent value="ongoing">
|
||||||
<MaintenanceTable
|
<MaintenanceTable
|
||||||
data={maintenanceData}
|
data={maintenanceData}
|
||||||
isLoading={loading && initialized}
|
isLoading={loading && initialized}
|
||||||
onMaintenanceUpdated={handleMaintenanceUpdated}
|
onMaintenanceUpdated={handleMaintenanceUpdated}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="completed" className="space-y-4">
|
<TabsContent value="completed">
|
||||||
<MaintenanceTable
|
<MaintenanceTable
|
||||||
data={maintenanceData}
|
data={maintenanceData}
|
||||||
isLoading={loading && initialized}
|
isLoading={loading && initialized}
|
||||||
onMaintenanceUpdated={handleMaintenanceUpdated}
|
onMaintenanceUpdated={handleMaintenanceUpdated}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface OverviewCardProps {
|
|||||||
valueClassName?: string;
|
valueClassName?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
gradient?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OverviewCard = ({
|
export const OverviewCard = ({
|
||||||
@@ -30,11 +31,15 @@ export const OverviewCard = ({
|
|||||||
valueClassName,
|
valueClassName,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
color = "blue",
|
color = "blue",
|
||||||
|
gradient,
|
||||||
}: OverviewCardProps) => {
|
}: OverviewCardProps) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
// Map color prop to gradient colors
|
// Map color prop to gradient colors
|
||||||
const getGradientBackground = () => {
|
const getGradientBackground = () => {
|
||||||
|
if (gradient) {
|
||||||
|
return gradient;
|
||||||
|
}
|
||||||
const colors = {
|
const colors = {
|
||||||
blue: theme === 'dark'
|
blue: theme === 'dark'
|
||||||
? "linear-gradient(135deg, rgba(25, 118, 210, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
|
? "linear-gradient(135deg, rgba(25, 118, 210, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ export const useIncidentData = ({ refreshTrigger = 0 }: UseIncidentDataProps) =>
|
|||||||
const fetchIncidentData = useCallback(async (force = false) => {
|
const fetchIncidentData = useCallback(async (force = false) => {
|
||||||
// Skip if already fetching
|
// Skip if already fetching
|
||||||
if (isFetchingRef.current) {
|
if (isFetchingRef.current) {
|
||||||
console.log('Already fetching data, skipping additional request');
|
// console.log('Already fetching data, skipping additional request');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if not forced and already initialized
|
// Skip if not forced and already initialized
|
||||||
if (initialized && !force) {
|
if (initialized && !force) {
|
||||||
console.log('Data already initialized and no force refresh, skipping fetch');
|
// console.log('Data already initialized and no force refresh, skipping fetch');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,22 +46,22 @@ export const useIncidentData = ({ refreshTrigger = 0 }: UseIncidentDataProps) =>
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`Fetching incident data (force=${force})`);
|
// console.log(`Fetching incident data (force=${force})`);
|
||||||
const allIncidents = await incidentService.getAllIncidents(force);
|
const allIncidents = await incidentService.getAllIncidents(force);
|
||||||
|
|
||||||
if (Array.isArray(allIncidents)) {
|
if (Array.isArray(allIncidents)) {
|
||||||
setIncidents(allIncidents);
|
setIncidents(allIncidents);
|
||||||
console.log(`Successfully set ${allIncidents.length} incidents to state`);
|
// console.log(`Successfully set ${allIncidents.length} incidents to state`);
|
||||||
} else {
|
} else {
|
||||||
setIncidents([]);
|
setIncidents([]);
|
||||||
console.warn('No incidents returned from service');
|
// console.warn('No incidents returned from service');
|
||||||
}
|
}
|
||||||
|
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching incident data:', error);
|
// console.error('Error fetching incident data:', error);
|
||||||
setError('Failed to load incident data. Please try again later.');
|
setError('Failed to load incident data. Please try again later.');
|
||||||
setIncidents([]);
|
setIncidents([]);
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
@@ -79,7 +79,7 @@ export const useIncidentData = ({ refreshTrigger = 0 }: UseIncidentDataProps) =>
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if the refresh trigger hasn't changed (prevents duplicate effect calls)
|
// Skip if the refresh trigger hasn't changed (prevents duplicate effect calls)
|
||||||
if (refreshTrigger === lastRefreshTriggerRef.current && initialized) {
|
if (refreshTrigger === lastRefreshTriggerRef.current && initialized) {
|
||||||
console.log('Refresh trigger unchanged, skipping fetch');
|
// console.log('Refresh trigger unchanged, skipping fetch');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ export const useIncidentData = ({ refreshTrigger = 0 }: UseIncidentDataProps) =>
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
console.log(`useIncidentData effect running, refreshTrigger: ${refreshTrigger}`);
|
// console.log(`useIncidentData effect running, refreshTrigger: ${refreshTrigger}`);
|
||||||
|
|
||||||
// Use a longer delay to ensure we don't trigger too many API calls
|
// Use a longer delay to ensure we don't trigger too many API calls
|
||||||
const fetchTimer = setTimeout(() => {
|
const fetchTimer = setTimeout(() => {
|
||||||
@@ -101,7 +101,7 @@ export const useIncidentData = ({ refreshTrigger = 0 }: UseIncidentDataProps) =>
|
|||||||
|
|
||||||
// Cleanup function to abort any in-flight requests and clear timers
|
// Cleanup function to abort any in-flight requests and clear timers
|
||||||
return () => {
|
return () => {
|
||||||
console.log('Cleaning up incident data fetch effect');
|
// console.log('Cleaning up incident data fetch effect');
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
clearTimeout(fetchTimer);
|
clearTimeout(fetchTimer);
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
@@ -112,7 +112,7 @@ export const useIncidentData = ({ refreshTrigger = 0 }: UseIncidentDataProps) =>
|
|||||||
const incidentData = useMemo(() => {
|
const incidentData = useMemo(() => {
|
||||||
if (!initialized || incidents.length === 0) return [];
|
if (!initialized || incidents.length === 0) return [];
|
||||||
|
|
||||||
console.log(`Filtering incidents by: ${filter}`);
|
// console.log(`Filtering incidents by: ${filter}`);
|
||||||
|
|
||||||
if (filter === "unresolved") {
|
if (filter === "unresolved") {
|
||||||
return incidents.filter(item => {
|
return incidents.filter(item => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import React from 'react';
|
|||||||
import { AlertCircle, CheckCircle, Clock, AlertTriangle, Flag } from 'lucide-react';
|
import { AlertCircle, CheckCircle, Clock, AlertTriangle, Flag } from 'lucide-react';
|
||||||
import { useLanguage } from '@/contexts/LanguageContext';
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
import { OverviewCard } from '../common/OverviewCard';
|
import { OverviewCard } from '../common/OverviewCard';
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
|
||||||
interface OverviewStatsProps {
|
interface OverviewStatsProps {
|
||||||
unresolved: number;
|
unresolved: number;
|
||||||
@@ -24,6 +25,7 @@ export const OverviewCards: React.FC<OverviewCardsProps> = ({
|
|||||||
initialized
|
initialized
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||||
@@ -32,35 +34,55 @@ export const OverviewCards: React.FC<OverviewCardsProps> = ({
|
|||||||
value={overviewStats.unresolved.toString()}
|
value={overviewStats.unresolved.toString()}
|
||||||
icon={<AlertCircle className="h-5 w-5 text-white" />}
|
icon={<AlertCircle className="h-5 w-5 text-white" />}
|
||||||
isLoading={loading && initialized}
|
isLoading={loading && initialized}
|
||||||
color="red"
|
gradient={
|
||||||
|
theme === "dark"
|
||||||
|
? "linear-gradient(135deg, #4b3b37 0%, rgba(239, 83, 80, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #4b3b37 0%, rgba(239, 83, 80, 0.6) 100%)"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<OverviewCard
|
<OverviewCard
|
||||||
title={t('criticalIssues')}
|
title={t('criticalIssues')}
|
||||||
value={overviewStats.critical.toString()}
|
value={overviewStats.critical.toString()}
|
||||||
icon={<AlertTriangle className="h-5 w-5 text-white" />}
|
icon={<AlertTriangle className="h-5 w-5 text-white" />}
|
||||||
isLoading={loading && initialized}
|
isLoading={loading && initialized}
|
||||||
color="amber"
|
gradient={
|
||||||
|
theme === "dark"
|
||||||
|
? "linear-gradient(135deg, #4b3b37 0%, rgba(255, 183, 77, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #4b3b37 0%, rgba(255, 183, 77, 0.6) 100%)"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<OverviewCard
|
<OverviewCard
|
||||||
title={t('highPriority')}
|
title={t('highPriority')}
|
||||||
value={overviewStats.highPriority.toString()}
|
value={overviewStats.highPriority.toString()}
|
||||||
icon={<Flag className="h-5 w-5 text-white" />}
|
icon={<Flag className="h-5 w-5 text-white" />}
|
||||||
isLoading={loading && initialized}
|
isLoading={loading && initialized}
|
||||||
color="orange"
|
gradient={
|
||||||
|
theme === "dark"
|
||||||
|
? "linear-gradient(135deg, #4b3b37 0%, rgba(255, 109, 0, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #4b3b37 0%, rgba(255, 109, 0, 0.6) 100%)"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<OverviewCard
|
<OverviewCard
|
||||||
title={t('resolvedIncidents')}
|
title={t('resolvedIncidents')}
|
||||||
value={overviewStats.resolved.toString()}
|
value={overviewStats.resolved.toString()}
|
||||||
icon={<CheckCircle className="h-5 w-5 text-white" />}
|
icon={<CheckCircle className="h-5 w-5 text-white" />}
|
||||||
isLoading={loading && initialized}
|
isLoading={loading && initialized}
|
||||||
color="green"
|
gradient={
|
||||||
|
theme === "dark"
|
||||||
|
? "linear-gradient(135deg, #4b3b37 0%, rgba(102, 187, 106, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #4b3b37 0%, rgba(102, 187, 106, 0.6) 100%)"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<OverviewCard
|
<OverviewCard
|
||||||
title={t('avgResolutionTime')}
|
title={t('avgResolutionTime')}
|
||||||
value={overviewStats.avgResolutionTime}
|
value={overviewStats.avgResolutionTime}
|
||||||
icon={<Clock className="h-5 w-5 text-white" />}
|
icon={<Clock className="h-5 w-5 text-white" />}
|
||||||
isLoading={loading && initialized}
|
isLoading={loading && initialized}
|
||||||
color="blue"
|
gradient={
|
||||||
|
theme === "dark"
|
||||||
|
? "linear-gradient(135deg, #4b3b37 0%, rgba(66, 165, 245, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #4b3b37 0%, rgba(66, 165, 245, 0.6) 100%)"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,19 +25,20 @@ export const IncidentDetailContent = ({
|
|||||||
onClose,
|
onClose,
|
||||||
assignedUser
|
assignedUser
|
||||||
}: IncidentDetailContentProps) => {
|
}: IncidentDetailContentProps) => {
|
||||||
// Fetch assigned user details if one wasn't provided and there's an assigned_to field
|
// Fetch assigned user details if none was provided; prefer server field
|
||||||
|
const assigneeId = incident?.assigned_users || incident?.assigned_to;
|
||||||
const { data: fetchedUser } = useQuery({
|
const { data: fetchedUser } = useQuery({
|
||||||
queryKey: ['user', incident?.assigned_to],
|
queryKey: ['user', assigneeId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!incident?.assigned_to) return null;
|
if (!assigneeId) return null;
|
||||||
try {
|
try {
|
||||||
return await userService.getUser(incident.assigned_to);
|
return await userService.getUser(assigneeId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch assigned user:", error);
|
console.error("Failed to fetch assigned user:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled: !!incident?.assigned_to && !assignedUser,
|
enabled: !!assigneeId && !assignedUser,
|
||||||
staleTime: 300000 // Cache for 5 minutes
|
staleTime: 300000 // Cache for 5 minutes
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const IncidentDetailDialog = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
incident
|
incident
|
||||||
}: IncidentDetailDialogProps) => {
|
}: IncidentDetailDialogProps) => {
|
||||||
// Fetch user data for assigned_to field
|
// Fetch user data for assigned field (prefer assigned_users, fallback to assigned_to)
|
||||||
const { data: users = [] } = useQuery({
|
const { data: users = [] } = useQuery({
|
||||||
queryKey: ['users'],
|
queryKey: ['users'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -25,12 +25,12 @@ export const IncidentDetailDialog = ({
|
|||||||
return Array.isArray(usersList) ? usersList : [];
|
return Array.isArray(usersList) ? usersList : [];
|
||||||
},
|
},
|
||||||
staleTime: 300000, // Cache for 5 minutes
|
staleTime: 300000, // Cache for 5 minutes
|
||||||
enabled: !!incident?.assigned_to && open // Only run query if there's an assigned_to value and dialog is open
|
enabled: !!(incident?.assigned_users || incident?.assigned_to) && open // Only run query if there's an assigned value and dialog is open
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find the assigned user
|
// Find the assigned user (prefer assigned_users, fallback to assigned_to)
|
||||||
const assignedUser = incident?.assigned_to
|
const assignedUser = (incident?.assigned_users || incident?.assigned_to)
|
||||||
? users.find(user => user.id === incident.assigned_to)
|
? users.find(user => user.id === (incident?.assigned_users || incident?.assigned_to))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!incident) return null;
|
if (!incident) return null;
|
||||||
|
|||||||
@@ -23,19 +23,19 @@ export {
|
|||||||
|
|
||||||
// Legacy component - keeping this for backward compatibility with other imports
|
// Legacy component - keeping this for backward compatibility with other imports
|
||||||
export const IncidentDetailSections = ({ incident }: { incident: IncidentItem | null }) => {
|
export const IncidentDetailSections = ({ incident }: { incident: IncidentItem | null }) => {
|
||||||
// Fetch assigned user details if there's an assigned_to field
|
// Fetch assigned user details if there's an assigned field (prefer assigned_users, fallback to assigned_to)
|
||||||
const { data: assignedUser } = useQuery({
|
const { data: assignedUser } = useQuery({
|
||||||
queryKey: ['user', incident?.assigned_to],
|
queryKey: ['user', incident?.assigned_users || incident?.assigned_to],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!incident?.assigned_to) return null;
|
if (!(incident?.assigned_users || incident?.assigned_to)) return null;
|
||||||
try {
|
try {
|
||||||
return await userService.getUser(incident.assigned_to);
|
return await userService.getUser(incident?.assigned_users || incident?.assigned_to);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch assigned user:", error);
|
console.error("Failed to fetch assigned user:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled: !!incident?.assigned_to,
|
enabled: !!(incident?.assigned_users || incident?.assigned_to),
|
||||||
staleTime: 300000 // Cache for 5 minutes
|
staleTime: 300000 // Cache for 5 minutes
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export const AssignmentSection: React.FC<AssignmentSectionProps> = ({ incident,
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<span>{assignedUser.full_name || assignedUser.username}</span>
|
<span>{assignedUser.full_name || assignedUser.username}</span>
|
||||||
</div>
|
</div>
|
||||||
) : incident.assigned_to ? (
|
) : (incident.assigned_users || incident.assigned_to) ? (
|
||||||
<span>{incident.assigned_to}</span>
|
<span>{incident.assigned_users || incident.assigned_to}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground italic">{t('unassigned')}</span>
|
<span className="text-muted-foreground italic">{t('unassigned')}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export const BasicInfoSection: React.FC<BasicInfoSectionProps> = ({ incident, as
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<span>{assignedUser.full_name || assignedUser.username}</span>
|
<span>{assignedUser.full_name || assignedUser.username}</span>
|
||||||
</div>
|
</div>
|
||||||
) : incident.assigned_to ? (
|
) : (incident.assigned_users || incident.assigned_to) ? (
|
||||||
<span>{incident.assigned_to}</span>
|
<span>{incident.assigned_users || incident.assigned_to}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground italic">{t('unassigned')}</span>
|
<span className="text-muted-foreground italic">{t('unassigned')}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ export const IncidentBasicFields: React.FC = () => {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const usersList = await userService.getUsers();
|
const usersList = await userService.getUsers();
|
||||||
console.log("Fetched users for incident assignment:", usersList);
|
// console.log("Fetched users for incident assignment:", usersList);
|
||||||
return Array.isArray(usersList) ? usersList : [];
|
return Array.isArray(usersList) ? usersList : [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch users:", error);
|
// console.error("Failed to fetch users:", error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -53,7 +53,7 @@ export const IncidentBasicFields: React.FC = () => {
|
|||||||
// Add user to assigned_to
|
// Add user to assigned_to
|
||||||
const addUser = (userId: string) => {
|
const addUser = (userId: string) => {
|
||||||
// For now, we're using a single user assignment
|
// For now, we're using a single user assignment
|
||||||
console.log("Setting user ID in form:", userId);
|
// console.log("Setting user ID in form:", userId);
|
||||||
form.setValue('assigned_to', userId, { shouldValidate: true, shouldDirty: true });
|
form.setValue('assigned_to', userId, { shouldValidate: true, shouldDirty: true });
|
||||||
setSelectedUserIds([userId]);
|
setSelectedUserIds([userId]);
|
||||||
};
|
};
|
||||||
@@ -68,7 +68,7 @@ export const IncidentBasicFields: React.FC = () => {
|
|||||||
const selectedUser = users.find(user => user.id === form.getValues('assigned_to'));
|
const selectedUser = users.find(user => user.id === form.getValues('assigned_to'));
|
||||||
|
|
||||||
// Function to get user initials from name
|
// Function to get user initials from name
|
||||||
const getUserInitials = (user: any): string => {
|
const getUserInitials = (user: { full_name?: string; username: string }): string => {
|
||||||
if (user.full_name) {
|
if (user.full_name) {
|
||||||
const nameParts = user.full_name.split(' ');
|
const nameParts = user.full_name.split(' ');
|
||||||
if (nameParts.length > 1) {
|
if (nameParts.length > 1) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const useIncidentEditForm = (
|
|||||||
impact: (incident.impact?.toLowerCase() || 'minor') as any,
|
impact: (incident.impact?.toLowerCase() || 'minor') as any,
|
||||||
priority: (incident.priority?.toLowerCase() || 'medium') as any,
|
priority: (incident.priority?.toLowerCase() || 'medium') as any,
|
||||||
service_id: incident.service_id || '',
|
service_id: incident.service_id || '',
|
||||||
assigned_to: incident.assigned_to || '',
|
assigned_to: incident.assigned_users || incident.assigned_to || '',
|
||||||
root_cause: incident.root_cause || '',
|
root_cause: incident.root_cause || '',
|
||||||
resolution_steps: incident.resolution_steps || '',
|
resolution_steps: incident.resolution_steps || '',
|
||||||
lessons_learned: incident.lessons_learned || '',
|
lessons_learned: incident.lessons_learned || '',
|
||||||
@@ -45,11 +45,14 @@ export const useIncidentEditForm = (
|
|||||||
impact: data.impact,
|
impact: data.impact,
|
||||||
priority: data.priority,
|
priority: data.priority,
|
||||||
service_id: data.service_id,
|
service_id: data.service_id,
|
||||||
assigned_to: data.assigned_to, // This is the user ID from the form
|
...(data.assigned_to
|
||||||
|
? { assigned_to: data.assigned_to, assigned_users: data.assigned_to }
|
||||||
|
: {}),
|
||||||
root_cause: data.root_cause,
|
root_cause: data.root_cause,
|
||||||
resolution_steps: data.resolution_steps,
|
resolution_steps: data.resolution_steps,
|
||||||
lessons_learned: data.lessons_learned,
|
lessons_learned: data.lessons_learned,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t('incidentUpdated'),
|
title: t('incidentUpdated'),
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const useIncidentForm = (onSuccess: () => void, onClose: () => void) => {
|
|||||||
priority: data.priority,
|
priority: data.priority,
|
||||||
service_id: data.service_id,
|
service_id: data.service_id,
|
||||||
assigned_to: data.assigned_to,
|
assigned_to: data.assigned_to,
|
||||||
|
assigned_users: data.assigned_to, // map to server field
|
||||||
root_cause: data.root_cause,
|
root_cause: data.root_cause,
|
||||||
resolution_steps: data.resolution_steps,
|
resolution_steps: data.resolution_steps,
|
||||||
lessons_learned: data.lessons_learned,
|
lessons_learned: data.lessons_learned,
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export const IncidentTableRow = memo(({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<AssignedUserCell userId={localItem.assigned_to} />
|
<AssignedUserCell userId={localItem.assigned_users || localItem.assigned_to} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex justify-end items-center space-x-2">
|
<div className="flex justify-end items-center space-x-2">
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const MaintenanceNotificationSettingsField = () => {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const channels = await alertConfigService.getAlertConfigurations();
|
const channels = await alertConfigService.getAlertConfigurations();
|
||||||
console.log("Fetched notification channels for form:", channels);
|
// console.log("Fetched notification channels for form:", channels);
|
||||||
|
|
||||||
// Only show enabled channels
|
// Only show enabled channels
|
||||||
const enabledChannels = channels.filter(channel => channel.enabled);
|
const enabledChannels = channels.filter(channel => channel.enabled);
|
||||||
@@ -38,18 +38,18 @@ export const MaintenanceNotificationSettingsField = () => {
|
|||||||
const currentChannel = getValues('notification_channel_id');
|
const currentChannel = getValues('notification_channel_id');
|
||||||
const shouldNotify = getValues('notify_subscribers');
|
const shouldNotify = getValues('notify_subscribers');
|
||||||
|
|
||||||
console.log("Current notification values:", {
|
// console.log("Current notification values:", {
|
||||||
currentChannel,
|
// currentChannel,
|
||||||
shouldNotify,
|
// shouldNotify,
|
||||||
availableChannels: enabledChannels.length
|
// availableChannels: enabledChannels.length
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (shouldNotify && (!currentChannel || currentChannel === 'none') && enabledChannels.length > 0) {
|
if (shouldNotify && (!currentChannel || currentChannel === 'none') && enabledChannels.length > 0) {
|
||||||
console.log("Setting default notification channel:", enabledChannels[0].id);
|
// console.log("Setting default notification channel:", enabledChannels[0].id);
|
||||||
setValue('notification_channel_id', enabledChannels[0].id);
|
setValue('notification_channel_id', enabledChannels[0].id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching notification channels:', error);
|
// console.error('Error fetching notification channels:', error);
|
||||||
toast({
|
toast({
|
||||||
title: t('error'),
|
title: t('error'),
|
||||||
description: t('errorFetchingNotificationChannels'),
|
description: t('errorFetchingNotificationChannels'),
|
||||||
@@ -64,12 +64,12 @@ export const MaintenanceNotificationSettingsField = () => {
|
|||||||
}, [t, toast, setValue, getValues]);
|
}, [t, toast, setValue, getValues]);
|
||||||
|
|
||||||
// Log value changes for debugging
|
// Log value changes for debugging
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
console.log("Current notification settings:", {
|
// console.log("Current notification settings:", {
|
||||||
channel_id: getValues('notification_channel_id'),
|
// channel_id: getValues('notification_channel_id'),
|
||||||
notify: notifySubscribers
|
// notify: notifySubscribers
|
||||||
});
|
// });
|
||||||
}, [notifySubscribers, notificationChannelId, getValues]);
|
// }, [notifySubscribers, notificationChannelId, getValues]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -98,7 +98,7 @@ export const MaintenanceNotificationSettingsField = () => {
|
|||||||
checked={field.value}
|
checked={field.value}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
field.onChange(checked);
|
field.onChange(checked);
|
||||||
console.log("Notification toggle changed to:", checked);
|
// console.log("Notification toggle changed to:", checked);
|
||||||
// If notifications are disabled, also clear the notification channel
|
// If notifications are disabled, also clear the notification channel
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
setValue('notification_channel_id', '');
|
setValue('notification_channel_id', '');
|
||||||
@@ -120,10 +120,10 @@ export const MaintenanceNotificationSettingsField = () => {
|
|||||||
// Make sure to handle both empty string and "none" as special cases
|
// Make sure to handle both empty string and "none" as special cases
|
||||||
const displayValue = field.value || "";
|
const displayValue = field.value || "";
|
||||||
|
|
||||||
console.log("Rendering notification channel field with value:", {
|
// console.log("Rendering notification channel field with value:", {
|
||||||
fieldValue: field.value,
|
// fieldValue: field.value,
|
||||||
displayValue
|
// displayValue
|
||||||
});
|
// });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
@@ -135,7 +135,7 @@ export const MaintenanceNotificationSettingsField = () => {
|
|||||||
<Select
|
<Select
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
console.log("Setting notification channel to:", value);
|
// console.log("Setting notification channel to:", value);
|
||||||
field.onChange(value === "none" ? "" : value);
|
field.onChange(value === "none" ? "" : value);
|
||||||
}}
|
}}
|
||||||
disabled={!notifySubscribers}
|
disabled={!notifySubscribers}
|
||||||
|
|||||||