Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72984f5da4 | |||
| 2d2122d2e5 | |||
| d17a70dfa8 | |||
| 3f3ce9122d | |||
| 60960cb3e0 | |||
| 50c742d82d | |||
| d0ecaaaf8f | |||
| ff19d46391 | |||
| e5afe276ee | |||
| c098ecb6bf | |||
| dc586dc15f | |||
| d40d5898c0 | |||
| 63d63a908a | |||
| 868832f5ba | |||
| c1c94115e0 | |||
| e388912cee | |||
| 6c227f496e | |||
| 0cad374616 | |||
| 6906a65952 | |||
| b9cdff37d5 | |||
| 88877a9fc0 | |||
| 2de3a17967 | |||
| b4a12a46df | |||
| cc132a66d9 | |||
| e4284afb09 | |||
| 664e2c8ecf | |||
| 7c08c8bc66 | |||
| fd7035965f | |||
| debf35703b | |||
| 213446422e | |||
| 79beb20b65 | |||
| f235043417 | |||
| 6512d4974a | |||
| 73f2566d78 | |||
| 46077e8deb | |||
| d72d6fb009 | |||
| 0ba3b3182b | |||
| 24c5a9957f | |||
| cb258d72c3 | |||
| 3f975db568 | |||
| 0941250867 | |||
| 7ef4d27148 | |||
| 2621544b24 | |||
| 9fd977ed15 | |||
| 1b40a1b663 | |||
| 53d8b110c9 | |||
| a013f9170a | |||
| 5daea8138c | |||
| 59dcdcb5df | |||
| 35e161e40d | |||
| de79eb963d | |||
| 7fd6d07c10 | |||
| 547a4c4041 | |||
| 2d2bd790b0 | |||
| 917d8a6d29 | |||
| ddeb22dc4d | |||
| e29f60447d | |||
| e355e771d1 | |||
| f4aeaebe0d | |||
| 6b49119797 | |||
| be7c80d7c4 | |||
| 08f31835e1 | |||
| 5d2b8b2ed2 | |||
| f0eec90e75 | |||
| 671f22c1d3 | |||
| ad869c17aa | |||
| 6702bfa5e3 | |||
| cb6946b7dd | |||
| f80b2c8a51 | |||
| dc9e8b981a | |||
| 306d2fc336 | |||
| d072ba7d23 | |||
| 8bd8ae25e6 | |||
| 1113ede9a6 | |||
| 1e84db4ecc | |||
| 20d5e86d3c | |||
| 549dcc21e4 | |||
| f97bc4f62f | |||
| d6793f104c | |||
| eddc12018f | |||
| bdcf5c75b9 | |||
| 4d68c6b8a9 | |||
| fda40de155 | |||
| 9b1ccc7f8d | |||
| d183ca9791 | |||
| 48ddc2b4c6 | |||
| 30bba14e37 | |||
| 474a439515 | |||
| 460fcbc7dc | |||
| 0b029dc914 | |||
| 691cd4f298 | |||
| 4bfeb42142 | |||
| c1fa02c380 | |||
| 15f0e93524 | |||
| b37a84734f | |||
| 9a4cc7c3db | |||
| c979dd24d8 | |||
| fd08a64a05 | |||
| c30bcf9e73 | |||
| 7b18a224c1 | |||
| 202a09993c | |||
| 094f5fcd5e | |||
| ab4f0aa6ea | |||
| 35b11f5a16 | |||
| 1b11deb40f | |||
| 879e106051 | |||
| 044f9e07fd | |||
| 23f3c5b8ca | |||
| ded509bd10 | |||
| 1e7c20593f | |||
| d385f24c28 | |||
| 57a65106cb | |||
| ea95490f13 |
@@ -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,53 @@
|
|||||||
|
name: ❓ Ask for Help
|
||||||
|
description: Ask a question or request guidance about this project.
|
||||||
|
title: "[Question]: "
|
||||||
|
labels: [question]
|
||||||
|
assignees: []
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
**Thank you for reaching out!**
|
||||||
|
Please fill out this form to help us understand your question.
|
||||||
|
|
||||||
|
- 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: Context
|
||||||
|
description: |
|
||||||
|
Provide any additional context, related issues, or links that help explain your question.
|
||||||
|
placeholder: "Related issues, pull requests, or documentation..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment Details
|
||||||
|
description: |
|
||||||
|
If applicable, please provide details about your environment (OS, Node version, browser, etc.).
|
||||||
|
placeholder: "Example: Ubuntu 22.04, Node.js v20, Chrome 125"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Before submitting, please confirm:
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and discussions.
|
||||||
|
required: true
|
||||||
|
- label: I have read the documentation.
|
||||||
|
required: true
|
||||||
@@ -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 fill out the following template so we can reproduce and fix the issue faster.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment
|
||||||
|
description: |
|
||||||
|
Describe where you encountered the issue.
|
||||||
|
(e.g., OS, browser, Node version, etc.)
|
||||||
|
placeholder: "Example: macOS 14.0, 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 what the bug is.
|
||||||
|
placeholder: "When I do X, Y happens instead of Z..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: |
|
||||||
|
How can we reproduce the behavior?
|
||||||
|
Please list the steps in order.
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. Scroll down to '...'
|
||||||
|
4. See 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 explain your problem.
|
||||||
|
placeholder: "Attach screenshots here."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs
|
||||||
|
description: |
|
||||||
|
If applicable, paste logs or error messages here.
|
||||||
|
Please remove any sensitive information.
|
||||||
|
render: shell
|
||||||
|
placeholder: |
|
||||||
|
```
|
||||||
|
Error: Something went wrong
|
||||||
|
at index.js:123:45
|
||||||
|
```
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: terms
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Before submitting, please confirm:
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues to avoid duplicates.
|
||||||
|
required: true
|
||||||
|
- label: I have provided enough information for reproduction.
|
||||||
|
required: true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -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,63 @@
|
|||||||
|
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,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
|
||||||
@@ -24,3 +24,4 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
application/src/lib/pocketbase.ts
|
application/src/lib/pocketbase.ts
|
||||||
Dockerfile
|
Dockerfile
|
||||||
|
application/src/lib/pocketbase-prod.ts
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders of the **CheckCle** project pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our community include:
|
||||||
|
|
||||||
|
- Demonstrating empathy and kindness toward other people
|
||||||
|
- Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
- Giving and gracefully accepting constructive feedback
|
||||||
|
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||||
|
- Focusing on what is best not just for us as individuals, but for the overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
- The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||||
|
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
- Public or private harassment
|
||||||
|
- Publishing others’ private information, such as a physical or email address, without their explicit permission
|
||||||
|
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||||
|
|
||||||
|
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all project spaces, and also applies when an individual is officially representing the project in public spaces. Examples include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project team at:
|
||||||
|
|
||||||
|
📧 **Email:** [hello@checkcle.io](mailto:hello@checkcle.io)
|
||||||
|
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All project maintainers are obligated to respect the privacy and security of the reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Project maintainers will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
**1. Correction**
|
||||||
|
|
||||||
|
- *Community Impact:* Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||||
|
- *Consequence:* A private, written warning from project maintainers, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
**2. Warning**
|
||||||
|
|
||||||
|
- *Community Impact:* A violation through a single incident or series of actions.
|
||||||
|
- *Consequence:* A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||||
|
|
||||||
|
**3. Temporary Ban**
|
||||||
|
|
||||||
|
- *Community Impact:* A serious violation of community standards, including sustained inappropriate behavior.
|
||||||
|
- *Consequence:* A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
**4. Permanent Ban**
|
||||||
|
|
||||||
|
- *Community Impact:* Demonstrating a pattern of violation of community standards, including sustained harassment, or showing no remorse after being warned.
|
||||||
|
- *Consequence:* A permanent ban from any sort of public interaction within the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at:
|
||||||
|
|
||||||
|
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see:
|
||||||
|
|
||||||
|
https://www.contributor-covenant.org/faq
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
@@ -1,3 +1,36 @@
|
|||||||
|
## 🌐 Select Language
|
||||||
|
|
||||||
|
<table align="center">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="CONTRIBUTING.md">
|
||||||
|
<img src="https://flagcdn.com/24x18/gb.png" alt="English" />
|
||||||
|
<br/><strong>English</strong>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="docs/CONTRIBUTING_km.md">
|
||||||
|
<img src="https://flagcdn.com/24x18/kh.png" alt="Khmer" />
|
||||||
|
<br/><strong>ខ្មែរ</strong>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="docs/CONTRIBUTING_ja.md">
|
||||||
|
<img src="https://flagcdn.com/24x18/jp.png" alt="Japanese" />
|
||||||
|
<br/><strong>Japanese</strong>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Thank you to all our contributors, users, and supporters for making this project thrive.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
🚀 <strong>Stay tuned for more updates, features, and improvements.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
# 🛠️ Contributing to CheckCle
|
# 🛠️ Contributing to 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 --branch main https://github.com/operacle/Distributed-Regional-Monitoring.git
|
||||||
|
cd Distributed-Regional-Monitoring
|
||||||
|
|
||||||
|
#### 3. Install Go Service (make sure you have installed the Go Service)
|
||||||
|
|
||||||
|
Copy .env.example -> .env
|
||||||
|
Change regional agent configuration in .env file
|
||||||
|
and Run: go run main.go
|
||||||
|
|
||||||
|
```
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Pull Request Process
|
## ✅ 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,37 @@
|
|||||||

|
## 🌐 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>
|
||||||
|
</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?
|
||||||
|
|
||||||
@@ -13,6 +46,7 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
|
|||||||
- 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.
|
||||||
@@ -76,9 +110,11 @@ 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
|
## 📝 Development Roadmap
|
||||||
|
|
||||||
@@ -92,10 +128,10 @@ services:
|
|||||||
- ✅ SSL & Domain Monitoring
|
- ✅ SSL & Domain Monitoring
|
||||||
- ✅ Schedule Maintenance
|
- ✅ Schedule Maintenance
|
||||||
- ✅ Incident Management
|
- ✅ Incident Management
|
||||||
- [ ] Uptime monitoring (PING - Inprogress)
|
|
||||||
- [ ] Infrastructure Server Monitoring
|
- [ ] Infrastructure Server Monitoring
|
||||||
- ✅ Operational Status / Public Status Pages
|
- ✅ Operational Status / Public Status Pages
|
||||||
- [ ] Uptime monitoring (TCP, PING, DNS)
|
- ✅ Uptime monitoring (HTTP, TCP, PING, DNS) Full functionality
|
||||||
|
- ✅ Distributed Regional Monitoring Agent [Support Operation](https://github.com/operacle/Distributed-Regional-Monitoring)
|
||||||
- ✅ System Setting Panel and Mail Settings
|
- ✅ System Setting Panel and Mail Settings
|
||||||
- ✅ User Permission Roles
|
- ✅ User Permission Roles
|
||||||
- [ ] Notifications (Email/Slack/Discord/Signal)
|
- [ ] Notifications (Email/Slack/Discord/Signal)
|
||||||
@@ -134,6 +170,10 @@ Here are some ways you can help improve CheckCle:
|
|||||||
CheckCle is released under the MIT License.
|
CheckCle is released under the MIT License.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 👥 Contributors
|
||||||
|
|
||||||
|
[](https://github.com/operacle/checkcle/graphs/contributors)
|
||||||
|
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -47,22 +47,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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
|
|
||||||
<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>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 49 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}</>;
|
||||||
|
};
|
||||||
@@ -53,7 +53,7 @@ export const Header = ({
|
|||||||
// Log avatar data for debugging
|
// Log avatar data for debugging
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
console.log("Avatar URL in Header:", currentUser.avatar);
|
// console.log("Avatar URL in Header:", currentUser.avatar);
|
||||||
}
|
}
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export const Header = ({
|
|||||||
} else {
|
} else {
|
||||||
avatarUrl = currentUser.avatar;
|
avatarUrl = currentUser.avatar;
|
||||||
}
|
}
|
||||||
console.log("Final avatar URL:", avatarUrl);
|
// console.log("Final avatar URL:", avatarUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -129,6 +129,9 @@ export const Header = ({
|
|||||||
<DropdownMenuItem onClick={() => setLanguage("de")} className={language === "de" ? "bg-accent" : ""}>
|
<DropdownMenuItem onClick={() => setLanguage("de")} className={language === "de" ? "bg-accent" : ""}>
|
||||||
{t("Deutsch")}
|
{t("Deutsch")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setLanguage("ja")} className={language === "ja" ? "bg-accent" : ""}>
|
||||||
|
{t("japanese")}
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ export const Sidebar = ({ collapsed }: SidebarProps) => {
|
|||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
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`}>
|
<div
|
||||||
|
className={`
|
||||||
|
${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'}
|
||||||
|
border-r flex flex-col h-full
|
||||||
|
${collapsed ? 'w-16' : 'w-64'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
<SidebarHeader collapsed={collapsed} />
|
<SidebarHeader collapsed={collapsed} />
|
||||||
<MainNavigation collapsed={collapsed} />
|
<MainNavigation collapsed={collapsed} />
|
||||||
<SettingsPanel collapsed={collapsed} />
|
<SettingsPanel collapsed={collapsed} />
|
||||||
|
|||||||
@@ -27,15 +27,15 @@ 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'
|
||||||
}}
|
}}
|
||||||
@@ -59,15 +59,15 @@ 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'
|
||||||
}}
|
}}
|
||||||
@@ -91,15 +91,15 @@ 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'
|
||||||
}}
|
}}
|
||||||
@@ -123,15 +123,15 @@ 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'
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export const MenuItem: React.FC<MenuItemProps> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (hasNavigation && path) {
|
if (hasNavigation && path) {
|
||||||
// Use navigate instead of window.location to prevent full page reload
|
|
||||||
navigate(path, { replace: false });
|
navigate(path, { replace: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -44,11 +43,22 @@ export const MenuItem: React.FC<MenuItemProps> = ({
|
|||||||
|
|
||||||
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={`
|
||||||
|
${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' : ''}
|
||||||
|
cursor-pointer
|
||||||
|
`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<Icon className={`${mainIconSize} ${color}`} />
|
<Icon className={`${mainIconSize} ${color}`} />
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t(translationKey)}</span>}
|
{!collapsed && (
|
||||||
|
<span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">
|
||||||
|
{t(translationKey)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</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,97 @@
|
|||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Table, TableBody } from "@/components/ui/table";
|
||||||
|
import { DockerContainer } from "@/types/docker.types";
|
||||||
|
import { DockerMetricsDialog } from "./DockerMetricsDialog";
|
||||||
|
import {
|
||||||
|
DockerTableSearch,
|
||||||
|
DockerTableHeader,
|
||||||
|
DockerTableRow,
|
||||||
|
DockerEmptyState
|
||||||
|
} from "./table";
|
||||||
|
|
||||||
|
interface DockerContainersTableProps {
|
||||||
|
containers: DockerContainer[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockerContainersTable = ({ containers, isLoading, onRefresh }: DockerContainersTableProps) => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedContainer, setSelectedContainer] = useState<DockerContainer | null>(null);
|
||||||
|
const [metricsDialogOpen, setMetricsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const filteredContainers = containers.filter(container =>
|
||||||
|
container.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
container.docker_id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
container.hostname.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleContainerAction = (action: string, containerId: string, containerName: string) => {
|
||||||
|
console.log(`${action} action for container ${containerName} (${containerId})`);
|
||||||
|
// TODO: Implement container actions
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowClick = (container: DockerContainer) => {
|
||||||
|
setSelectedContainer(container);
|
||||||
|
setMetricsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewMetrics = (container: DockerContainer) => {
|
||||||
|
setSelectedContainer(container);
|
||||||
|
setMetricsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="w-full bg-transparent border-0 shadow-none">
|
||||||
|
<CardHeader className="pb-4 px-0">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<CardTitle className="text-lg sm:text-xl font-semibold">Docker Containers</CardTitle>
|
||||||
|
<DockerTableSearch
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
onSearchChange={setSearchTerm}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-full inline-block align-middle">
|
||||||
|
<div className="overflow-hidden border border-border rounded-lg shadow-sm">
|
||||||
|
<Table>
|
||||||
|
<DockerTableHeader />
|
||||||
|
<TableBody>
|
||||||
|
{filteredContainers.length === 0 ? (
|
||||||
|
<DockerEmptyState searchTerm={searchTerm} />
|
||||||
|
) : (
|
||||||
|
filteredContainers.map((container) => (
|
||||||
|
<DockerTableRow
|
||||||
|
key={container.id}
|
||||||
|
container={container}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
onContainerAction={handleContainerAction}
|
||||||
|
onViewMetrics={handleViewMetrics}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<DockerMetricsDialog
|
||||||
|
container={selectedContainer}
|
||||||
|
open={metricsDialogOpen}
|
||||||
|
onOpenChange={setMetricsDialogOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer, AreaChart, Area } from "recharts";
|
||||||
|
import { DockerContainer, DockerMetrics } from "@/types/docker.types";
|
||||||
|
import { dockerService } from "@/services/dockerService";
|
||||||
|
import { Loader2, Cpu, HardDrive, Network, MemoryStick } from "lucide-react";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
interface DockerMetricsDialogProps {
|
||||||
|
container: DockerContainer | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
|
||||||
|
|
||||||
|
const timeRangeOptions = [
|
||||||
|
{ value: '60m' as TimeRange, label: '60 minutes', hours: 1 },
|
||||||
|
{ value: '1d' as TimeRange, label: '1 day', hours: 24 },
|
||||||
|
{ value: '7d' as TimeRange, label: '7 days', hours: 24 * 7 },
|
||||||
|
{ value: '1m' as TimeRange, label: '1 month', hours: 24 * 30 },
|
||||||
|
{ value: '3m' as TimeRange, label: '3 months', hours: 24 * 90 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMetricsDialogProps) => {
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: metrics = [],
|
||||||
|
isLoading,
|
||||||
|
error
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['docker-metrics', container?.docker_id, timeRange],
|
||||||
|
queryFn: () => container ? dockerService.getContainerMetrics(container.docker_id) : Promise.resolve([]),
|
||||||
|
enabled: !!container && open,
|
||||||
|
refetchInterval: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number, decimals = 2) => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseValueWithUnit = (value: string | number): { numeric: number; unit: string; original: string } => {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return { numeric: value, unit: 'B', original: value.toString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = value.toString();
|
||||||
|
const match = str.match(/^([\d.]+)\s*([A-Za-z%]*)/);
|
||||||
|
if (match) {
|
||||||
|
const numeric = parseFloat(match[1]);
|
||||||
|
const unit = match[2] || '';
|
||||||
|
return { numeric, unit, original: str };
|
||||||
|
}
|
||||||
|
return { numeric: 0, unit: '', original: str };
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertToBytes = (value: string | number): number => {
|
||||||
|
if (typeof value === 'number') return value;
|
||||||
|
|
||||||
|
const parsed = parseValueWithUnit(value);
|
||||||
|
const multipliers: { [key: string]: number } = {
|
||||||
|
'B': 1,
|
||||||
|
'KB': 1024,
|
||||||
|
'MB': 1024 * 1024,
|
||||||
|
'GB': 1024 * 1024 * 1024,
|
||||||
|
'TB': 1024 * 1024 * 1024 * 1024
|
||||||
|
};
|
||||||
|
|
||||||
|
const multiplier = multipliers[parsed.unit.toUpperCase()] || 1;
|
||||||
|
return parsed.numeric * multiplier;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterMetricsByTimeRange = (metrics: DockerMetrics[], timeRange: TimeRange): DockerMetrics[] => {
|
||||||
|
const now = new Date();
|
||||||
|
const selectedRange = timeRangeOptions.find(opt => opt.value === timeRange);
|
||||||
|
if (!selectedRange) return metrics;
|
||||||
|
|
||||||
|
const cutoffTime = new Date(now.getTime() - (selectedRange.hours * 60 * 60 * 1000));
|
||||||
|
|
||||||
|
return metrics.filter(metric => {
|
||||||
|
const metricTime = new Date(metric.timestamp);
|
||||||
|
return metricTime >= cutoffTime;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatChartData = (metrics: DockerMetrics[]) => {
|
||||||
|
const filteredMetrics = filterMetricsByTimeRange(metrics, timeRange);
|
||||||
|
|
||||||
|
return filteredMetrics.slice(0, 100).reverse().map((metric, index) => {
|
||||||
|
// Parse CPU usage
|
||||||
|
const cpuUsage = typeof metric.cpu_usage === 'string' ?
|
||||||
|
parseFloat(metric.cpu_usage.replace('%', '')) :
|
||||||
|
parseFloat(metric.cpu_usage) || 0;
|
||||||
|
|
||||||
|
// Parse memory values
|
||||||
|
const ramUsedBytes = convertToBytes(metric.ram_used);
|
||||||
|
const ramTotalBytes = convertToBytes(metric.ram_total);
|
||||||
|
const ramFreeBytes = convertToBytes(metric.ram_free);
|
||||||
|
const ramUsagePercent = ramTotalBytes > 0 ? (ramUsedBytes / ramTotalBytes) * 100 : 0;
|
||||||
|
|
||||||
|
// Parse disk values
|
||||||
|
const diskUsedBytes = convertToBytes(metric.disk_used);
|
||||||
|
const diskTotalBytes = convertToBytes(metric.disk_total);
|
||||||
|
const diskFreeBytes = convertToBytes(metric.disk_free);
|
||||||
|
const diskUsagePercent = diskTotalBytes > 0 ? (diskUsedBytes / diskTotalBytes) * 100 : 0;
|
||||||
|
|
||||||
|
// Network values
|
||||||
|
const networkRxBytes = metric.network_rx_bytes || 0;
|
||||||
|
const networkTxBytes = metric.network_tx_bytes || 0;
|
||||||
|
const networkRxSpeed = metric.network_rx_speed || 0;
|
||||||
|
const networkTxSpeed = metric.network_tx_speed || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: new Date(metric.timestamp).toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}),
|
||||||
|
// CPU
|
||||||
|
cpuUsage: Math.round(cpuUsage * 100) / 100,
|
||||||
|
cpuCores: parseInt(metric.cpu_cores) || 0,
|
||||||
|
cpuFree: 100 - cpuUsage,
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
ramUsedBytes,
|
||||||
|
ramTotalBytes,
|
||||||
|
ramFreeBytes,
|
||||||
|
ramUsed: formatBytes(ramUsedBytes),
|
||||||
|
ramTotal: formatBytes(ramTotalBytes),
|
||||||
|
ramFree: formatBytes(ramFreeBytes),
|
||||||
|
ramUsagePercent: Math.round(ramUsagePercent * 100) / 100,
|
||||||
|
|
||||||
|
// Disk
|
||||||
|
diskUsedBytes,
|
||||||
|
diskTotalBytes,
|
||||||
|
diskFreeBytes,
|
||||||
|
diskUsed: formatBytes(diskUsedBytes),
|
||||||
|
diskTotal: formatBytes(diskTotalBytes),
|
||||||
|
diskFree: formatBytes(diskFreeBytes),
|
||||||
|
diskUsagePercent: Math.round(diskUsagePercent * 100) / 100,
|
||||||
|
|
||||||
|
// Network
|
||||||
|
networkRxBytes,
|
||||||
|
networkTxBytes,
|
||||||
|
networkRx: formatBytes(networkRxBytes),
|
||||||
|
networkTx: formatBytes(networkTxBytes),
|
||||||
|
networkRxSpeed: Math.round(networkRxSpeed * 100) / 100,
|
||||||
|
networkTxSpeed: Math.round(networkTxSpeed * 100) / 100,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartData = formatChartData(metrics);
|
||||||
|
const latestMetric = chartData[chartData.length - 1];
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
cpuUsage: {
|
||||||
|
label: "CPU Usage (%)",
|
||||||
|
color: theme === 'dark' ? "#3b82f6" : "#2563eb",
|
||||||
|
},
|
||||||
|
ramUsagePercent: {
|
||||||
|
label: "RAM Usage (%)",
|
||||||
|
color: theme === 'dark' ? "#10b981" : "#059669",
|
||||||
|
},
|
||||||
|
diskUsagePercent: {
|
||||||
|
label: "Disk Usage (%)",
|
||||||
|
color: theme === 'dark' ? "#f59e0b" : "#d97706",
|
||||||
|
},
|
||||||
|
networkRx: {
|
||||||
|
label: "Network RX",
|
||||||
|
color: theme === 'dark' ? "#8b5cf6" : "#7c3aed",
|
||||||
|
},
|
||||||
|
networkTx: {
|
||||||
|
label: "Network TX",
|
||||||
|
color: theme === 'dark' ? "#ef4444" : "#dc2626",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGridColor = () => theme === 'dark' ? '#374151' : '#e5e7eb';
|
||||||
|
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||||
|
|
||||||
|
const MetricCard = ({ title, used, total, free, percentage, icon: Icon, color }: {
|
||||||
|
title: string;
|
||||||
|
used: string;
|
||||||
|
total: string;
|
||||||
|
free: string;
|
||||||
|
percentage: number;
|
||||||
|
icon: any;
|
||||||
|
color: string;
|
||||||
|
}) => (
|
||||||
|
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="h-4 w-4" style={{ color }} />
|
||||||
|
<span className="text-sm font-medium">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Used:</span>
|
||||||
|
<span className="font-mono">{used}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Free:</span>
|
||||||
|
<span className="font-mono">{free}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Total:</span>
|
||||||
|
<span className="font-mono">{total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Usage:</span>
|
||||||
|
<span className="font-mono">{percentage.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!container) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-7xl max-h-[95vh] overflow-y-auto bg-background text-foreground">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
|
||||||
|
<Cpu className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
Container Metrics: {container.name}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={timeRange} onValueChange={(value: TimeRange) => setTimeRange(value)}>
|
||||||
|
<SelectTrigger className="w-[140px] h-8">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{timeRangeOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Docker ID: {container.docker_id} • {container.hostname}
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
<span className="ml-2">Loading metrics...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center h-96 text-muted-foreground">
|
||||||
|
<p>Error loading metrics: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
) : chartData.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-96 text-muted-foreground">
|
||||||
|
<p>No metrics data available for this container</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Current Metrics Summary */}
|
||||||
|
{latestMetric && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<MetricCard
|
||||||
|
title="CPU"
|
||||||
|
used={`${latestMetric.cpuUsage}%`}
|
||||||
|
total={`${latestMetric.cpuCores} cores`}
|
||||||
|
free={`${(100 - latestMetric.cpuUsage).toFixed(1)}%`}
|
||||||
|
percentage={latestMetric.cpuUsage}
|
||||||
|
icon={Cpu}
|
||||||
|
color={chartConfig.cpuUsage.color}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Memory"
|
||||||
|
used={latestMetric.ramUsed}
|
||||||
|
total={latestMetric.ramTotal}
|
||||||
|
free={latestMetric.ramFree}
|
||||||
|
percentage={latestMetric.ramUsagePercent}
|
||||||
|
icon={MemoryStick}
|
||||||
|
color={chartConfig.ramUsagePercent.color}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Disk"
|
||||||
|
used={latestMetric.diskUsed}
|
||||||
|
total={latestMetric.diskTotal}
|
||||||
|
free={latestMetric.diskFree}
|
||||||
|
percentage={latestMetric.diskUsagePercent}
|
||||||
|
icon={HardDrive}
|
||||||
|
color={chartConfig.diskUsagePercent.color}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Network"
|
||||||
|
used={`RX: ${latestMetric.networkRx}`}
|
||||||
|
total={`TX: ${latestMetric.networkTx}`}
|
||||||
|
free={`Speed: ${latestMetric.networkRxSpeed} KB/s`}
|
||||||
|
percentage={0}
|
||||||
|
icon={Network}
|
||||||
|
color={chartConfig.networkRx.color}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs defaultValue="cpu" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-4 bg-muted">
|
||||||
|
<TabsTrigger value="cpu" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||||
|
<Cpu className="h-4 w-4" />
|
||||||
|
CPU
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="memory" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||||
|
<MemoryStick className="h-4 w-4" />
|
||||||
|
Memory
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="disk" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||||
|
<HardDrive className="h-4 w-4" />
|
||||||
|
Disk
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="network" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||||
|
<Network className="h-4 w-4" />
|
||||||
|
Network
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="cpu" className="space-y-4 mt-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground">CPU Usage (%)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={chartConfig} className="h-80">
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||||
|
cursor={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cpuUsage"
|
||||||
|
stroke={chartConfig.cpuUsage.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: chartConfig.cpuUsage.color }}
|
||||||
|
name="CPU Usage (%)"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground">CPU Usage vs Available</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={chartConfig} className="h-80">
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||||
|
cursor={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cpuFree"
|
||||||
|
stackId="1"
|
||||||
|
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||||
|
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
name="CPU Available (%)"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cpuUsage"
|
||||||
|
stackId="1"
|
||||||
|
stroke={chartConfig.cpuUsage.color}
|
||||||
|
fill={chartConfig.cpuUsage.color}
|
||||||
|
fillOpacity={0.6}
|
||||||
|
name="CPU Usage (%)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="memory" className="space-y-4 mt-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground">Memory Usage (%)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={chartConfig} className="h-80">
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||||
|
cursor={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="ramUsagePercent"
|
||||||
|
stackId="1"
|
||||||
|
stroke={chartConfig.ramUsagePercent.color}
|
||||||
|
fill={chartConfig.ramUsagePercent.color}
|
||||||
|
fillOpacity={0.6}
|
||||||
|
name="RAM Usage (%)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground">Memory Usage (Bytes)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={chartConfig} className="h-80">
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
tickFormatter={(value) => formatBytes(value)}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||||
|
cursor={{ stroke: getGridColor() }}
|
||||||
|
formatter={(value, name) => [
|
||||||
|
name === 'Used Memory' ? formatBytes(Number(value)) :
|
||||||
|
name === 'Total Memory' ? formatBytes(Number(value)) : value,
|
||||||
|
name
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="ramTotalBytes"
|
||||||
|
stackId="1"
|
||||||
|
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||||
|
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
name="Total Memory"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="ramUsedBytes"
|
||||||
|
stackId="1"
|
||||||
|
stroke={chartConfig.ramUsagePercent.color}
|
||||||
|
fill={chartConfig.ramUsagePercent.color}
|
||||||
|
fillOpacity={0.6}
|
||||||
|
name="Used Memory"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="disk" className="space-y-4 mt-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground">Disk Usage (%)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={chartConfig} className="h-80">
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||||
|
cursor={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="diskUsagePercent"
|
||||||
|
stackId="1"
|
||||||
|
stroke={chartConfig.diskUsagePercent.color}
|
||||||
|
fill={chartConfig.diskUsagePercent.color}
|
||||||
|
fillOpacity={0.6}
|
||||||
|
name="Disk Usage (%)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground">Disk Usage (Bytes)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={chartConfig} className="h-80">
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
tickFormatter={(value) => formatBytes(value)}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||||
|
cursor={{ stroke: getGridColor() }}
|
||||||
|
formatter={(value, name) => [
|
||||||
|
name === 'Used Disk' ? formatBytes(Number(value)) :
|
||||||
|
name === 'Total Disk' ? formatBytes(Number(value)) : value,
|
||||||
|
name
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="diskTotalBytes"
|
||||||
|
stackId="1"
|
||||||
|
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||||
|
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
name="Total Disk"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="diskUsedBytes"
|
||||||
|
stackId="1"
|
||||||
|
stroke={chartConfig.diskUsagePercent.color}
|
||||||
|
fill={chartConfig.diskUsagePercent.color}
|
||||||
|
fillOpacity={0.6}
|
||||||
|
name="Used Disk"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="network" className="space-y-4 mt-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground">Network Traffic</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={chartConfig} className="h-64">
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||||
|
cursor={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="networkRxBytes"
|
||||||
|
stroke={chartConfig.networkRx.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
name="RX Bytes"
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="networkTxBytes"
|
||||||
|
stroke={chartConfig.networkTx.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
name="TX Bytes"
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground">Network Speed (KB/s)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={chartConfig} className="h-64">
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||||
|
cursor={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="networkRxSpeed"
|
||||||
|
stroke={chartConfig.networkRx.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
name="RX Speed (KB/s)"
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="networkTxSpeed"
|
||||||
|
stroke={chartConfig.networkTx.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
name="TX Speed (KB/s)"
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Container, Play, Square, AlertTriangle } from "lucide-react";
|
||||||
|
import { DockerStats } from "@/types/docker.types";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
interface DockerStatsCardsProps {
|
||||||
|
stats: DockerStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
title: "Total Containers",
|
||||||
|
value: stats.total,
|
||||||
|
icon: Container,
|
||||||
|
color: "text-blue-600",
|
||||||
|
gradient: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(59, 130, 246, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #3b82f6 100%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Running",
|
||||||
|
value: stats.running,
|
||||||
|
icon: Play,
|
||||||
|
color: "text-green-600",
|
||||||
|
gradient: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(16, 185, 129, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #10b981 100%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Stopped",
|
||||||
|
value: stats.stopped,
|
||||||
|
icon: Square,
|
||||||
|
color: "text-gray-600",
|
||||||
|
gradient: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(107, 114, 128, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #6b7280 100%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Warning",
|
||||||
|
value: stats.warning,
|
||||||
|
icon: AlertTriangle,
|
||||||
|
color: "text-amber-600",
|
||||||
|
gradient: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(245, 158, 11, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #f59e0b 100%)"
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
|
||||||
|
{cards.map((card) => {
|
||||||
|
const IconComponent = card.icon;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={card.title}
|
||||||
|
className="border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative"
|
||||||
|
style={{ background: card.gradient }}
|
||||||
|
>
|
||||||
|
{/* Grid Pattern Overlay */}
|
||||||
|
<div className="absolute inset-0 z-0 opacity-10">
|
||||||
|
<div
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '20px 20px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||||
|
<CardTitle className="text-sm font-medium text-white/70">
|
||||||
|
{card.title}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="p-2.5 rounded-xl bg-white/20 backdrop-blur-sm shadow-sm transition-all duration-300 group-hover:scale-110">
|
||||||
|
<IconComponent className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="relative z-10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{card.value}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs font-mono font-bold px-2 py-1 rounded-md bg-white/20 backdrop-blur-sm text-white border border-white/30"
|
||||||
|
>
|
||||||
|
Containers
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
interface DockerStatusBadgeProps {
|
||||||
|
status: 'running' | 'stopped' | 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockerStatusBadge = ({ status }: DockerStatusBadgeProps) => {
|
||||||
|
const getStatusConfig = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return {
|
||||||
|
variant: 'default' as const,
|
||||||
|
className: 'bg-emerald-100 text-emerald-800 border-emerald-200 hover:bg-emerald-200',
|
||||||
|
label: 'Running'
|
||||||
|
};
|
||||||
|
case 'stopped':
|
||||||
|
return {
|
||||||
|
variant: 'secondary' as const,
|
||||||
|
className: 'bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-200',
|
||||||
|
label: 'Stopped'
|
||||||
|
};
|
||||||
|
case 'warning':
|
||||||
|
return {
|
||||||
|
variant: 'destructive' as const,
|
||||||
|
className: 'bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-200',
|
||||||
|
label: 'Warning'
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
variant: 'outline' as const,
|
||||||
|
className: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||||
|
label: 'Unknown'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getStatusConfig(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={config.variant}
|
||||||
|
className={`${config.className} font-medium text-xs px-2 py-1`}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
|
||||||
|
import { TableCell, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
|
interface DockerEmptyStateProps {
|
||||||
|
searchTerm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockerEmptyState = ({ searchTerm }: DockerEmptyStateProps) => {
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-12 text-muted-foreground">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="text-lg font-medium">
|
||||||
|
{searchTerm ? "No containers found" : "No containers running"}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{searchTerm ? "Try adjusting your search terms." : "Start some containers to see them here."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||||
|
import { MoreHorizontal, Eye, Play, Pause, Square, Trash2, BarChart3, RefreshCw } from "lucide-react";
|
||||||
|
import { DockerContainer } from "@/types/docker.types";
|
||||||
|
|
||||||
|
interface DockerRowActionsProps {
|
||||||
|
container: DockerContainer;
|
||||||
|
containerStatus: 'running' | 'stopped' | 'warning';
|
||||||
|
onContainerAction: (action: string, containerId: string, containerName: string) => void;
|
||||||
|
onViewMetrics: (container: DockerContainer) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockerRowActions = ({ container, containerStatus, onContainerAction, onViewMetrics }: DockerRowActionsProps) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0 hover:bg-muted">
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48 bg-popover border-border shadow-md">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onViewMetrics(container)}
|
||||||
|
className="cursor-pointer hover:bg-muted"
|
||||||
|
>
|
||||||
|
<BarChart3 className="mr-2 h-4 w-4" />
|
||||||
|
View Metrics
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onContainerAction('view-detail', container.id, container.name)}
|
||||||
|
className="cursor-pointer hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
View Details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
import { TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
|
export const DockerTableHeader = () => {
|
||||||
|
return (
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-border bg-muted/30">
|
||||||
|
<TableHead className="min-w-[200px] font-semibold">Container</TableHead>
|
||||||
|
<TableHead className="min-w-[100px] font-semibold">Status</TableHead>
|
||||||
|
<TableHead className="min-w-[140px] font-semibold">CPU Usage</TableHead>
|
||||||
|
<TableHead className="min-w-[160px] font-semibold">Memory</TableHead>
|
||||||
|
<TableHead className="min-w-[160px] font-semibold">Disk</TableHead>
|
||||||
|
<TableHead className="min-w-[100px] font-semibold">Uptime</TableHead>
|
||||||
|
<TableHead className="min-w-[160px] font-semibold">Last Checked</TableHead>
|
||||||
|
<TableHead className="min-w-[80px] text-center font-semibold">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
|
||||||
|
import { TableCell, TableRow } from "@/components/ui/table";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { DockerContainer } from "@/types/docker.types";
|
||||||
|
import { DockerStatusBadge } from "../DockerStatusBadge";
|
||||||
|
import { DockerRowActions } from "./DockerRowActions";
|
||||||
|
import { dockerService } from "@/services/dockerService";
|
||||||
|
|
||||||
|
interface DockerTableRowProps {
|
||||||
|
container: DockerContainer;
|
||||||
|
onRowClick: (container: DockerContainer) => void;
|
||||||
|
onContainerAction: (action: string, containerId: string, containerName: string) => void;
|
||||||
|
onViewMetrics: (container: DockerContainer) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockerTableRow = ({ container, onRowClick, onContainerAction, onViewMetrics }: DockerTableRowProps) => {
|
||||||
|
const cpuPercentage = container.cpu_usage;
|
||||||
|
const memoryPercentage = Math.round((container.ram_used / container.ram_total) * 100);
|
||||||
|
const diskPercentage = Math.round((container.disk_used / container.disk_total) * 100);
|
||||||
|
const containerStatus = dockerService.getStatusFromDockerStatus(container.status);
|
||||||
|
|
||||||
|
const formatPercentage = (used: number, total: number) => {
|
||||||
|
if (total === 0) return "0%";
|
||||||
|
return `${Math.round((used / total) * 100)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUsageColor = (percentage: number) => {
|
||||||
|
if (percentage >= 90) return "text-red-500";
|
||||||
|
if (percentage >= 70) return "text-amber-500";
|
||||||
|
return "text-emerald-500";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressColor = (percentage: number) => {
|
||||||
|
if (percentage >= 90) return "bg-red-500";
|
||||||
|
if (percentage >= 70) return "bg-amber-500";
|
||||||
|
return "bg-emerald-500";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
className="hover:bg-muted/50 transition-colors border-border cursor-pointer"
|
||||||
|
onClick={() => onRowClick(container)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-semibold text-sm sm:text-base text-foreground">{container.name}</div>
|
||||||
|
<div className="text-xs sm:text-sm text-muted-foreground">
|
||||||
|
<div className="font-mono">{container.docker_id}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DockerStatusBadge status={containerStatus} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Progress
|
||||||
|
value={cpuPercentage}
|
||||||
|
className="flex-1 h-2 bg-muted/50"
|
||||||
|
indicatorClassName={getProgressColor(cpuPercentage)}
|
||||||
|
/>
|
||||||
|
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(cpuPercentage)}`}>
|
||||||
|
{cpuPercentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Progress
|
||||||
|
value={memoryPercentage}
|
||||||
|
className="flex-1 h-2 bg-muted/50"
|
||||||
|
indicatorClassName={getProgressColor(memoryPercentage)}
|
||||||
|
/>
|
||||||
|
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(memoryPercentage)}`}>
|
||||||
|
{formatPercentage(container.ram_used, container.ram_total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-mono">
|
||||||
|
{dockerService.formatBytes(container.ram_used)} / {dockerService.formatBytes(container.ram_total)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Progress
|
||||||
|
value={diskPercentage}
|
||||||
|
className="flex-1 h-2 bg-muted/50"
|
||||||
|
indicatorClassName={getProgressColor(diskPercentage)}
|
||||||
|
/>
|
||||||
|
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(diskPercentage)}`}>
|
||||||
|
{formatPercentage(container.disk_used, container.disk_total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-mono">
|
||||||
|
{dockerService.formatBytes(container.disk_used)} / {dockerService.formatBytes(container.disk_total)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-xs sm:text-sm font-medium font-mono">
|
||||||
|
{dockerService.formatUptime(container.uptime)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-xs sm:text-sm text-muted-foreground">
|
||||||
|
{new Date(container.last_checked).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DockerRowActions
|
||||||
|
container={container}
|
||||||
|
containerStatus={containerStatus}
|
||||||
|
onContainerAction={onContainerAction}
|
||||||
|
onViewMetrics={onViewMetrics}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Search, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
interface DockerTableSearchProps {
|
||||||
|
searchTerm: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockerTableSearch = ({ searchTerm, onSearchChange, onRefresh, isLoading }: DockerTableSearchProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
|
||||||
|
<div className="relative flex-1 sm:flex-initial">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search containers..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="pl-10 sm:w-64 bg-background border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="w-full sm:w-auto bg-background border-border hover:bg-muted"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
<span className="sm:inline">Refresh</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
export { DockerTableSearch } from './DockerTableSearch';
|
||||||
|
export { DockerTableHeader } from './DockerTableHeader';
|
||||||
|
export { DockerTableRow } from './DockerTableRow';
|
||||||
|
export { DockerRowActions } from './DockerRowActions';
|
||||||
|
export { DockerEmptyState } from './DockerEmptyState';
|
||||||
@@ -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';
|
||||||
@@ -38,8 +38,9 @@ interface EditOperationalPageDialogProps {
|
|||||||
|
|
||||||
export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOperationalPageDialogProps) => {
|
export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOperationalPageDialogProps) => {
|
||||||
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 +64,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 +109,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 +154,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 +179,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,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');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,460 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Copy, Download, Terminal, CheckCircle, Zap, Play } from "lucide-react";
|
||||||
|
import { regionalService } from "@/services/regionalService";
|
||||||
|
import { InstallCommand } from "@/types/regional.types";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface AddRegionalAgentDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onAgentAdded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddRegionalAgentDialog: React.FC<AddRegionalAgentDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onAgentAdded
|
||||||
|
}) => {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [regionName, setRegionName] = useState("");
|
||||||
|
const [agentIp, setAgentIp] = useState("");
|
||||||
|
const [installCommand, setInstallCommand] = useState<InstallCommand | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleCreateAgent = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!regionName.trim() || !agentIp.trim()) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await regionalService.createRegionalService({
|
||||||
|
region_name: regionName,
|
||||||
|
agent_ip_address: agentIp,
|
||||||
|
});
|
||||||
|
|
||||||
|
setInstallCommand(result.installCommand);
|
||||||
|
setStep(2);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to create regional agent configuration.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string, description: string = "Content") => {
|
||||||
|
try {
|
||||||
|
// Try the modern clipboard API first
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast({
|
||||||
|
title: "Copied!",
|
||||||
|
description: `${description} copied to clipboard.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for older browsers or non-secure contexts (like localhost)
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.left = '-999999px';
|
||||||
|
textArea.style.top = '-999999px';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
const successful = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
|
||||||
|
if (successful) {
|
||||||
|
toast({
|
||||||
|
title: "Copied!",
|
||||||
|
description: `${description} copied to clipboard.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('execCommand failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy to clipboard:', error);
|
||||||
|
|
||||||
|
// Show the text in a modal or alert as final fallback
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase();
|
||||||
|
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
|
|
||||||
|
let errorMessage = "Failed to copy to clipboard.";
|
||||||
|
if (isLocalhost) {
|
||||||
|
errorMessage += " This is common in local development. Please manually copy the text.";
|
||||||
|
} else if (userAgent.includes('chrome')) {
|
||||||
|
errorMessage += " Try using HTTPS or enable clipboard permissions.";
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Copy failed",
|
||||||
|
description: errorMessage,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
|
||||||
|
// As a last resort, select the text for manual copying
|
||||||
|
try {
|
||||||
|
const textarea = document.querySelector('textarea[readonly]') as HTMLTextAreaElement;
|
||||||
|
if (textarea) {
|
||||||
|
textarea.select();
|
||||||
|
textarea.setSelectionRange(0, 99999); // For mobile devices
|
||||||
|
}
|
||||||
|
} catch (selectError) {
|
||||||
|
console.error('Failed to select text:', selectError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadScript = () => {
|
||||||
|
if (!installCommand) return;
|
||||||
|
|
||||||
|
const blob = new Blob([installCommand.bash_script], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `install-regional-agent-${installCommand.agent_id}.sh`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Downloaded!",
|
||||||
|
description: "Installation script downloaded successfully.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = () => {
|
||||||
|
onAgentAdded();
|
||||||
|
setStep(1);
|
||||||
|
setRegionName("");
|
||||||
|
setAgentIp("");
|
||||||
|
setInstallCommand(null);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetDialog = () => {
|
||||||
|
setStep(1);
|
||||||
|
setRegionName("");
|
||||||
|
setAgentIp("");
|
||||||
|
setInstallCommand(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Regional Monitoring Agent</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Deploy a regional monitoring agent with automatic one-click installation.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<form onSubmit={handleCreateAgent} className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="regionName">Region Name</Label>
|
||||||
|
<Input
|
||||||
|
id="regionName"
|
||||||
|
placeholder="e.g., us-east-1, europe-west-1, asia-pacific-1"
|
||||||
|
value={regionName}
|
||||||
|
onChange={(e) => setRegionName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="agentIp">Agent Server IP Address</Label>
|
||||||
|
<Input
|
||||||
|
id="agentIp"
|
||||||
|
placeholder="e.g., 192.168.1.100 or your-server.example.com"
|
||||||
|
value={agentIp}
|
||||||
|
onChange={(e) => setAgentIp(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Generating..." : "Generate Installation"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && installCommand && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<CheckCircle className="h-12 w-12 text-green-600 mx-auto" />
|
||||||
|
<h3 className="text-lg font-semibold">Agent Configuration Ready!</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
One-click installation script generated with automatic configuration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="oneclicK" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="oneclicK">One-Click Install</TabsTrigger>
|
||||||
|
<TabsTrigger value="details">Agent Details</TabsTrigger>
|
||||||
|
<TabsTrigger value="manual">Manual Install</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="oneclicK" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Zap className="h-5 w-5 text-yellow-500" />
|
||||||
|
One-Click Automatic Installation
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Complete installation, configuration, and service startup in one command
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 text-green-800 font-medium mb-2">
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
What this script does automatically:
|
||||||
|
</div>
|
||||||
|
<ul className="text-sm text-green-700 space-y-1 ml-6">
|
||||||
|
<li>• Downloads the latest .deb package</li>
|
||||||
|
<li>• Installs the regional monitoring agent</li>
|
||||||
|
<li>• Creates configuration file with your settings</li>
|
||||||
|
<li>• Starts and enables the service</li>
|
||||||
|
<li>• Runs health checks</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Run this command on your target server:</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
readOnly
|
||||||
|
value={`# One-click installation command:
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/install-regional-agent.sh | sudo bash -s -- \\
|
||||||
|
--region-name="${regionName}" \\
|
||||||
|
--agent-id="${installCommand.agent_id}" \\
|
||||||
|
--agent-ip="${agentIp}" \\
|
||||||
|
--token="${installCommand.token}" \\
|
||||||
|
--pocketbase-url="${installCommand.api_endpoint}"`}
|
||||||
|
className="font-mono text-sm min-h-[120px] pr-12"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
onClick={() => copyToClipboard(`curl -fsSL https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/install-regional-agent.sh | sudo bash -s -- --region-name="${regionName}" --agent-id="${installCommand.agent_id}" --agent-ip="${agentIp}" --token="${installCommand.token}" --pocketbase-url="${installCommand.api_endpoint}"`, "Installation command")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={downloadScript} variant="outline" className="flex-1">
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download Complete Script
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => copyToClipboard(installCommand.bash_script, "Installation script")}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Copy Full Script
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Local development notice */}
|
||||||
|
{(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-yellow-800">
|
||||||
|
<strong>Local Development Notice:</strong> If clipboard copying fails, this is normal in local development.
|
||||||
|
You can use the "Download Complete Script" button instead or manually copy the text.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="details" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Generated Agent Configuration</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
These values will be automatically configured during installation
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-foreground">Agent ID</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={installCommand.agent_id}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground pr-10"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
|
||||||
|
onClick={() => copyToClipboard(installCommand.agent_id, "Agent ID")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-foreground">Region Name</Label>
|
||||||
|
<Input
|
||||||
|
value={regionName}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-foreground">Server IP</Label>
|
||||||
|
<Input
|
||||||
|
value={agentIp}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-foreground">API Endpoint</Label>
|
||||||
|
<Input
|
||||||
|
value={installCommand.api_endpoint}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-foreground">Authentication Token</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={installCommand.token}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground pr-10"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
|
||||||
|
onClick={() => copyToClipboard(installCommand.token, "Authentication token")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
<strong>Configuration file location:</strong> /etc/regional-check-agent/regional-check-agent.env
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="manual" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Terminal className="h-5 w-5" />
|
||||||
|
Manual Installation Steps
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Step-by-step manual installation process
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="text-sm space-y-4">
|
||||||
|
<div className="border-l-4 border-blue-500 pl-4">
|
||||||
|
<p className="font-medium">Step 1: Download Package</p>
|
||||||
|
<code className="text-xs bg-muted p-2 rounded block mt-1">
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">✅ If you’re using the amd64 architecture, please download this package. </p> <br/>
|
||||||
|
wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_amd64.deb
|
||||||
|
</code>
|
||||||
|
<code className="text-xs bg-muted p-2 rounded block mt-1">
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">✅ If you’re using the arm64 architecture, please download this package. </p> <br/>
|
||||||
|
wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_arm64.deb
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-4 border-blue-500 pl-4">
|
||||||
|
<p className="font-medium">Step 2: Install Package</p>
|
||||||
|
<code className="text-xs bg-muted p-2 rounded block mt-1">
|
||||||
|
sudo dpkg -i distributed-regional-check-agent_1.0.0_amd64.deb<br/>
|
||||||
|
sudo apt-get install -f
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-4 border-blue-500 pl-4">
|
||||||
|
<p className="font-medium">Step 3: Configure Agent</p>
|
||||||
|
<code className="text-xs bg-muted p-2 rounded block mt-1">
|
||||||
|
sudo nano /etc/regional-check-agent/regional-check-agent.env
|
||||||
|
</code>
|
||||||
|
<code className="text-xs bg-muted p-2 rounded block mt-1">
|
||||||
|
Copy sample configuration from https://github.com/operacle/Distributed-Regional-Monitoring/blob/main/packaging/regional-check-agent.conf
|
||||||
|
</code>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Add the configuration values shown in the Agent Details tab</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-4 border-blue-500 pl-4">
|
||||||
|
<p className="font-medium">Step 4: Start Service</p>
|
||||||
|
<code className="text-xs bg-muted p-2 rounded block mt-1">
|
||||||
|
sudo systemctl enable regional-check-agent<br/>
|
||||||
|
sudo systemctl start regional-check-agent
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-4 border-green-500 pl-4">
|
||||||
|
<p className="font-medium">Step 5: Verify Installation</p>
|
||||||
|
<code className="text-xs bg-muted p-2 rounded block mt-1">
|
||||||
|
sudo systemctl status regional-check-agent<br/>
|
||||||
|
curl http://localhost:8091/health
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button variant="outline" onClick={resetDialog}>
|
||||||
|
Add Another Agent
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleComplete}>
|
||||||
|
Complete Setup
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import { MapPin, Wifi, WifiOff, MoreVertical, Trash2, Terminal, Copy } from "lucide-react";
|
||||||
|
import { RegionalService } from "@/types/regional.types";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface RegionalAgentCardProps {
|
||||||
|
agent: RegionalService;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onDelete }) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Check if this is the default agent that cannot be removed
|
||||||
|
const isDefaultAgent = agent.agent_id === "1" || agent.region_name === "Default";
|
||||||
|
|
||||||
|
const copyAgentId = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(agent.agent_id);
|
||||||
|
toast({
|
||||||
|
title: "Copied!",
|
||||||
|
description: "Agent ID copied to clipboard.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Copy failed",
|
||||||
|
description: "Failed to copy agent ID.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConnectionStatus = () => {
|
||||||
|
if (agent.connection === 'online') {
|
||||||
|
return {
|
||||||
|
icon: <Wifi className="h-4 w-4" />,
|
||||||
|
label: 'Online',
|
||||||
|
variant: 'default' as const,
|
||||||
|
className: 'bg-green-100 text-green-800 hover:bg-green-100'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
icon: <WifiOff className="h-4 w-4" />,
|
||||||
|
label: 'Offline',
|
||||||
|
variant: 'secondary' as const,
|
||||||
|
className: 'bg-red-100 text-red-800 hover:bg-red-100'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectionStatus = getConnectionStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="relative">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<MapPin className="h-5 w-5 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
{agent.region_name}
|
||||||
|
{isDefaultAgent && (
|
||||||
|
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
|
||||||
|
Default
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="flex items-center gap-1 text-sm">
|
||||||
|
{agent.agent_ip_address}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={copyAgentId}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Copy Agent ID
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{!isDefaultAgent && (
|
||||||
|
<DropdownMenuItem onClick={onDelete} className="text-red-600">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Remove Agent
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge
|
||||||
|
variant={connectionStatus.variant}
|
||||||
|
className={connectionStatus.className}
|
||||||
|
>
|
||||||
|
{connectionStatus.icon}
|
||||||
|
<span className="ml-1">{connectionStatus.label}</span>
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{agent.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Agent ID:</span>
|
||||||
|
<span className="font-mono text-xs">{agent.agent_id.substring(0, 12)}...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Last Updated:</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
{formatDistanceToNow(new Date(agent.updated), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{agent.connection === 'online' && (
|
||||||
|
<div className="pt-2 border-t">
|
||||||
|
<div className="flex items-center text-xs text-green-600">
|
||||||
|
<div className="w-2 h-2 bg-green-600 rounded-full mr-2 animate-pulse"></div>
|
||||||
|
Active monitoring
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{agent.connection === 'offline' && (
|
||||||
|
<div className="pt-2 border-t">
|
||||||
|
<div className="flex items-center text-xs text-red-600">
|
||||||
|
<div className="w-2 h-2 bg-red-600 rounded-full mr-2"></div>
|
||||||
|
Connection lost
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Plus, MapPin, Activity, Wifi, WifiOff } from "lucide-react";
|
||||||
|
import { regionalService } from "@/services/regionalService";
|
||||||
|
import { RegionalService } from "@/types/regional.types";
|
||||||
|
import { AddRegionalAgentDialog } from "./AddRegionalAgentDialog";
|
||||||
|
import { RegionalAgentCard } from "./RegionalAgentCard";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
export const RegionalMonitoringContent = () => {
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: regionalServices = [], isLoading, error } = useQuery({
|
||||||
|
queryKey: ['regional-services'],
|
||||||
|
queryFn: regionalService.getRegionalServices,
|
||||||
|
refetchInterval: 30000, // Refetch every 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAgentAdded = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['regional-services'] });
|
||||||
|
toast({
|
||||||
|
title: "Regional Agent Added",
|
||||||
|
description: "The regional monitoring agent has been successfully configured.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAgent = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await regionalService.deleteRegionalService(id);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['regional-services'] });
|
||||||
|
toast({
|
||||||
|
title: "Agent Removed",
|
||||||
|
description: "The regional monitoring agent has been removed.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to remove the regional monitoring agent.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onlineAgents = regionalServices.filter(agent => agent.connection === 'online').length;
|
||||||
|
const totalAgents = regionalServices.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Regional Monitoring</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage distributed monitoring agents across different regions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setAddDialogOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Regional Agent
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Agents</CardTitle>
|
||||||
|
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totalAgents}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Regional monitoring agents
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Online Agents</CardTitle>
|
||||||
|
<Wifi className="h-4 w-4 text-green-600" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{onlineAgents}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Currently connected
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Offline Agents</CardTitle>
|
||||||
|
<WifiOff className="h-4 w-4 text-red-600" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-red-600">{totalAgents - onlineAgents}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Disconnected agents
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agents List */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">Regional Agents</h2>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<Card key={i} className="animate-pulse">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3 bg-gray-200 rounded"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-5/6"></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : regionalServices.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<MapPin className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">No Regional Agents</h3>
|
||||||
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
|
Get started by adding your first regional monitoring agent to extend your monitoring coverage.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setAddDialogOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add First Agent
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{regionalServices.map((agent) => (
|
||||||
|
<RegionalAgentCard
|
||||||
|
key={agent.id}
|
||||||
|
agent={agent}
|
||||||
|
onDelete={() => handleDeleteAgent(agent.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddRegionalAgentDialog
|
||||||
|
open={addDialogOpen}
|
||||||
|
onOpenChange={setAddDialogOpen}
|
||||||
|
onAgentAdded={handleAgentAdded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
export { RegionalMonitoringContent } from './RegionalMonitoringContent';
|
||||||
|
export { AddRegionalAgentDialog } from './AddRegionalAgentDialog';
|
||||||
|
export { RegionalAgentCard } from './RegionalAgentCard';
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -31,17 +31,17 @@ export const AssignedUsersField = () => {
|
|||||||
// Ensure assigned_users is initialized as an array
|
// Ensure assigned_users is initialized as an array
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentValue = form.getValues('assigned_users');
|
const currentValue = form.getValues('assigned_users');
|
||||||
console.log("Initial assigned_users value:", currentValue);
|
// console.log("Initial assigned_users value:", currentValue);
|
||||||
|
|
||||||
// Initialize as empty array if no value or invalid value
|
// Initialize as empty array if no value or invalid value
|
||||||
if (!currentValue || !Array.isArray(currentValue)) {
|
if (!currentValue || !Array.isArray(currentValue)) {
|
||||||
console.log("Initializing assigned_users as empty array");
|
// console.log("Initializing assigned_users as empty array");
|
||||||
form.setValue('assigned_users', [], { shouldValidate: false, shouldDirty: true });
|
form.setValue('assigned_users', [], { shouldValidate: false, shouldDirty: true });
|
||||||
}
|
}
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
console.log("Current form values:", form.getValues());
|
//console.log("Current form values:", form.getValues());
|
||||||
console.log("Current assigned_users:", form.getValues('assigned_users'));
|
//console.log("Current assigned_users:", form.getValues('assigned_users'));
|
||||||
|
|
||||||
// Fetch users for the assignment dropdown
|
// Fetch users for the assignment dropdown
|
||||||
const { data: users = [], isLoading } = useQuery({
|
const { data: users = [], isLoading } = useQuery({
|
||||||
@@ -49,10 +49,10 @@ export const AssignedUsersField = () => {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const usersList = await userService.getUsers();
|
const usersList = await userService.getUsers();
|
||||||
console.log("Fetched users for assignment:", usersList);
|
// console.log("Fetched users for 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 [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -64,7 +64,7 @@ export const AssignedUsersField = () => {
|
|||||||
? form.watch('assigned_users')
|
? form.watch('assigned_users')
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
console.log("Selected user IDs:", selectedUserIds);
|
// console.log("Selected user IDs:", selectedUserIds);
|
||||||
|
|
||||||
// Function to add a user
|
// Function to add a user
|
||||||
const addUser = (userId: string) => {
|
const addUser = (userId: string) => {
|
||||||
@@ -73,7 +73,7 @@ export const AssignedUsersField = () => {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (!currentValues.includes(userId)) {
|
if (!currentValues.includes(userId)) {
|
||||||
console.log("Adding user:", userId);
|
// console.log("Adding user:", userId);
|
||||||
form.setValue('assigned_users', [...currentValues, userId], { shouldValidate: true, shouldDirty: true });
|
form.setValue('assigned_users', [...currentValues, userId], { shouldValidate: true, shouldDirty: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -84,7 +84,7 @@ export const AssignedUsersField = () => {
|
|||||||
? [...form.getValues('assigned_users')]
|
? [...form.getValues('assigned_users')]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
console.log("Removing user:", userId);
|
// console.log("Removing user:", userId);
|
||||||
form.setValue(
|
form.setValue(
|
||||||
'assigned_users',
|
'assigned_users',
|
||||||
currentValues.filter(id => id !== userId),
|
currentValues.filter(id => id !== userId),
|
||||||
@@ -94,7 +94,7 @@ export const AssignedUsersField = () => {
|
|||||||
|
|
||||||
// Get selected users data
|
// Get selected users data
|
||||||
const selectedUsers = users.filter(user => selectedUserIds.includes(user.id));
|
const selectedUsers = users.filter(user => selectedUserIds.includes(user.id));
|
||||||
console.log("Matched selected users:", selectedUsers);
|
// console.log("Matched selected users:", selectedUsers);
|
||||||
|
|
||||||
// Function to get user initials from name
|
// Function to get user initials from name
|
||||||
const getUserInitials = (user: any): string => {
|
const getUserInitials = (user: any): string => {
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Server } from "lucide-react";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { getCurrentEndpoint } from "@/lib/pocketbase";
|
||||||
|
import { ServerAgentConfigForm } from "./ServerAgentConfigForm";
|
||||||
|
import { OneClickInstallTab } from "./OneClickInstallTab";
|
||||||
|
import { ManualInstallTab } from "./ManualInstallTab";
|
||||||
|
|
||||||
|
interface AddServerAgentDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onAgentAdded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onAgentAdded,
|
||||||
|
}) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState("configure");
|
||||||
|
|
||||||
|
// Get current PocketBase URL
|
||||||
|
const currentPocketBaseUrl = getCurrentEndpoint();
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
serverName: "",
|
||||||
|
description: "",
|
||||||
|
osType: "",
|
||||||
|
checkInterval: "60",
|
||||||
|
retryAttempt: "3",
|
||||||
|
dockerEnabled: false,
|
||||||
|
notificationEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generated server token and agent ID
|
||||||
|
const [serverToken] = useState(() =>
|
||||||
|
`srv_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const [serverId] = useState(() =>
|
||||||
|
`agent_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isSubmitting) return;
|
||||||
|
|
||||||
|
if (!formData.serverName || !formData.osType) {
|
||||||
|
toast({
|
||||||
|
title: "Validation Error",
|
||||||
|
description: "Please fill in all required fields.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Here you would typically create the server monitoring configuration
|
||||||
|
// For now, we'll simulate the process
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Server Agent Created",
|
||||||
|
description: `${formData.serverName} monitoring agent has been configured successfully.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to one-click install tab after successful creation
|
||||||
|
setActiveTab("one-click");
|
||||||
|
onAgentAdded();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to create server monitoring agent.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
// Reset form and tab when dialog closes
|
||||||
|
setActiveTab("configure");
|
||||||
|
setFormData({
|
||||||
|
serverName: "",
|
||||||
|
description: "",
|
||||||
|
osType: "",
|
||||||
|
checkInterval: "60",
|
||||||
|
retryAttempt: "3",
|
||||||
|
dockerEnabled: false,
|
||||||
|
notificationEnabled: true,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||||
|
<DialogContent className="sm:max-w-[900px] max-w-[95vw] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Server className="h-5 w-5" />
|
||||||
|
Add Server Monitoring Agent
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure a new server monitoring agent to track system metrics and performance.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="configure">Configure Agent</TabsTrigger>
|
||||||
|
<TabsTrigger value="one-click">One-Click Install</TabsTrigger>
|
||||||
|
<TabsTrigger value="manual">Manual Installation</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="configure" className="space-y-6">
|
||||||
|
<ServerAgentConfigForm
|
||||||
|
formData={formData}
|
||||||
|
setFormData={setFormData}
|
||||||
|
serverId={serverId}
|
||||||
|
serverToken={serverToken}
|
||||||
|
currentPocketBaseUrl={currentPocketBaseUrl}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="one-click" className="space-y-6">
|
||||||
|
<OneClickInstallTab
|
||||||
|
serverToken={serverToken}
|
||||||
|
currentPocketBaseUrl={currentPocketBaseUrl}
|
||||||
|
formData={formData}
|
||||||
|
serverId={serverId}
|
||||||
|
onDialogClose={handleDialogClose}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="manual" className="space-y-6">
|
||||||
|
<ManualInstallTab
|
||||||
|
serverToken={serverToken}
|
||||||
|
currentPocketBaseUrl={currentPocketBaseUrl}
|
||||||
|
formData={formData}
|
||||||
|
serverId={serverId}
|
||||||
|
onDialogClose={handleDialogClose}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
import { Server } from "@/types/server.types";
|
||||||
|
import { RefreshCw, X } from "lucide-react";
|
||||||
|
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
|
||||||
|
import { templateService, NotificationTemplate } from "@/services/templateService";
|
||||||
|
import { serverThresholdService, ServerThreshold } from "@/services/serverThresholdService";
|
||||||
|
|
||||||
|
interface EditServerDialogProps {
|
||||||
|
server: Server | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onServerUpdated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerFormData {
|
||||||
|
name: string;
|
||||||
|
check_interval: number;
|
||||||
|
retry_attempts: number;
|
||||||
|
docker_monitoring: boolean;
|
||||||
|
notification_enabled: boolean;
|
||||||
|
notification_channels: string[]; // Changed to array for multiple selections
|
||||||
|
threshold_id: string;
|
||||||
|
template_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThresholdFormData {
|
||||||
|
cpu_threshold: number;
|
||||||
|
ram_threshold: number;
|
||||||
|
disk_threshold: number;
|
||||||
|
network_threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditServerDialog: React.FC<EditServerDialogProps> = ({
|
||||||
|
server,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onServerUpdated,
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState<ServerFormData>({
|
||||||
|
name: "",
|
||||||
|
check_interval: 60,
|
||||||
|
retry_attempts: 3,
|
||||||
|
docker_monitoring: false,
|
||||||
|
notification_enabled: false,
|
||||||
|
notification_channels: [], // Changed to array
|
||||||
|
threshold_id: "none",
|
||||||
|
template_id: "none",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [thresholdFormData, setThresholdFormData] = useState<ThresholdFormData>({
|
||||||
|
cpu_threshold: 80,
|
||||||
|
ram_threshold: 80,
|
||||||
|
disk_threshold: 80,
|
||||||
|
network_threshold: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
|
||||||
|
const [templates, setTemplates] = useState<NotificationTemplate[]>([]);
|
||||||
|
const [thresholds, setThresholds] = useState<ServerThreshold[]>([]);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<NotificationTemplate | null>(null);
|
||||||
|
const [selectedThreshold, setSelectedThreshold] = useState<ServerThreshold | null>(null);
|
||||||
|
const [loadingAlertConfigs, setLoadingAlertConfigs] = useState(false);
|
||||||
|
const [loadingTemplates, setLoadingTemplates] = useState(false);
|
||||||
|
const [loadingThresholds, setLoadingThresholds] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Initialize form data when server changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (server) {
|
||||||
|
// console.log("Setting form data for server:", server);
|
||||||
|
// Parse comma-separated notification_id into array
|
||||||
|
const notificationChannels = server.notification_id
|
||||||
|
? server.notification_id.split(',').map(id => id.trim()).filter(id => id)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
name: server.name || "",
|
||||||
|
check_interval: server.check_interval || 60,
|
||||||
|
retry_attempts: 3,
|
||||||
|
docker_monitoring: server.docker === "true",
|
||||||
|
notification_enabled: notificationChannels.length > 0,
|
||||||
|
notification_channels: notificationChannels,
|
||||||
|
threshold_id: server.threshold_id || "none",
|
||||||
|
template_id: server.template_id || "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [server]);
|
||||||
|
|
||||||
|
// Load data when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadAlertConfigurations();
|
||||||
|
loadTemplates();
|
||||||
|
loadThresholds();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Load existing threshold data when thresholds are loaded and we have a server with threshold_id
|
||||||
|
useEffect(() => {
|
||||||
|
if (server && server.threshold_id && thresholds.length > 0) {
|
||||||
|
// console.log("Loading existing threshold data for server:", server.threshold_id);
|
||||||
|
const existingThreshold = thresholds.find(t => t.id === server.threshold_id);
|
||||||
|
if (existingThreshold) {
|
||||||
|
// console.log("Found existing threshold:", existingThreshold);
|
||||||
|
setSelectedThreshold(existingThreshold);
|
||||||
|
// Handle the API response format with proper field names and type conversion
|
||||||
|
setThresholdFormData({
|
||||||
|
cpu_threshold: parseInt(String(existingThreshold.cpu_threshold)) || 80,
|
||||||
|
ram_threshold: parseInt(String((existingThreshold as any).ram_threshold_message || existingThreshold.ram_threshold)) || 80,
|
||||||
|
disk_threshold: parseInt(String(existingThreshold.disk_threshold)) || 80,
|
||||||
|
network_threshold: parseInt(String(existingThreshold.network_threshold)) || 80,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [server, thresholds]);
|
||||||
|
|
||||||
|
// Update selected template when form data or templates change
|
||||||
|
useEffect(() => {
|
||||||
|
if (formData.template_id && formData.template_id !== "none" && templates.length > 0) {
|
||||||
|
const template = templates.find(t => t.id === formData.template_id);
|
||||||
|
setSelectedTemplate(template || null);
|
||||||
|
} else {
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
}
|
||||||
|
}, [formData.template_id, templates]);
|
||||||
|
|
||||||
|
// Update selected threshold when threshold_id changes in form
|
||||||
|
useEffect(() => {
|
||||||
|
if (formData.threshold_id && formData.threshold_id !== "none" && thresholds.length > 0) {
|
||||||
|
const threshold = thresholds.find(t => t.id === formData.threshold_id);
|
||||||
|
setSelectedThreshold(threshold || null);
|
||||||
|
if (threshold) {
|
||||||
|
// Handle the API response format with proper field names and type conversion
|
||||||
|
setThresholdFormData({
|
||||||
|
cpu_threshold: parseInt(String(threshold.cpu_threshold)) || 80,
|
||||||
|
ram_threshold: parseInt(String((threshold as any).ram_threshold_message || threshold.ram_threshold)) || 80,
|
||||||
|
disk_threshold: parseInt(String(threshold.disk_threshold)) || 80,
|
||||||
|
network_threshold: parseInt(String(threshold.network_threshold)) || 80,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (formData.threshold_id === "none") {
|
||||||
|
setSelectedThreshold(null);
|
||||||
|
setThresholdFormData({
|
||||||
|
cpu_threshold: 80,
|
||||||
|
ram_threshold: 80,
|
||||||
|
disk_threshold: 80,
|
||||||
|
network_threshold: 80,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [formData.threshold_id, thresholds]);
|
||||||
|
|
||||||
|
const loadAlertConfigurations = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingAlertConfigs(true);
|
||||||
|
const configs = await alertConfigService.getAlertConfigurations();
|
||||||
|
setAlertConfigs(configs);
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('Error loading alert configurations:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load notification channels",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoadingAlertConfigs(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTemplates = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingTemplates(true);
|
||||||
|
const templateList = await templateService.getTemplates();
|
||||||
|
setTemplates(templateList);
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('Error loading templates:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load templates",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoadingTemplates(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadThresholds = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingThresholds(true);
|
||||||
|
const thresholdList = await serverThresholdService.getServerThresholds();
|
||||||
|
setThresholds(thresholdList);
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('Error loading server thresholds:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load server thresholds",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoadingThresholds(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleThresholdUpdate = async () => {
|
||||||
|
if (!selectedThreshold) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the correct field name for RAM threshold
|
||||||
|
const updateData = {
|
||||||
|
cpu_threshold: thresholdFormData.cpu_threshold,
|
||||||
|
ram_threshold_message: thresholdFormData.ram_threshold, // Use the correct field name
|
||||||
|
disk_threshold: thresholdFormData.disk_threshold,
|
||||||
|
network_threshold: thresholdFormData.network_threshold,
|
||||||
|
};
|
||||||
|
|
||||||
|
await serverThresholdService.updateServerThreshold(selectedThreshold.id, updateData);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setSelectedThreshold({
|
||||||
|
...selectedThreshold,
|
||||||
|
...thresholdFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update thresholds list
|
||||||
|
setThresholds(prev => prev.map(t =>
|
||||||
|
t.id === selectedThreshold.id
|
||||||
|
? { ...t, ...thresholdFormData }
|
||||||
|
: t
|
||||||
|
));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Threshold updated",
|
||||||
|
description: "Server threshold values have been updated successfully.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('Error updating threshold:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to update threshold values.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNotificationChannelToggle = (channelId: string, checked: boolean) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
notification_channels: checked
|
||||||
|
? [...prev.notification_channels, channelId]
|
||||||
|
: prev.notification_channels.filter(id => id !== channelId)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeNotificationChannel = (channelId: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
notification_channels: prev.notification_channels.filter(id => id !== channelId)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!server || isSubmitting) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// Convert notification channels array to comma-separated string
|
||||||
|
const notificationChannelsString = formData.notification_enabled
|
||||||
|
? formData.notification_channels.join(',')
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
name: formData.name,
|
||||||
|
check_interval: formData.check_interval,
|
||||||
|
docker: formData.docker_monitoring ? "true" : "false",
|
||||||
|
notification_id: notificationChannelsString,
|
||||||
|
threshold_id: formData.notification_enabled && formData.threshold_id !== "none" ? formData.threshold_id : "",
|
||||||
|
template_id: formData.notification_enabled && formData.template_id !== "none" ? formData.template_id : "",
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await pb.collection('servers').update(server.id, updateData);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Server updated",
|
||||||
|
description: `${formData.name} has been updated successfully.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
onServerUpdated();
|
||||||
|
onOpenChange(false);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('Error updating server:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to update server. Please try again.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (server) {
|
||||||
|
const notificationChannels = server.notification_id
|
||||||
|
? server.notification_id.split(',').map(id => id.trim()).filter(id => id)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
name: server.name || "",
|
||||||
|
check_interval: server.check_interval || 60,
|
||||||
|
retry_attempts: 3,
|
||||||
|
docker_monitoring: server.docker === "true",
|
||||||
|
notification_enabled: notificationChannels.length > 0,
|
||||||
|
notification_channels: notificationChannels,
|
||||||
|
threshold_id: server.threshold_id || "none",
|
||||||
|
template_id: server.template_id || "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Server Configuration</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="serverName">Server Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="serverName"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="Enter server name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="checkInterval">Check Interval</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.check_interval.toString()}
|
||||||
|
onValueChange={(value) => setFormData(prev => ({ ...prev, check_interval: parseInt(value) }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select interval" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="30">30 seconds</SelectItem>
|
||||||
|
<SelectItem value="60">1 minute</SelectItem>
|
||||||
|
<SelectItem value="120">2 minutes</SelectItem>
|
||||||
|
<SelectItem value="300">5 minutes</SelectItem>
|
||||||
|
<SelectItem value="600">10 minutes</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="retryAttempts">Retry Attempts</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.retry_attempts.toString()}
|
||||||
|
onValueChange={(value) => setFormData(prev => ({ ...prev, retry_attempts: parseInt(value) }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select retry attempts" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1 attempt</SelectItem>
|
||||||
|
<SelectItem value="2">2 attempts</SelectItem>
|
||||||
|
<SelectItem value="3">3 attempts</SelectItem>
|
||||||
|
<SelectItem value="5">5 attempts</SelectItem>
|
||||||
|
<SelectItem value="10">10 attempts</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dockerMonitoring">Docker Monitoring</Label>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="dockerMonitoring"
|
||||||
|
checked={formData.docker_monitoring}
|
||||||
|
onCheckedChange={(checked) => setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
docker_monitoring: checked
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="dockerMonitoring" className="text-sm text-muted-foreground">
|
||||||
|
{formData.docker_monitoring ? "Enabled" : "Disabled"}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notification Status Toggle */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="notificationEnabled"
|
||||||
|
checked={formData.notification_enabled}
|
||||||
|
onCheckedChange={(checked) => setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
notification_enabled: checked,
|
||||||
|
notification_channels: checked ? prev.notification_channels : [],
|
||||||
|
threshold_id: checked ? prev.threshold_id : "none",
|
||||||
|
template_id: checked ? prev.template_id : "none"
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="notificationEnabled">Enable Notifications</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Notification Settings */}
|
||||||
|
{formData.notification_enabled && (
|
||||||
|
<Card className="border-l-4 border-l-blue-500">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Notification Settings</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Multiple Notification Channels Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Notification Channels</Label>
|
||||||
|
<div className="space-y-2 max-h-40 overflow-y-auto border rounded-md p-3">
|
||||||
|
{loadingAlertConfigs ? (
|
||||||
|
<div className="text-sm text-muted-foreground">Loading channels...</div>
|
||||||
|
) : alertConfigs.length > 0 ? (
|
||||||
|
alertConfigs.map((config) => (
|
||||||
|
<div key={config.id} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`channel-${config.id}`}
|
||||||
|
checked={formData.notification_channels.includes(config.id || "")}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleNotificationChannelToggle(config.id || "", checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`channel-${config.id}`}
|
||||||
|
className="flex-1 text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
{config.notify_name} ({config.notification_type})
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">No notification channels available</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Channels Display */}
|
||||||
|
{formData.notification_channels.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Selected Channels:</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{formData.notification_channels.map((channelId) => {
|
||||||
|
const channel = alertConfigs.find(c => c.id === channelId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={channelId}
|
||||||
|
className="flex items-center gap-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
<span>{channel?.notify_name || channelId}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
onClick={() => removeNotificationChannel(channelId)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Set Threshold Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="thresholdId">Server Set Threshold</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.threshold_id}
|
||||||
|
onValueChange={(value) => setFormData(prev => ({ ...prev, threshold_id: value }))}
|
||||||
|
disabled={loadingThresholds}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={loadingThresholds ? "Loading thresholds..." : "Select server threshold"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">No threshold (use default)</SelectItem>
|
||||||
|
{thresholds.map((threshold) => (
|
||||||
|
<SelectItem key={threshold.id} value={threshold.id}>
|
||||||
|
{threshold.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editable Threshold Details */}
|
||||||
|
{selectedThreshold && (
|
||||||
|
<Card className="bg-muted/50">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-base">Threshold Details: {selectedThreshold.name}</CardTitle>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleThresholdUpdate}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Update Thresholds
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium text-muted-foreground">CPU Threshold (%)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={thresholdFormData.cpu_threshold}
|
||||||
|
onChange={(e) => setThresholdFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
cpu_threshold: parseInt(e.target.value) || 0
|
||||||
|
}))}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium text-muted-foreground">RAM Threshold (%)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={thresholdFormData.ram_threshold}
|
||||||
|
onChange={(e) => setThresholdFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
ram_threshold: parseInt(e.target.value) || 0
|
||||||
|
}))}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium text-muted-foreground">Disk Threshold (%)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={thresholdFormData.disk_threshold}
|
||||||
|
onChange={(e) => setThresholdFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
disk_threshold: parseInt(e.target.value) || 0
|
||||||
|
}))}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium text-muted-foreground">Network Threshold (%)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={thresholdFormData.network_threshold}
|
||||||
|
onChange={(e) => setThresholdFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
network_threshold: parseInt(e.target.value) || 0
|
||||||
|
}))}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server Template Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="templateId">Server Template</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.template_id}
|
||||||
|
onValueChange={(value) => setFormData(prev => ({ ...prev, template_id: value }))}
|
||||||
|
disabled={loadingTemplates}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={loadingTemplates ? "Loading templates..." : "Select server template"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">No template (use default)</SelectItem>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<SelectItem key={template.id} value={template.id}>
|
||||||
|
{template.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template Details */}
|
||||||
|
{selectedTemplate && (
|
||||||
|
<Card className="bg-muted/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Template Details: {selectedTemplate.name}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-1 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium text-muted-foreground">RAM Threshold Message</Label>
|
||||||
|
<p className="text-sm bg-background p-2 rounded border">
|
||||||
|
{selectedTemplate.up_message || "No RAM threshold message defined"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium text-muted-foreground">CPU Threshold Message</Label>
|
||||||
|
<p className="text-sm bg-background p-2 rounded border">
|
||||||
|
{selectedTemplate.down_message || "No CPU threshold message defined"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium text-muted-foreground">Disk Threshold Message</Label>
|
||||||
|
<p className="text-sm bg-background p-2 rounded border">
|
||||||
|
{selectedTemplate.incident_message || "No disk threshold message defined"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium text-muted-foreground">Network Threshold Message</Label>
|
||||||
|
<p className="text-sm bg-background p-2 rounded border">
|
||||||
|
{selectedTemplate.maintenance_message || "No network threshold message defined"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Updating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Update Server"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface ManualInstallTabProps {
|
||||||
|
serverToken: string;
|
||||||
|
currentPocketBaseUrl: string;
|
||||||
|
formData: {
|
||||||
|
serverName: string;
|
||||||
|
osType: string;
|
||||||
|
checkInterval: string;
|
||||||
|
};
|
||||||
|
serverId: string;
|
||||||
|
onDialogClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManualInstallTab: React.FC<ManualInstallTabProps> = ({
|
||||||
|
serverToken,
|
||||||
|
currentPocketBaseUrl,
|
||||||
|
formData,
|
||||||
|
serverId,
|
||||||
|
onDialogClose,
|
||||||
|
}) => {
|
||||||
|
const getManualInstallSteps = () => {
|
||||||
|
const scriptUrl = "https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/server-agent.sh";
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "Download the installation script",
|
||||||
|
command: `curl -L -o server-agent.sh "${scriptUrl}"`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Make the script executable",
|
||||||
|
command: `chmod +x server-agent.sh`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Run the installation with your configuration",
|
||||||
|
command: `SERVER_TOKEN="${serverToken}" POCKETBASE_URL="${currentPocketBaseUrl}" SERVER_NAME="${formData.serverName}" AGENT_ID="${serverId}" sudo bash server-agent.sh`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Terminal className="h-5 w-5" />
|
||||||
|
Manual Installation Steps
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Step-by-step installation process
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Server Name:</span> {formData.serverName}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Agent ID:</span> {serverId}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">OS Type:</span> {formData.osType}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Check Interval:</span> {formData.checkInterval}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{getManualInstallSteps().map((step, index) => (
|
||||||
|
<div key={index} className="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">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{step.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-8 relative">
|
||||||
|
<pre className="bg-muted p-3 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
|
<code>{step.command}</code>
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
onClick={() => copyToClipboard(step.command)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 mr-1" />
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</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 curl is installed for downloading files</li>
|
||||||
|
<li>Internet connection required for downloading script</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}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface OSOption {
|
||||||
|
value: string;
|
||||||
|
name: string;
|
||||||
|
logo: string; // now it's a path to image, e.g., /logos/ubuntu.svg
|
||||||
|
}
|
||||||
|
|
||||||
|
const osOptions: OSOption[] = [
|
||||||
|
{ value: "ubuntu", name: "Ubuntu", logo: "/upload/os/ubuntu.png" },
|
||||||
|
{ value: "debian", name: "Debian", logo: "/upload/os/debian.png" },
|
||||||
|
{ value: "centos", name: "CentOS", logo: "/upload/os/centos.png" },
|
||||||
|
{ value: "rhel", name: "Red Hat Enterprise Linux", logo: "/upload/os/rhel.png" },
|
||||||
|
{ value: "linux", name: "Linux (Generic)", logo: "/upload/os/linux.png" },
|
||||||
|
{ value: "windows", name: "Windows Server", logo: "/upload/os/windows.png" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface OSSelectorProps {
|
||||||
|
value: string;
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OSSelector: React.FC<OSSelectorProps> = ({ value, onValueChange }) => {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{osOptions.map((os) => (
|
||||||
|
<button
|
||||||
|
key={os.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onValueChange(os.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between w-full rounded-md border px-4 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground transition",
|
||||||
|
value === os.value ? "bg-accent text-accent-foreground border-ring" : "border-input"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
src={os.logo}
|
||||||
|
alt={`${os.name} logo`}
|
||||||
|
className="w-6 h-6 object-contain"
|
||||||
|
/>
|
||||||
|
<span>{os.name}</span>
|
||||||
|
</div>
|
||||||
|
{value === os.value && <Check className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
import { Monitor, Server, Smartphone, Laptop } from "lucide-react";
|
||||||
|
|
||||||
|
interface OSTypeIconProps {
|
||||||
|
osType: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OSTypeIcon = ({ osType, className = "h-4 w-4" }: OSTypeIconProps) => {
|
||||||
|
const getOSIcon = (os: string) => {
|
||||||
|
const osLower = os.toLowerCase();
|
||||||
|
|
||||||
|
if (osLower.includes('linux') || osLower.includes('ubuntu') || osLower.includes('centos') || osLower.includes('debian')) {
|
||||||
|
return <Server className={className} />;
|
||||||
|
} else if (osLower.includes('windows')) {
|
||||||
|
return <Monitor className={className} />;
|
||||||
|
} else if (osLower.includes('mac') || osLower.includes('darwin')) {
|
||||||
|
return <Laptop className={className} />;
|
||||||
|
} else if (osLower.includes('android') || osLower.includes('ios')) {
|
||||||
|
return <Smartphone className={className} />;
|
||||||
|
} else {
|
||||||
|
return <Server className={className} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return getOSIcon(osType);
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Copy, Download } from "lucide-react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { copyToClipboard } from "@/utils/copyUtils";
|
||||||
|
|
||||||
|
interface OneClickInstallTabProps {
|
||||||
|
serverToken: string;
|
||||||
|
currentPocketBaseUrl: string;
|
||||||
|
formData: {
|
||||||
|
serverName: string;
|
||||||
|
osType: string;
|
||||||
|
checkInterval: string;
|
||||||
|
retryAttempt: string;
|
||||||
|
};
|
||||||
|
serverId: string;
|
||||||
|
onDialogClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OneClickInstallTab: React.FC<OneClickInstallTabProps> = ({
|
||||||
|
serverToken,
|
||||||
|
currentPocketBaseUrl,
|
||||||
|
formData,
|
||||||
|
serverId,
|
||||||
|
onDialogClose,
|
||||||
|
}) => {
|
||||||
|
const getOneClickInstallCommand = () => {
|
||||||
|
const scriptUrl = "https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/server-agent.sh";
|
||||||
|
|
||||||
|
return `curl -L -o server-agent.sh "${scriptUrl}"
|
||||||
|
chmod +x server-agent.sh
|
||||||
|
SERVER_TOKEN="${serverToken}" \\
|
||||||
|
POCKETBASE_URL="${currentPocketBaseUrl}" \\
|
||||||
|
SERVER_NAME="${formData.serverName}" \\
|
||||||
|
AGENT_ID="${serverId}" \\
|
||||||
|
OS_TYPE="${formData.osType}" \\
|
||||||
|
CHECK_INTERVAL="${formData.checkInterval}" \\
|
||||||
|
RETRY_ATTEMPTS="${formData.retryAttempt}" \\
|
||||||
|
sudo -E bash ./server-agent.sh`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyCommand = async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
console.log('Copy button clicked'); // Debug log
|
||||||
|
const command = getOneClickInstallCommand();
|
||||||
|
console.log('Copying command:', command); // Debug log
|
||||||
|
await copyToClipboard(command);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-green-500/20 bg-green-0/50 dark:bg-green-950/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-green-700 dark:text-green-400">
|
||||||
|
<Download className="h-5 w-5" />
|
||||||
|
One-Click Install
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-green-600 dark:text-green-300">
|
||||||
|
Copy and paste this single command to install the monitoring agent instantly
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-green-700 dark:text-green-400">Quick Install Command</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="bg-black-50 dark:bg-green-100/950 border border-green-200 dark:border-green-800 p-4 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all text-green-800 dark:text-green-200">
|
||||||
|
<code>{getOneClickInstallCommand()}</code>
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
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 text-green-700 dark:text-green-400"
|
||||||
|
onClick={handleCopyCommand}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 mr-1" />
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 p-3 rounded-md">
|
||||||
|
<p className="font-medium mb-1">Simply run this command on your server:</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-xs">
|
||||||
|
<li>SSH into your target server</li>
|
||||||
|
<li>Paste and run the command above</li>
|
||||||
|
<li>The agent will be installed and started automatically</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button onClick={onDialogClose}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Copy } from "lucide-react";
|
||||||
|
import { copyToClipboard } from "@/utils/copyUtils";
|
||||||
|
import { OSSelector } from "./OSSelector";
|
||||||
|
|
||||||
|
interface ServerAgentConfigFormProps {
|
||||||
|
formData: {
|
||||||
|
serverName: string;
|
||||||
|
description: string;
|
||||||
|
osType: string;
|
||||||
|
checkInterval: string;
|
||||||
|
retryAttempt: string;
|
||||||
|
dockerEnabled: boolean;
|
||||||
|
notificationEnabled: boolean;
|
||||||
|
};
|
||||||
|
setFormData: React.Dispatch<React.SetStateAction<{
|
||||||
|
serverName: string;
|
||||||
|
description: string;
|
||||||
|
osType: string;
|
||||||
|
checkInterval: string;
|
||||||
|
retryAttempt: string;
|
||||||
|
dockerEnabled: boolean;
|
||||||
|
notificationEnabled: boolean;
|
||||||
|
}>>;
|
||||||
|
serverId: string;
|
||||||
|
serverToken: string;
|
||||||
|
currentPocketBaseUrl: string;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerAgentConfigForm: React.FC<ServerAgentConfigFormProps> = ({
|
||||||
|
formData,
|
||||||
|
setFormData,
|
||||||
|
serverId,
|
||||||
|
serverToken,
|
||||||
|
currentPocketBaseUrl,
|
||||||
|
isSubmitting,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="serverName">Server Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="serverName"
|
||||||
|
placeholder="e.g., web-server-01"
|
||||||
|
value={formData.serverName}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, serverName: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">What is the name or label used as the identifier</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="serverId">Server Agent ID</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="serverId"
|
||||||
|
value={serverId}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-sm bg-muted"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyToClipboard(serverId)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Auto-generated unique identifier</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Operating System *</Label>
|
||||||
|
<OSSelector
|
||||||
|
value={formData.osType}
|
||||||
|
onValueChange={(value) => setFormData(prev => ({ ...prev, osType: value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="checkInterval">Check Interval</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.checkInterval}
|
||||||
|
onValueChange={(value) => setFormData(prev => ({ ...prev, checkInterval: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select interval" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="30">30 seconds</SelectItem>
|
||||||
|
<SelectItem value="60">1 minute</SelectItem>
|
||||||
|
<SelectItem value="120">2 minutes</SelectItem>
|
||||||
|
<SelectItem value="300">5 minutes</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">How often to check the server and metric status</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="retryAttempt">Retry Attempts</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.retryAttempt}
|
||||||
|
onValueChange={(value) => setFormData(prev => ({ ...prev, retryAttempt: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select retry attempts" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1 attempt</SelectItem>
|
||||||
|
<SelectItem value="2">2 attempts</SelectItem>
|
||||||
|
<SelectItem value="3">3 attempts</SelectItem>
|
||||||
|
<SelectItem value="5">5 attempts</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">Number of retry attempts before marking as down</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Server Token</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input value={serverToken} readOnly className="font-mono text-sm bg-muted" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyToClipboard(serverToken)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Auto-generated authentication token</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>System URL</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input value={currentPocketBaseUrl} readOnly className="font-mono text-sm bg-muted" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyToClipboard(currentPocketBaseUrl)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Current system API URL</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||||
|
{isSubmitting ? "Creating Agent..." : "Create Server Agent"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Loader2, TrendingUp } from "lucide-react";
|
||||||
|
import { TimeRangeSelector } from "./charts/TimeRangeSelector";
|
||||||
|
import { useServerHistoryData } from "./charts/hooks/useServerHistoryData";
|
||||||
|
import { CPUChart } from "./charts/CPUChart";
|
||||||
|
import { MemoryChart } from "./charts/MemoryChart";
|
||||||
|
import { DiskChart } from "./charts/DiskChart";
|
||||||
|
import { NetworkChart } from "./charts/NetworkChart";
|
||||||
|
|
||||||
|
interface ServerHistoryChartsProps {
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
|
||||||
|
const {
|
||||||
|
timeRange,
|
||||||
|
setTimeRange,
|
||||||
|
metrics,
|
||||||
|
chartData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isFetching
|
||||||
|
} = useServerHistoryData(serverId);
|
||||||
|
|
||||||
|
// console.log('ServerHistoryCharts: Rendering with serverId:', serverId, 'timeRange:', timeRange);
|
||||||
|
|
||||||
|
// Memoize latest data calculation to prevent unnecessary recalculations
|
||||||
|
const latestData = useMemo(() => {
|
||||||
|
return chartData.length > 0 ? chartData[chartData.length - 1] : null;
|
||||||
|
}, [chartData]);
|
||||||
|
|
||||||
|
// Show skeleton loading state for better UX
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5" />
|
||||||
|
<h2 className="text-lg font-medium">Historical Performance</h2>
|
||||||
|
<div className="flex items-center gap-2 ml-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-xs text-muted-foreground">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TimeRangeSelector value={timeRange} onChange={setTimeRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skeleton loading cards */}
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6">
|
||||||
|
{[1, 2, 3, 4].map((index) => (
|
||||||
|
<Card key={index} className="bg-gradient-to-br from-background to-muted/20">
|
||||||
|
<CardContent className="p-4 lg:p-6">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-muted rounded-lg"></div>
|
||||||
|
<div className="w-24 h-5 bg-muted rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div className="w-16 h-4 bg-muted rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-64 lg:h-80 bg-muted rounded"></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
// console.error('ServerHistoryCharts: Error loading data:', error);
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5" />
|
||||||
|
<h2 className="text-lg font-medium">Historical Performance</h2>
|
||||||
|
{isFetching && <Loader2 className="h-4 w-4 animate-spin ml-2" />}
|
||||||
|
</div>
|
||||||
|
<TimeRangeSelector value={timeRange} onChange={setTimeRange} />
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground">Error loading chart data</p>
|
||||||
|
<p className="text-xs mt-2 font-mono text-red-500">{error?.message}</p>
|
||||||
|
<p className="text-xs mt-1 text-muted-foreground">Server ID: {serverId} • Time Range: {timeRange}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5" />
|
||||||
|
<h2 className="text-lg font-medium">Historical Performance</h2>
|
||||||
|
{isFetching && <Loader2 className="h-4 w-4 animate-spin ml-2" />}
|
||||||
|
</div>
|
||||||
|
<TimeRangeSelector value={timeRange} onChange={setTimeRange} />
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground">No historical data available for {timeRange}</p>
|
||||||
|
<p className="text-xs mt-2">Raw metrics count: {metrics.length}</p>
|
||||||
|
<p className="text-xs mt-1">Server ID: {serverId} • Time Range: {timeRange}</p>
|
||||||
|
<p className="text-xs mt-1 text-muted-foreground">
|
||||||
|
{metrics.length > 0 ? 'Data exists but may be outside selected time range' : 'No metrics data found'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log('ServerHistoryCharts: Rendering charts with', chartData.length, 'data points for time range:', timeRange);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5" />
|
||||||
|
<h2 className="text-lg font-medium">Historical Performance</h2>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({chartData.length} data points • {timeRange})
|
||||||
|
{isFetching && (
|
||||||
|
<span className="inline-flex items-center gap-1 ml-2">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
<span className="text-blue-500">Updating...</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<TimeRangeSelector value={timeRange} onChange={setTimeRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Improved responsive grid layout */}
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6 auto-rows-fr">
|
||||||
|
<div className="w-full min-w-0">
|
||||||
|
<CPUChart data={chartData} latestData={latestData} />
|
||||||
|
</div>
|
||||||
|
<div className="w-full min-w-0">
|
||||||
|
<MemoryChart data={chartData} latestData={latestData} />
|
||||||
|
</div>
|
||||||
|
<div className="w-full min-w-0">
|
||||||
|
<DiskChart data={chartData} latestData={latestData} />
|
||||||
|
</div>
|
||||||
|
<div className="w-full min-w-0">
|
||||||
|
<NetworkChart data={chartData} latestData={latestData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { serverService } from "@/services/serverService";
|
||||||
|
import { Loader2, Cpu, HardDrive, Network, MemoryStick } from "lucide-react";
|
||||||
|
import { formatChartData } from "./charts/dataUtils";
|
||||||
|
import { TimeRangeSelector } from "./charts/TimeRangeSelector";
|
||||||
|
import { CPUChart } from "./charts/CPUChart";
|
||||||
|
import { MemoryChart } from "./charts/MemoryChart";
|
||||||
|
import { DiskChart } from "./charts/DiskChart";
|
||||||
|
import { NetworkChart } from "./charts/NetworkChart";
|
||||||
|
|
||||||
|
interface ServerMetricsChartsProps {
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
|
||||||
|
|
||||||
|
export const ServerMetricsCharts = ({ serverId }: ServerMetricsChartsProps) => {
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: metrics = [],
|
||||||
|
isLoading,
|
||||||
|
error
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['server-metrics', serverId, timeRange],
|
||||||
|
queryFn: () => serverService.getServerMetrics(serverId, timeRange),
|
||||||
|
enabled: !!serverId,
|
||||||
|
refetchInterval: timeRange === '60m' ? 60000 : timeRange === '1d' ? 120000 : 300000, // Increased intervals
|
||||||
|
staleTime: timeRange === '60m' ? 30000 : timeRange === '1d' ? 60000 : 120000, // Increased stale time
|
||||||
|
gcTime: 10 * 60 * 1000, // 10 minutes cache
|
||||||
|
refetchOnWindowFocus: false, // Prevent refetch on window focus
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = formatChartData(metrics, timeRange);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
<span className="ml-2">Loading server metrics...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96 text-muted-foreground">
|
||||||
|
<p>Error loading server metrics: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96 text-muted-foreground">
|
||||||
|
<p>No server metrics data available for {timeRange}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate latest data for each chart
|
||||||
|
const latestData = chartData[chartData.length - 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-lg font-medium">Server Metrics</h2>
|
||||||
|
<span className="text-xs text-muted-foreground">({chartData.length} data points • {timeRange})</span>
|
||||||
|
</div>
|
||||||
|
<TimeRangeSelector value={timeRange} onChange={setTimeRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="cpu" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-4 bg-muted/50 backdrop-blur-sm">
|
||||||
|
<TabsTrigger value="cpu" className="flex items-center gap-2 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||||
|
<Cpu className="h-4 w-4" />
|
||||||
|
CPU
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="memory" className="flex items-center gap-2 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||||
|
<MemoryStick className="h-4 w-4" />
|
||||||
|
Memory
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="disk" className="flex items-center gap-2 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||||
|
<HardDrive className="h-4 w-4" />
|
||||||
|
Disk
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="network" className="flex items-center gap-2 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||||
|
<Network className="h-4 w-4" />
|
||||||
|
Network
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="cpu" className="space-y-4 mt-6">
|
||||||
|
<CPUChart data={chartData} latestData={latestData} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="memory" className="space-y-4 mt-6">
|
||||||
|
<MemoryChart data={chartData} latestData={latestData} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="disk" className="space-y-4 mt-6">
|
||||||
|
<DiskChart data={chartData} latestData={latestData} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="network" className="space-y-4 mt-6">
|
||||||
|
<NetworkChart data={chartData} latestData={latestData} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Server } from "@/types/server.types";
|
||||||
|
import { Cpu, HardDrive, MemoryStick, Clock, Activity, Info } from "lucide-react";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
interface ServerMetricsOverviewProps {
|
||||||
|
server: Server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerMetricsOverview = ({ server }: ServerMetricsOverviewProps) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
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(1)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'up':
|
||||||
|
return 'text-green-500';
|
||||||
|
case 'down':
|
||||||
|
return 'text-red-500';
|
||||||
|
case 'warning':
|
||||||
|
return 'text-yellow-500';
|
||||||
|
default:
|
||||||
|
return 'text-muted-foreground';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cpuUsagePercent = server.cpu_usage || 0;
|
||||||
|
const ramUsagePercent = server.ram_total > 0 ? (server.ram_used / server.ram_total) * 100 : 0;
|
||||||
|
const diskUsagePercent = server.disk_total > 0 ? (server.disk_used / server.disk_total) * 100 : 0;
|
||||||
|
|
||||||
|
const MetricCard = ({ title, used, total, free, percentage, icon: Icon, color, gradient, additionalInfo }: {
|
||||||
|
title: string;
|
||||||
|
used: string;
|
||||||
|
total: string;
|
||||||
|
free: string;
|
||||||
|
percentage: number;
|
||||||
|
icon: any;
|
||||||
|
color: string;
|
||||||
|
gradient: string;
|
||||||
|
additionalInfo?: string;
|
||||||
|
}) => (
|
||||||
|
<Card
|
||||||
|
className="border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative"
|
||||||
|
style={{ background: 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="pb-3 relative z-10">
|
||||||
|
<CardTitle className="flex items-center justify-between text-sm font-semibold">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-xl bg-white/20 backdrop-blur-sm shadow-sm transition-all duration-300 group-hover:scale-110">
|
||||||
|
<Icon className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div 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">
|
||||||
|
{percentage.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4 relative z-10">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-white/70">Used:</span>
|
||||||
|
<span className="font-mono font-semibold text-white">{used}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-white/70">Free:</span>
|
||||||
|
<span className="font-mono font-semibold text-white">{free}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-white/70">Total:</span>
|
||||||
|
<span className="font-mono font-semibold text-white">{total}</span>
|
||||||
|
</div>
|
||||||
|
{additionalInfo && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-white/70">Cores:</span>
|
||||||
|
<span className="font-mono font-semibold text-white">{additionalInfo}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full bg-white/20 rounded-full h-3 overflow-hidden shadow-inner backdrop-blur-sm">
|
||||||
|
<div
|
||||||
|
className="h-3 rounded-full transition-all duration-700 ease-out relative overflow-hidden bg-white/80"
|
||||||
|
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-pulse" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-white/10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6">
|
||||||
|
{/* Status Card */}
|
||||||
|
<Card
|
||||||
|
className="border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative"
|
||||||
|
style={{
|
||||||
|
background: 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%)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 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="pb-3 relative z-10">
|
||||||
|
<CardTitle className="flex items-center gap-3 text-sm font-semibold">
|
||||||
|
<div className="p-2.5 rounded-xl bg-white/20 backdrop-blur-sm shadow-sm transition-all duration-300 group-hover:scale-110">
|
||||||
|
<Activity className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white">Status</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4 relative z-10">
|
||||||
|
<div className={`text-lg font-bold capitalize flex items-center gap-3 text-white`}>
|
||||||
|
<div className={`w-3 h-3 rounded-full ${server.status === 'up' ? 'bg-green-400' : server.status === 'down' ? 'bg-red-400' : 'bg-yellow-400'} animate-pulse shadow-sm`} />
|
||||||
|
{server.status}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="flex justify-between py-1">
|
||||||
|
<span className="text-white/70">Agent:</span>
|
||||||
|
<span className="font-semibold text-white">{server.agent_status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-1">
|
||||||
|
<span className="text-white/70">Connection:</span>
|
||||||
|
<span className="font-semibold text-white">{server.connection}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Uptime Card */}
|
||||||
|
<Card
|
||||||
|
className="border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative"
|
||||||
|
style={{
|
||||||
|
background: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(34, 197, 94, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #22c55e 100%)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 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="pb-3 relative z-10">
|
||||||
|
<CardTitle className="flex items-center gap-3 text-sm font-semibold">
|
||||||
|
<div className="p-2.5 rounded-xl bg-white/20 backdrop-blur-sm shadow-sm transition-all duration-300 group-hover:scale-110">
|
||||||
|
<Clock className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white">Uptime</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4 relative z-10">
|
||||||
|
<div className="text-lg font-bold text-white">
|
||||||
|
{server.uptime}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white/70 space-y-1">
|
||||||
|
<div>Last Check:</div>
|
||||||
|
<div className="font-mono text-[10px] text-white/90 bg-white/10 px-2 py-1 rounded backdrop-blur-sm">
|
||||||
|
{new Date(server.last_checked).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* CPU Metric */}
|
||||||
|
<MetricCard
|
||||||
|
title="CPU"
|
||||||
|
used={`${cpuUsagePercent.toFixed(1)}%`}
|
||||||
|
total={`${server.cpu_cores} cores`}
|
||||||
|
free={`${(100 - cpuUsagePercent).toFixed(1)}%`}
|
||||||
|
percentage={cpuUsagePercent}
|
||||||
|
icon={Cpu}
|
||||||
|
color={theme === 'dark' ? "#3b82f6" : "#2563eb"}
|
||||||
|
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%)"
|
||||||
|
}
|
||||||
|
additionalInfo={server.cpu_cores?.toString()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Memory Metric */}
|
||||||
|
<MetricCard
|
||||||
|
title="Memory"
|
||||||
|
used={formatBytes(server.ram_used)}
|
||||||
|
total={formatBytes(server.ram_total)}
|
||||||
|
free={formatBytes(server.ram_total - server.ram_used)}
|
||||||
|
percentage={ramUsagePercent}
|
||||||
|
icon={MemoryStick}
|
||||||
|
color={theme === 'dark' ? "#10b981" : "#059669"}
|
||||||
|
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%)"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Disk Metric */}
|
||||||
|
<MetricCard
|
||||||
|
title="Disk"
|
||||||
|
used={formatBytes(server.disk_used)}
|
||||||
|
total={formatBytes(server.disk_total)}
|
||||||
|
free={formatBytes(server.disk_total - server.disk_used)}
|
||||||
|
percentage={diskUsagePercent}
|
||||||
|
icon={HardDrive}
|
||||||
|
color={theme === 'dark' ? "#f59e0b" : "#d97706"}
|
||||||
|
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%)"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Server, Activity, AlertTriangle, Power } from "lucide-react";
|
||||||
|
import { ServerStats } from "@/types/server.types";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
interface ServerStatsCardsProps {
|
||||||
|
stats: ServerStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerStatsCards = ({ stats }: ServerStatsCardsProps) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
title: "TOTAL SERVERS",
|
||||||
|
value: stats.total,
|
||||||
|
icon: Server,
|
||||||
|
gradient: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(160, 82, 45, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #a0522d 100%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "ONLINE SERVERS",
|
||||||
|
value: stats.online,
|
||||||
|
icon: Activity,
|
||||||
|
gradient: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #66bb6a 100%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "OFFLINE SERVERS",
|
||||||
|
value: stats.offline,
|
||||||
|
icon: Power,
|
||||||
|
gradient: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #ef5350 100%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "WARNING SERVERS",
|
||||||
|
value: stats.warning,
|
||||||
|
icon: AlertTriangle,
|
||||||
|
gradient: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #ffb74d 100%)"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 w-full">
|
||||||
|
{cards.map((card, index) => (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
|
||||||
|
theme === 'dark' ? 'dark-card' : ''
|
||||||
|
} relative z-10`}
|
||||||
|
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(#000 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '20px 20px'
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<CardHeader className="pb-2 relative z-10">
|
||||||
|
<CardTitle className="text-sm font-medium text-white">{card.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center justify-between relative z-10">
|
||||||
|
<span className="text-5xl font-bold text-white">{card.value}</span>
|
||||||
|
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
|
||||||
|
<card.icon className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
interface ServerStatusBadgeProps {
|
||||||
|
status: 'up' | 'down' | 'warning' | 'paused';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerStatusBadge = ({ status }: ServerStatusBadgeProps) => {
|
||||||
|
const getStatusConfig = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'up':
|
||||||
|
return {
|
||||||
|
label: 'Online',
|
||||||
|
className: 'bg-green-600 text-green-100 border-green-200'
|
||||||
|
};
|
||||||
|
case 'down':
|
||||||
|
return {
|
||||||
|
label: 'Offline',
|
||||||
|
className: 'bg-red-600 text-red-100 border-red-200'
|
||||||
|
};
|
||||||
|
case 'warning':
|
||||||
|
return {
|
||||||
|
label: 'Warning',
|
||||||
|
className: 'bg-yellow-600 text-yellow-800 border-yellow-200'
|
||||||
|
};
|
||||||
|
case 'paused':
|
||||||
|
return {
|
||||||
|
label: 'Paused',
|
||||||
|
className: 'bg-gray-600 text-gray-100 border-gray-200'
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
label: 'Unknown',
|
||||||
|
className: 'bg-gray-600 text-gray-100 border-gray-200'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getStatusConfig(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={config.className}>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Server, Monitor, Cpu, HardDrive, DatabaseIcon, InfoIcon } from "lucide-react";
|
||||||
|
import { Server as ServerType } from "@/types/server.types";
|
||||||
|
|
||||||
|
interface ServerSystemInfoCardProps {
|
||||||
|
server: ServerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerSystemInfoCard({ server }: ServerSystemInfoCardProps) {
|
||||||
|
// Parse system_info if it's a string
|
||||||
|
let systemInfo: any = {};
|
||||||
|
if (server.system_info) {
|
||||||
|
try {
|
||||||
|
systemInfo = typeof server.system_info === 'string'
|
||||||
|
? JSON.parse(server.system_info)
|
||||||
|
: server.system_info;
|
||||||
|
} catch (error) {
|
||||||
|
// console.log('Error parsing system_info:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUptime = (uptimeSeconds: string | number) => {
|
||||||
|
const seconds = typeof uptimeSeconds === 'string' ? parseInt(uptimeSeconds) : uptimeSeconds;
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d ${hours}h`;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format the detailed system info string
|
||||||
|
const getDetailedSystemInfo = () => {
|
||||||
|
if (typeof server.system_info === 'string' && server.system_info.includes('|')) {
|
||||||
|
// If system_info is already in the detailed format, return it
|
||||||
|
return server.system_info;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, build the detailed string from available data
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
// Basic server info
|
||||||
|
parts.push(server.hostname || 'Unknown');
|
||||||
|
parts.push(server.ip_address || 'Unknown IP');
|
||||||
|
parts.push(server.os_type || 'Unknown OS');
|
||||||
|
|
||||||
|
// Parse additional info from system_info if available
|
||||||
|
if (systemInfo.OSName) {
|
||||||
|
parts.push(systemInfo.OSName + (systemInfo.OSVersion ? ` ${systemInfo.OSVersion}` : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemInfo.Architecture) {
|
||||||
|
parts.push(`| ${systemInfo.Architecture}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemInfo.KernelVersion) {
|
||||||
|
parts.push(`| Kernel: ${systemInfo.KernelVersion}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemInfo.CPUModel) {
|
||||||
|
parts.push(`| CPU: ${systemInfo.CPUModel} (${server.cpu_cores || 0} cores)`);
|
||||||
|
} else if (server.cpu_cores) {
|
||||||
|
parts.push(`| CPU: ${server.cpu_cores} cores`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.ram_total) {
|
||||||
|
parts.push(`| RAM: ${formatBytes(server.ram_total)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemInfo.GoVersion) {
|
||||||
|
parts.push(`| Go ${systemInfo.GoVersion}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemInfo.IPAddress && systemInfo.IPAddress !== server.ip_address) {
|
||||||
|
parts.push(`| IP: ${systemInfo.IPAddress}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Docker info
|
||||||
|
const dockerInfo = server.docker || systemInfo.Docker;
|
||||||
|
if (dockerInfo !== undefined) {
|
||||||
|
parts.push(`| Docker: ${dockerInfo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' • ').replace(/• \|/g, '|');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-card/50 backdrop-blur-sm border-border/50 max-w-md">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="h-6 w-6 rounded bg-primary/10 flex items-center justify-center">
|
||||||
|
<InfoIcon className="h-3 w-3 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-medium">System Information</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground leading-relaxed break-all">
|
||||||
|
{getDetailedSystemInfo()}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||||
|
import { RefreshCw, Search, Eye, Activity, MoreHorizontal, Pause, Play, Edit, Trash2 } from "lucide-react";
|
||||||
|
import { Server } from "@/types/server.types";
|
||||||
|
import { ServerStatusBadge } from "./ServerStatusBadge";
|
||||||
|
import { OSTypeIcon } from "./OSTypeIcon";
|
||||||
|
import { EditServerDialog } from "./EditServerDialog";
|
||||||
|
import { serverService } from "@/services/serverService";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
interface ServerTableProps {
|
||||||
|
servers: Server[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [pausingServers, setPausingServers] = useState<Set<string>>(new Set());
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const filteredServers = servers.filter(server =>
|
||||||
|
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
server.hostname.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
server.ip_address.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleViewDetails = (serverId: string) => {
|
||||||
|
navigate(`/server-detail/${serverId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewContainers = (serverId: string) => {
|
||||||
|
navigate(`/container-monitoring/${serverId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePauseResume = async (server: Server) => {
|
||||||
|
const serverId = server.id;
|
||||||
|
const isPaused = server.status === "paused";
|
||||||
|
|
||||||
|
if (pausingServers.has(serverId)) {
|
||||||
|
return; // Already processing this server
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setPausingServers(prev => new Set(prev).add(serverId));
|
||||||
|
|
||||||
|
// Only update the status field, preserving all other server configuration
|
||||||
|
const updateData = {
|
||||||
|
status: isPaused ? "up" : "paused",
|
||||||
|
last_checked: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
await pb.collection('servers').update(serverId, updateData);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: isPaused ? "Server resumed" : "Server paused",
|
||||||
|
description: `Monitoring ${isPaused ? 'resumed' : 'paused'} for ${server.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log(`${isPaused ? 'Resume' : 'Pause'} server monitoring: ${serverId}`);
|
||||||
|
|
||||||
|
// Refresh the server list to show updated status
|
||||||
|
onRefresh();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('Error updating server status:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: `Failed to ${isPaused ? 'resume' : 'pause'} server monitoring. Please try again.`,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPausingServers(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(serverId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (server: Server) => {
|
||||||
|
setSelectedServer(server);
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (server: Server) => {
|
||||||
|
setSelectedServer(server);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!selectedServer || isDeleting) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsDeleting(true);
|
||||||
|
|
||||||
|
// Delete the server from the database
|
||||||
|
await pb.collection('servers').delete(selectedServer.id);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Server deleted",
|
||||||
|
description: `${selectedServer.name} has been deleted successfully.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh the server list
|
||||||
|
onRefresh();
|
||||||
|
|
||||||
|
// Close the dialog
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setSelectedServer(null);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('Error deleting server:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to delete server. Please try again.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomProgressBar = ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
subtitle,
|
||||||
|
type
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
subtitle: string;
|
||||||
|
type: 'cpu' | 'memory' | 'disk'
|
||||||
|
}) => {
|
||||||
|
const getGradientColors = (type: string, value: number) => {
|
||||||
|
if (type === 'cpu') {
|
||||||
|
if (value > 90) return 'from-red-500 to-red-600';
|
||||||
|
if (value > 75) return 'from-orange-500 to-orange-600';
|
||||||
|
if (value > 60) return 'from-yellow-500 to-yellow-600';
|
||||||
|
return 'from-green-500 to-green-600';
|
||||||
|
}
|
||||||
|
if (type === 'memory') {
|
||||||
|
if (value > 90) return 'from-red-500 to-red-600';
|
||||||
|
if (value > 75) return 'from-yellow-500 to-yellow-600';
|
||||||
|
return 'from-blue-500 to-blue-600';
|
||||||
|
}
|
||||||
|
if (type === 'disk') {
|
||||||
|
if (value > 95) return 'from-red-500 to-red-600';
|
||||||
|
if (value > 85) return 'from-yellow-500 to-yellow-600';
|
||||||
|
return 'from-orange-500 to-orange-600';
|
||||||
|
}
|
||||||
|
return 'from-gray-500 to-gray-600';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextColor = (value: number) => {
|
||||||
|
if (value > 90) return 'text-red-600 dark:text-red-400';
|
||||||
|
if (value > 75) return 'text-orange-600 dark:text-orange-400';
|
||||||
|
if (value > 60) return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
return 'text-green-600 dark:text-green-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 min-w-[120px]">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className={`text-sm font-semibold ${getTextColor(value)}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{subtitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-full h-3 bg-muted/30 rounded-full overflow-hidden shadow-inner">
|
||||||
|
<div
|
||||||
|
className={`h-full bg-gradient-to-r ${getGradientColors(type, value)} rounded-full transition-all duration-700 ease-out relative overflow-hidden`}
|
||||||
|
style={{ width: `${Math.min(value, 100)}%` }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-pulse opacity-60" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-white/10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="flex-1 flex flex-col">
|
||||||
|
<CardHeader className="flex-shrink-0">
|
||||||
|
<CardTitle>Servers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2">Loading servers...</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="bg-transparent border-0 shadow-none">
|
||||||
|
<CardHeader className="pb-4 px-0">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<CardTitle className="text-xl font-semibold">Servers</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1 sm:w-64">
|
||||||
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search servers..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onRefresh} variant="outline" size="icon">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{filteredServers.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<p className="text-muted-foreground">No servers found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg border border-border shadow-sm`}>
|
||||||
|
<Table>
|
||||||
|
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
||||||
|
<TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
|
||||||
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Name</TableHead>
|
||||||
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Status</TableHead>
|
||||||
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>OS</TableHead>
|
||||||
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>IP Address</TableHead>
|
||||||
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>CPU</TableHead>
|
||||||
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Memory</TableHead>
|
||||||
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Disk</TableHead>
|
||||||
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Uptime</TableHead>
|
||||||
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Last Checked</TableHead>
|
||||||
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4 text-right`}>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredServers.map((server) => {
|
||||||
|
const cpuUsage = server.cpu_usage || 0;
|
||||||
|
const memoryUsage = server.ram_total > 0 ? (server.ram_used / server.ram_total) * 100 : 0;
|
||||||
|
const diskUsage = server.disk_total > 0 ? (server.disk_used / server.disk_total) * 100 : 0;
|
||||||
|
const isPaused = server.status === "paused";
|
||||||
|
const isProcessing = pausingServers.has(server.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={server.id} className="hover:bg-muted/50">
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="truncate" title={server.name}>
|
||||||
|
{server.name}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ServerStatusBadge status={server.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<OSTypeIcon osType={server.os_type} />
|
||||||
|
<span className="text-sm truncate" title={server.os_type}>
|
||||||
|
{server.os_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-sm bg-muted px-1 py-0.5 rounded text-xs">
|
||||||
|
{server.ip_address}
|
||||||
|
</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<CustomProgressBar
|
||||||
|
value={cpuUsage}
|
||||||
|
label={`${cpuUsage.toFixed(1)}%`}
|
||||||
|
subtitle={`${server.cpu_cores} cores`}
|
||||||
|
type="cpu"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<CustomProgressBar
|
||||||
|
value={memoryUsage}
|
||||||
|
label={`${memoryUsage.toFixed(1)}%`}
|
||||||
|
subtitle={serverService.formatBytes(server.ram_total)}
|
||||||
|
type="memory"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<CustomProgressBar
|
||||||
|
value={diskUsage}
|
||||||
|
label={`${diskUsage.toFixed(1)}%`}
|
||||||
|
subtitle={serverService.formatBytes(server.disk_total)}
|
||||||
|
type="disk"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm truncate" title={server.uptime}>
|
||||||
|
{server.uptime}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm text-muted-foreground text-xs">
|
||||||
|
{new Date(server.last_checked).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0" disabled={isProcessing}>
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
{isProcessing ? (
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[200px]">
|
||||||
|
<DropdownMenuItem onClick={() => handleViewDetails(server.id)}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
View Server Detail
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{server.docker === 'true' && (
|
||||||
|
<DropdownMenuItem onClick={() => handleViewContainers(server.id)}>
|
||||||
|
<Activity className="mr-2 h-4 w-4" />
|
||||||
|
Container Monitoring
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handlePauseResume(server)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{isPaused ? (
|
||||||
|
<>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Resume Monitoring
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pause className="mr-2 h-4 w-4" />
|
||||||
|
Pause Monitoring
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => handleEdit(server)}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit Server
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDelete(server)}
|
||||||
|
className="text-red-600 focus:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete Server
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Edit Server Dialog */}
|
||||||
|
<EditServerDialog
|
||||||
|
server={selectedServer}
|
||||||
|
open={editDialogOpen}
|
||||||
|
onOpenChange={setEditDialogOpen}
|
||||||
|
onServerUpdated={onRefresh}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure you want to delete this server?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete{' '}
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{selectedServer?.name}
|
||||||
|
</span>{' '}
|
||||||
|
and all of its monitoring data.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{isDeleting ? "Deleting..." : "Delete"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
||||||
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from "recharts";
|
||||||
|
import { Cpu } from "lucide-react";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent";
|
||||||
|
|
||||||
|
interface CPUChartProps {
|
||||||
|
data: any[];
|
||||||
|
latestData?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CPUChart = ({ data, latestData }: CPUChartProps) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
const getGridColor = () => theme === 'dark' ? '#374151' : '#e5e7eb';
|
||||||
|
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm h-full flex flex-col">
|
||||||
|
<CardHeader className="pb-2 flex-shrink-0">
|
||||||
|
<CardTitle className="flex items-center justify-between text-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-2 rounded-lg bg-blue-500/15">
|
||||||
|
<Cpu className="h-4 w-4 lg:h-5 lg:w-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm lg:text-base">CPU Usage</span>
|
||||||
|
</div>
|
||||||
|
{latestData && (
|
||||||
|
<div className="text-right text-xs lg:text-sm">
|
||||||
|
<div className="text-blue-500 font-semibold">{latestData.cpuUsage}%</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{latestData.cpuCores} cores</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-2 flex-1 min-h-0">
|
||||||
|
<div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="cpuGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={theme === 'dark' ? "#3b82f6" : "#2563eb"} stopOpacity={0.4}/>
|
||||||
|
<stop offset="95%" stopColor={theme === 'dark' ? "#1e40af" : "#1d4ed8"} stopOpacity={0.1}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} opacity={0.3} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
label={{ value: 'CPU %', angle: -90, position: 'insideLeft' }}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={<DetailedTooltipContent />}
|
||||||
|
cursor={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cpuUsage"
|
||||||
|
stroke={theme === 'dark' ? "#60a5fa" : "#2563eb"}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
fill="url(#cpuGradient)"
|
||||||
|
fillOpacity={1}
|
||||||
|
name="CPU Usage (%)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
||||||
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from "recharts";
|
||||||
|
import { HardDrive } from "lucide-react";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent";
|
||||||
|
|
||||||
|
interface DiskChartProps {
|
||||||
|
data: any[];
|
||||||
|
latestData?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DiskChart = ({ data, latestData }: DiskChartProps) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
const getGridColor = () => theme === 'dark' ? '#374151' : '#e5e7eb';
|
||||||
|
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm h-full flex flex-col">
|
||||||
|
<CardHeader className="pb-2 flex-shrink-0">
|
||||||
|
<CardTitle className="flex items-center justify-between text-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-2 rounded-lg bg-amber-500/15">
|
||||||
|
<HardDrive className="h-4 w-4 lg:h-5 lg:w-5 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm lg:text-base">Disk Usage</span>
|
||||||
|
</div>
|
||||||
|
{latestData && (
|
||||||
|
<div className="text-right text-xs lg:text-sm">
|
||||||
|
<div className="text-amber-500 font-semibold">{latestData.diskUsagePercent}%</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{latestData.diskUsed} / {latestData.diskTotal}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-2 flex-1 min-h-0">
|
||||||
|
<div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="diskGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={theme === 'dark' ? "#f59e0b" : "#d97706"} stopOpacity={0.6}/>
|
||||||
|
<stop offset="95%" stopColor={theme === 'dark' ? "#d97706" : "#b45309"} stopOpacity={0.1}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} opacity={0.3} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
label={{ value: 'Disk %', angle: -90, position: 'insideLeft' }}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={<DetailedTooltipContent />}
|
||||||
|
cursor={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="step"
|
||||||
|
dataKey="diskUsagePercent"
|
||||||
|
stroke={theme === 'dark' ? "#fbbf24" : "#d97706"}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
fill="url(#diskGradient)"
|
||||||
|
fillOpacity={1}
|
||||||
|
name="Disk Usage (%)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
||||||
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from "recharts";
|
||||||
|
import { MemoryStick } from "lucide-react";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent";
|
||||||
|
|
||||||
|
interface MemoryChartProps {
|
||||||
|
data: any[];
|
||||||
|
latestData?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MemoryChart = ({ data, latestData }: MemoryChartProps) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
const getGridColor = () => theme === 'dark' ? '#374151' : '#e5e7eb';
|
||||||
|
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm h-full flex flex-col">
|
||||||
|
<CardHeader className="pb-2 flex-shrink-0">
|
||||||
|
<CardTitle className="flex items-center justify-between text-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-2 rounded-lg bg-green-500/15">
|
||||||
|
<MemoryStick className="h-4 w-4 lg:h-5 lg:w-5 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm lg:text-base">Memory Usage</span>
|
||||||
|
</div>
|
||||||
|
{latestData && (
|
||||||
|
<div className="text-right text-xs lg:text-sm">
|
||||||
|
<div className="text-green-500 font-semibold">{latestData.ramUsagePercent}%</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{latestData.ramUsed} / {latestData.ramTotal}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-2 flex-1 min-h-0">
|
||||||
|
<div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="memoryGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={theme === 'dark' ? "#10b981" : "#059669"} stopOpacity={0.5}/>
|
||||||
|
<stop offset="95%" stopColor={theme === 'dark' ? "#047857" : "#065f46"} stopOpacity={0.1}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} opacity={0.3} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
label={{ value: 'Memory %', angle: -90, position: 'insideLeft' }}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={<DetailedTooltipContent />}
|
||||||
|
cursor={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="basis"
|
||||||
|
dataKey="ramUsagePercent"
|
||||||
|
stroke={theme === 'dark' ? "#34d399" : "#059669"}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
fill="url(#memoryGradient)"
|
||||||
|
fillOpacity={1}
|
||||||
|
name="Memory Usage (%)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from "recharts";
|
||||||
|
import { Wifi } from "lucide-react";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { NetworkTooltipContent } from "./tooltips/NetworkTooltipContent";
|
||||||
|
|
||||||
|
interface NetworkChartProps {
|
||||||
|
data: any[];
|
||||||
|
latestData?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NetworkChart = ({ data, latestData }: NetworkChartProps) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
const getGridColor = () => theme === 'dark' ? '#374151' : '#e5e7eb';
|
||||||
|
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm h-full flex flex-col">
|
||||||
|
<CardHeader className="pb-2 flex-shrink-0">
|
||||||
|
<CardTitle className="flex items-center justify-between text-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-2 rounded-lg bg-purple-500/15">
|
||||||
|
<Wifi className="h-4 w-4 lg:h-5 lg:w-5 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm lg:text-base">Network Traffic</span>
|
||||||
|
</div>
|
||||||
|
{latestData && (
|
||||||
|
<div className="text-right text-xs lg:text-sm">
|
||||||
|
<div className="text-purple-500 font-semibold">{latestData.networkRxSpeed} KB/s ↓</div>
|
||||||
|
<div className="text-red-500 font-semibold">{latestData.networkTxSpeed} KB/s ↑</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-2 flex-1 min-h-0">
|
||||||
|
<div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="networkGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={theme === 'dark' ? "#8b5cf6" : "#7c3aed"} stopOpacity={0.2}/>
|
||||||
|
<stop offset="95%" stopColor={theme === 'dark' ? "#7c3aed" : "#6d28d9"} stopOpacity={0.05}/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glow">
|
||||||
|
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="coloredBlur"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} opacity={0.3} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||||
|
axisLine={{ stroke: getGridColor() }}
|
||||||
|
label={{ value: 'Network Speed (KB/s)', angle: -90, position: 'insideLeft' }}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={<NetworkTooltipContent />}
|
||||||
|
cursor={{ stroke: getGridColor() }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="networkRxSpeed"
|
||||||
|
stroke={theme === 'dark' ? "#a78bfa" : "#7c3aed"}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
name="RX Speed"
|
||||||
|
filter="url(#glow)"
|
||||||
|
strokeDasharray="0"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="networkTxSpeed"
|
||||||
|
stroke={theme === 'dark' ? "#f87171" : "#dc2626"}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
name="TX Speed"
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
|
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
|
||||||
|
|
||||||
|
interface TimeRangeSelectorProps {
|
||||||
|
value: TimeRange;
|
||||||
|
onChange: (value: TimeRange) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeRangeOptions = [
|
||||||
|
{ value: '60m' as TimeRange, label: 'Last 60 minutes' },
|
||||||
|
{ value: '1d' as TimeRange, label: 'Last 24 hours' },
|
||||||
|
{ value: '7d' as TimeRange, label: 'Last 7 days' },
|
||||||
|
{ value: '1m' as TimeRange, label: 'Last 30 days' },
|
||||||
|
{ value: '3m' as TimeRange, label: 'Last 90 days' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TimeRangeSelector = ({ value, onChange }: TimeRangeSelectorProps) => {
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={(value: TimeRange) => onChange(value)}>
|
||||||
|
<SelectTrigger className="w-[160px] h-8">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{timeRangeOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
export 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];
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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 };
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
|
||||||
|
|
||||||
|
export const timeRangeOptions = [
|
||||||
|
{ value: '60m' as TimeRange, label: 'Last 60 minutes', hours: 1 },
|
||||||
|
{ value: '1d' as TimeRange, label: 'Last 24 hours', hours: 24 },
|
||||||
|
{ value: '7d' as TimeRange, label: 'Last 7 days', hours: 24 * 7 },
|
||||||
|
{ value: '1m' as TimeRange, label: 'Last 30 days', hours: 24 * 30 },
|
||||||
|
{ value: '3m' as TimeRange, label: 'Last 90 days', hours: 24 * 90 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const filterMetricsByTimeRange = (metrics: any[], timeRange: TimeRange): any[] => {
|
||||||
|
if (!metrics?.length) {
|
||||||
|
// console.log('🔍 filterMetricsByTimeRange: No metrics provided');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
//console.log('🔍 filterMetricsByTimeRange: Starting with', metrics.length, 'metrics for', timeRange);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// For 60m, let's be very specific about what we're looking for
|
||||||
|
if (timeRange === '60m') {
|
||||||
|
// console.log('⏰ 60m filter: Current time:', now.toISOString());
|
||||||
|
|
||||||
|
// Try exact 60 minutes first
|
||||||
|
const cutoffTime60m = new Date(now.getTime() - (60 * 60 * 1000));
|
||||||
|
// console.log('⏰ 60m filter: Cutoff time:', cutoffTime60m.toISOString());
|
||||||
|
|
||||||
|
const filtered60m = metrics.filter(metric => {
|
||||||
|
const metricTime = new Date(metric.created || metric.timestamp);
|
||||||
|
const isWithinRange = metricTime >= cutoffTime60m && metricTime <= now;
|
||||||
|
|
||||||
|
if (!isWithinRange) {
|
||||||
|
const ageMinutes = Math.round((now.getTime() - metricTime.getTime()) / (1000 * 60));
|
||||||
|
// console.log('⏰ Excluding record:', {
|
||||||
|
// created: metric.created || metric.timestamp,
|
||||||
|
// ageMinutes: ageMinutes,
|
||||||
|
// reason: ageMinutes > 60 ? 'too old' : 'future date'
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
return isWithinRange;
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log('✅ 60m strict filter result:', filtered60m.length, 'records');
|
||||||
|
|
||||||
|
if (filtered60m.length > 0) {
|
||||||
|
return filtered60m.sort((a, b) =>
|
||||||
|
new Date(a.created || a.timestamp).getTime() - new Date(b.created || b.timestamp).getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no data in exactly 60m, show what we have and return it anyway for debugging
|
||||||
|
// console.log('⚠️ No data in 60m range, showing all available data ages:');
|
||||||
|
metrics.forEach((metric, index) => {
|
||||||
|
const metricTime = new Date(metric.created || metric.timestamp);
|
||||||
|
const ageMinutes = Math.round((now.getTime() - metricTime.getTime()) / (1000 * 60));
|
||||||
|
// console.log(`Record ${index}: ${metric.created || metric.timestamp} (${ageMinutes} minutes ago)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the most recent data regardless of age
|
||||||
|
const sorted = metrics.sort((a, b) =>
|
||||||
|
new Date(b.created || b.timestamp).getTime() - new Date(a.created || a.timestamp).getTime()
|
||||||
|
);
|
||||||
|
// console.log('🔄 Returning most recent available data for 60m view');
|
||||||
|
return sorted.slice(0, 20); // Show last 20 records
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other time ranges, use normal filtering
|
||||||
|
const selectedRange = timeRangeOptions.find(opt => opt.value === timeRange);
|
||||||
|
if (!selectedRange) return metrics;
|
||||||
|
|
||||||
|
const cutoffTime = new Date(now.getTime() - (selectedRange.hours * 60 * 60 * 1000));
|
||||||
|
|
||||||
|
const filtered = metrics.filter(metric => {
|
||||||
|
const metricTime = new Date(metric.created || metric.timestamp);
|
||||||
|
return metricTime >= cutoffTime && metricTime <= now;
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log('✅ Filtered', metrics.length, 'to', filtered.length, 'metrics for', timeRange);
|
||||||
|
|
||||||
|
return filtered.sort((a, b) =>
|
||||||
|
new Date(a.created || a.timestamp).getTime() - new Date(b.created || b.timestamp).getTime()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timestampCache = new Map<string, string>();
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: string, timeRange: TimeRange): string => {
|
||||||
|
const cacheKey = `${timestamp}-${timeRange}`;
|
||||||
|
if (timestampCache.has(cacheKey)) {
|
||||||
|
return timestampCache.get(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
let formatted: string;
|
||||||
|
|
||||||
|
if (timeRange === '60m') {
|
||||||
|
formatted = date.toLocaleString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
});
|
||||||
|
} else if (timeRange === '1d') {
|
||||||
|
formatted = date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
});
|
||||||
|
} else if (timeRange === '7d') {
|
||||||
|
formatted = date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
formatted = date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timestampCache.size > 1000) {
|
||||||
|
timestampCache.clear();
|
||||||
|
}
|
||||||
|
timestampCache.set(cacheKey, formatted);
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatChartData = (metrics: any[], timeRange: TimeRange) => {
|
||||||
|
// console.log('📊 formatChartData: Processing', {
|
||||||
|
// inputCount: metrics?.length || 0,
|
||||||
|
// timeRange,
|
||||||
|
// sampleMetric: metrics?.[0] ? {
|
||||||
|
// id: metrics[0].id,
|
||||||
|
// created: metrics[0].created,
|
||||||
|
// timestamp: metrics[0].timestamp,
|
||||||
|
// server_id: metrics[0].server_id
|
||||||
|
// } : null
|
||||||
|
// });
|
||||||
|
|
||||||
|
if (!metrics?.length) {
|
||||||
|
// console.log('❌ formatChartData: No metrics provided');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredMetrics = filterMetricsByTimeRange(metrics, timeRange);
|
||||||
|
// console.log('📊 formatChartData: After time filtering:', filteredMetrics?.length || 0, 'metrics');
|
||||||
|
|
||||||
|
if (!filteredMetrics.length) {
|
||||||
|
// console.log('❌ formatChartData: No metrics after time filtering');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic sampling based on time range and data volume
|
||||||
|
let maxDataPoints: number;
|
||||||
|
switch (timeRange) {
|
||||||
|
case '60m': maxDataPoints = 60; break;
|
||||||
|
case '1d': maxDataPoints = 144; break;
|
||||||
|
case '7d': maxDataPoints = 168; break;
|
||||||
|
case '1m': maxDataPoints = 120; break;
|
||||||
|
case '3m': maxDataPoints = 90; break;
|
||||||
|
default: maxDataPoints = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampledMetrics = filteredMetrics.length > maxDataPoints * 1.2
|
||||||
|
? filteredMetrics.filter((_, index) => index % Math.ceil(filteredMetrics.length / maxDataPoints) === 0)
|
||||||
|
: filteredMetrics;
|
||||||
|
|
||||||
|
// console.log('📊 formatChartData: After sampling:', sampledMetrics.length, 'metrics for display');
|
||||||
|
|
||||||
|
const formattedData = sampledMetrics.map((metric) => {
|
||||||
|
const cpuUsage = typeof metric.cpu_usage === 'string' ?
|
||||||
|
parseFloat(metric.cpu_usage.replace('%', '')) :
|
||||||
|
parseFloat(metric.cpu_usage) || 0;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const timestamp = metric.created || metric.timestamp;
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: formatTimestamp(timestamp, timeRange),
|
||||||
|
fullTimestamp: timestamp,
|
||||||
|
cpuUsage: Math.round(cpuUsage * 100) / 100,
|
||||||
|
cpuCores: parseInt(metric.cpu_cores) || 0,
|
||||||
|
cpuFree: Math.round((100 - cpuUsage) * 100) / 100,
|
||||||
|
|
||||||
|
ramUsedBytes,
|
||||||
|
ramTotalBytes,
|
||||||
|
ramFreeBytes,
|
||||||
|
ramUsed: formatBytes(ramUsedBytes),
|
||||||
|
ramTotal: formatBytes(ramTotalBytes),
|
||||||
|
ramFree: formatBytes(ramFreeBytes),
|
||||||
|
ramUsagePercent: Math.round(ramUsagePercent * 100) / 100,
|
||||||
|
|
||||||
|
diskUsedBytes,
|
||||||
|
diskTotalBytes,
|
||||||
|
diskFreeBytes,
|
||||||
|
diskUsed: formatBytes(diskUsedBytes),
|
||||||
|
diskTotal: formatBytes(diskTotalBytes),
|
||||||
|
diskFree: formatBytes(diskFreeBytes),
|
||||||
|
diskUsagePercent: Math.round(diskUsagePercent * 100) / 100,
|
||||||
|
|
||||||
|
networkRxBytes,
|
||||||
|
networkTxBytes,
|
||||||
|
networkRx: formatBytes(networkRxBytes),
|
||||||
|
networkTx: formatBytes(networkTxBytes),
|
||||||
|
networkRxSpeed: Math.round(networkRxSpeed * 100) / 100,
|
||||||
|
networkTxSpeed: Math.round(networkTxSpeed * 100) / 100,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log('✅ formatChartData: Final formatted data count:', formattedData.length);
|
||||||
|
return formattedData;
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { serverService } from "@/services/serverService";
|
||||||
|
import { formatChartData } from "../dataUtils";
|
||||||
|
|
||||||
|
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
|
||||||
|
|
||||||
|
export const useServerHistoryData = (serverId: string) => {
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>("60m");
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: metrics = [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isFetching
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['server-metrics-history', serverId, timeRange],
|
||||||
|
queryFn: async () => {
|
||||||
|
// console.log('useServerHistoryData: Fetching metrics for serverId:', serverId, 'timeRange:', timeRange);
|
||||||
|
const result = await serverService.getServerMetrics(serverId, timeRange);
|
||||||
|
// console.log('useServerHistoryData: Raw metrics result for timeRange', timeRange, ':', result?.length || 0, 'records');
|
||||||
|
return result || [];
|
||||||
|
},
|
||||||
|
enabled: !!serverId,
|
||||||
|
refetchInterval: timeRange === '60m' ? 60000 : timeRange === '1d' ? 120000 : 300000, // Significantly increased intervals
|
||||||
|
staleTime: timeRange === '60m' ? 30000 : timeRange === '1d' ? 60000 : 120000, // Increased stale time
|
||||||
|
retry: 1, // Reduced retries to prevent excessive requests
|
||||||
|
retryDelay: 2000, // Slower retry
|
||||||
|
gcTime: 10 * 60 * 1000, // 10 minutes cache
|
||||||
|
refetchOnWindowFocus: false, // Prevent refetch on window focus
|
||||||
|
refetchOnMount: false, // Prevent refetch on mount if data exists
|
||||||
|
});
|
||||||
|
|
||||||
|
// Memoize chart data formatting to prevent unnecessary recalculations
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
return formatChartData(metrics, timeRange);
|
||||||
|
}, [metrics, timeRange]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeRange,
|
||||||
|
setTimeRange,
|
||||||
|
metrics,
|
||||||
|
chartData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isFetching
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
|
||||||
|
interface DetailedTooltipContentProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: any[];
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DetailedTooltipContent = ({ active, payload, label }: DetailedTooltipContentProps) => {
|
||||||
|
if (!active || !payload || !payload.length) return null;
|
||||||
|
|
||||||
|
const data = payload[0]?.payload;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-popover/95 border border-border backdrop-blur-sm rounded-lg p-3 shadow-lg">
|
||||||
|
<p className="font-medium text-popover-foreground mb-2">{label}</p>
|
||||||
|
{data?.fullTimestamp && (
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
{new Date(data.fullTimestamp).toLocaleString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{payload.map((entry: any, index: number) => {
|
||||||
|
const data = entry.payload;
|
||||||
|
return (
|
||||||
|
<div key={index} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{entry.name}: {entry.value}%</span>
|
||||||
|
</div>
|
||||||
|
{entry.dataKey === 'cpuUsage' && (
|
||||||
|
<div className="text-xs text-muted-foreground ml-5">
|
||||||
|
<div>CPU Cores: {data.cpuCores}</div>
|
||||||
|
<div>Free: {data.cpuFree}%</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entry.dataKey === 'ramUsagePercent' && (
|
||||||
|
<div className="text-xs text-muted-foreground ml-5">
|
||||||
|
<div>Used: {data.ramUsed}</div>
|
||||||
|
<div>Total: {data.ramTotal}</div>
|
||||||
|
<div>Free: {data.ramFree}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entry.dataKey === 'diskUsagePercent' && (
|
||||||
|
<div className="text-xs text-muted-foreground ml-5">
|
||||||
|
<div>Used: {data.diskUsed}</div>
|
||||||
|
<div>Total: {data.diskTotal}</div>
|
||||||
|
<div>Free: {data.diskFree}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
|
||||||
|
interface NetworkTooltipContentProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: any[];
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NetworkTooltipContent = ({ active, payload, label }: NetworkTooltipContentProps) => {
|
||||||
|
if (!active || !payload || !payload.length) return null;
|
||||||
|
|
||||||
|
const data = payload[0]?.payload;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-popover/95 border border-border backdrop-blur-sm rounded-lg p-3 shadow-lg">
|
||||||
|
<p className="font-medium text-popover-foreground mb-2">{label}</p>
|
||||||
|
{data?.fullTimestamp && (
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
{new Date(data.fullTimestamp).toLocaleString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{payload.map((entry: any, index: number) => (
|
||||||
|
<div key={index} className="flex items-center gap-2 mb-1">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{entry.name}: {entry.dataKey.includes('Speed') ? `${entry.value} KB/s` : entry.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{data && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-2 pt-2 border-t border-border">
|
||||||
|
<div>Total RX: {data.networkRx}</div>
|
||||||
|
<div>Total TX: {data.networkTx}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
|
||||||
|
import { UptimeData } from "@/types/service.types";
|
||||||
|
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay } from "date-fns";
|
||||||
|
|
||||||
|
interface HeatmapChartProps {
|
||||||
|
uptimeData: UptimeData[];
|
||||||
|
selectedMonth: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HeatmapChart = ({ uptimeData, selectedMonth }: HeatmapChartProps) => {
|
||||||
|
const monthStart = startOfMonth(selectedMonth);
|
||||||
|
const monthEnd = endOfMonth(selectedMonth);
|
||||||
|
const daysInMonth = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||||
|
|
||||||
|
const getStatusForDay = (day: Date) => {
|
||||||
|
const dayData = uptimeData.filter(data =>
|
||||||
|
isSameDay(new Date(data.timestamp), day)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dayData.length === 0) return 'no-data';
|
||||||
|
|
||||||
|
// Calculate the predominant status for the day
|
||||||
|
const statusCounts = dayData.reduce((acc, data) => {
|
||||||
|
acc[data.status] = (acc[data.status] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
const predominantStatus = Object.entries(statusCounts)
|
||||||
|
.sort(([,a], [,b]) => b - a)[0][0];
|
||||||
|
|
||||||
|
return predominantStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'up':
|
||||||
|
return 'bg-emerald-500';
|
||||||
|
case 'down':
|
||||||
|
return 'bg-red-500';
|
||||||
|
case 'warning':
|
||||||
|
return 'bg-amber-500';
|
||||||
|
case 'paused':
|
||||||
|
return 'bg-slate-500';
|
||||||
|
default:
|
||||||
|
return 'bg-slate-700';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'up':
|
||||||
|
return 'Up';
|
||||||
|
case 'down':
|
||||||
|
return 'Down';
|
||||||
|
case 'warning':
|
||||||
|
return 'Warning';
|
||||||
|
case 'paused':
|
||||||
|
return 'Paused';
|
||||||
|
default:
|
||||||
|
return 'No Data';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group days by weeks
|
||||||
|
const weeks: Date[][] = [];
|
||||||
|
let currentWeek: Date[] = [];
|
||||||
|
|
||||||
|
daysInMonth.forEach((day, index) => {
|
||||||
|
if (index === 0) {
|
||||||
|
// Fill empty days at the start of the month
|
||||||
|
const dayOfWeek = day.getDay();
|
||||||
|
for (let i = 0; i < dayOfWeek; i++) {
|
||||||
|
currentWeek.push(new Date(0)); // Placeholder for empty cells
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentWeek.push(day);
|
||||||
|
|
||||||
|
if (currentWeek.length === 7) {
|
||||||
|
weeks.push([...currentWeek]);
|
||||||
|
currentWeek = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add remaining days to last week
|
||||||
|
if (currentWeek.length > 0) {
|
||||||
|
while (currentWeek.length < 7) {
|
||||||
|
currentWeek.push(new Date(0)); // Placeholder for empty cells
|
||||||
|
}
|
||||||
|
weeks.push(currentWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-xl font-semibold text-white mb-2">
|
||||||
|
Service Health - {format(selectedMonth, 'MMMM yyyy')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
Daily status overview for the current month
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Grid */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Day labels */}
|
||||||
|
<div className="grid grid-cols-7 gap-2 text-sm text-gray-400 font-medium">
|
||||||
|
<div className="text-center py-2">Sun</div>
|
||||||
|
<div className="text-center py-2">Mon</div>
|
||||||
|
<div className="text-center py-2">Tue</div>
|
||||||
|
<div className="text-center py-2">Wed</div>
|
||||||
|
<div className="text-center py-2">Thu</div>
|
||||||
|
<div className="text-center py-2">Fri</div>
|
||||||
|
<div className="text-center py-2">Sat</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar days */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{weeks.map((week, weekIndex) => (
|
||||||
|
<div key={weekIndex} className="grid grid-cols-7 gap-2">
|
||||||
|
{week.map((day, dayIndex) => {
|
||||||
|
const isPlaceholder = day.getTime() === 0;
|
||||||
|
const status = isPlaceholder ? 'no-data' : getStatusForDay(day);
|
||||||
|
const dayData = isPlaceholder ? [] : uptimeData.filter(data =>
|
||||||
|
isSameDay(new Date(data.timestamp), day)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={dayIndex}
|
||||||
|
className={`
|
||||||
|
relative aspect-square rounded-lg flex items-center justify-center text-sm font-medium text-white
|
||||||
|
transition-all duration-200 cursor-pointer
|
||||||
|
${isPlaceholder ? 'invisible' : `
|
||||||
|
${getStatusColor(status)} hover:scale-110 hover:shadow-lg
|
||||||
|
`}
|
||||||
|
`}
|
||||||
|
title={isPlaceholder ? '' : `${format(day, 'MMM d, yyyy')}: ${getStatusLabel(status)}${dayData.length > 0 ? ` (${dayData.length} checks)` : ''}`}
|
||||||
|
>
|
||||||
|
{isPlaceholder ? '' : format(day, 'd')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Legend */}
|
||||||
|
<div className="flex items-center justify-center gap-6 pt-4 border-t border-gray-700">
|
||||||
|
<span className="text-gray-400 text-sm font-medium">Status:</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-3 h-3 bg-emerald-500 rounded-full"></div>
|
||||||
|
<span className="text-gray-300 text-sm">Up</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-3 h-3 bg-amber-500 rounded-full"></div>
|
||||||
|
<span className="text-gray-300 text-sm">Warning</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
||||||
|
<span className="text-gray-300 text-sm">Down</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-3 h-3 bg-slate-500 rounded-full"></div>
|
||||||
|
<span className="text-gray-300 text-sm">Paused</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { HeatmapChart } from "./HeatmapChart";
|
||||||
|
import { UptimeData } from "@/types/service.types";
|
||||||
|
import { addMonths, subMonths, format } from "date-fns";
|
||||||
|
|
||||||
|
interface HeatmapDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
serviceName: string;
|
||||||
|
uptimeData: UptimeData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HeatmapDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
serviceName,
|
||||||
|
uptimeData
|
||||||
|
}: HeatmapDialogProps) => {
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState(new Date());
|
||||||
|
|
||||||
|
const handlePreviousMonth = () => {
|
||||||
|
setSelectedMonth(prev => subMonths(prev, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextMonth = () => {
|
||||||
|
setSelectedMonth(prev => addMonths(prev, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCurrentMonth = () => {
|
||||||
|
setSelectedMonth(new Date());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto bg-slate-900 border-slate-700">
|
||||||
|
<DialogHeader className="pb-4">
|
||||||
|
<DialogTitle className="text-xl font-semibold text-white">
|
||||||
|
Health Heatmap - {serviceName}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-gray-400">
|
||||||
|
Monthly overview of service health status with daily breakdown
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Month Navigation */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePreviousMonth}
|
||||||
|
className="flex items-center gap-2 bg-slate-800 border-slate-600 text-white hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCurrentMonth}
|
||||||
|
className="bg-slate-700 text-white hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
Current Month
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNextMonth}
|
||||||
|
className="flex items-center gap-2 bg-slate-800 border-slate-600 text-white hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heatmap Chart */}
|
||||||
|
<HeatmapChart
|
||||||
|
uptimeData={uptimeData}
|
||||||
|
selectedMonth={selectedMonth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { regionalService } from "@/services/regionalService";
|
||||||
|
import { MapPin, Loader2, BarChart3 } from "lucide-react";
|
||||||
|
|
||||||
|
interface RegionalAgentFilterProps {
|
||||||
|
selectedAgent: string;
|
||||||
|
onAgentChange: (agent: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegionalAgentFilter({ selectedAgent, onAgentChange }: RegionalAgentFilterProps) {
|
||||||
|
const { data: regionalAgents = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['regional-services'],
|
||||||
|
queryFn: regionalService.getRegionalServices,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter only online agents
|
||||||
|
const onlineAgents = regionalAgents.filter(agent => agent.connection === 'online');
|
||||||
|
|
||||||
|
const getCurrentAgentDisplay = () => {
|
||||||
|
if (!selectedAgent || selectedAgent === "all") {
|
||||||
|
return "All Monitoring";
|
||||||
|
}
|
||||||
|
|
||||||
|
const [regionName] = selectedAgent.split("|");
|
||||||
|
const agent = onlineAgents.find(agent =>
|
||||||
|
`${agent.region_name}|${agent.agent_id}` === selectedAgent
|
||||||
|
);
|
||||||
|
|
||||||
|
if (agent) {
|
||||||
|
return `${agent.region_name} (${agent.agent_ip_address})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return regionName || "All Monitoring";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-64">
|
||||||
|
<label className="text-sm font-medium flex items-center gap-2 mb-2">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
Monitoring Source
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
onValueChange={onAgentChange}
|
||||||
|
value={selectedAgent || "all"}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={
|
||||||
|
isLoading
|
||||||
|
? "Loading agents..."
|
||||||
|
: getCurrentAgentDisplay()
|
||||||
|
} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<SelectItem value="loading" disabled>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading agents...
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SelectItem value="all">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-4 w-4 text-purple-500" />
|
||||||
|
<span className="font-medium">All Monitoring</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
{onlineAgents.length > 0 && onlineAgents.map((agent) => (
|
||||||
|
<SelectItem key={agent.id} value={`${agent.region_name}|${agent.agent_id}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
|
<span className="font-medium">{agent.region_name}</span>
|
||||||
|
<span className="text-muted-foreground">({agent.agent_ip_address})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{onlineAgents.length === 0 && !isLoading && (
|
||||||
|
<p className="text-xs text-amber-600 mt-1">
|
||||||
|
No regional agents available. Using default monitoring only.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,106 +1,499 @@
|
|||||||
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, LineChart, Line, BarChart, Bar } from "recharts";
|
||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from "recharts";
|
|
||||||
import { UptimeData } from "@/types/service.types";
|
import { UptimeData } from "@/types/service.types";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AreaChart as AreaChartIcon, BarChart3, TrendingUp } from "lucide-react";
|
||||||
|
|
||||||
interface ResponseTimeChartProps {
|
interface ResponseTimeChartProps {
|
||||||
uptimeData: UptimeData[];
|
uptimeData: UptimeData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChartType = 'area' | 'line' | 'bar';
|
||||||
|
|
||||||
export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const [chartType, setChartType] = useState<ChartType>('area');
|
||||||
|
|
||||||
|
// Fixed color palette - consistent colors that don't change
|
||||||
|
const fixedColors = [
|
||||||
|
{ stroke: '#3b82f6', fill: 'rgba(59, 130, 246, 0.2)', name: 'Ocean Blue' },
|
||||||
|
{ stroke: '#10b981', fill: 'rgba(16, 185, 129, 0.2)', name: 'Emerald Green' },
|
||||||
|
{ stroke: '#f59e0b', fill: 'rgba(245, 158, 11, 0.2)', name: 'Golden Amber' },
|
||||||
|
{ stroke: '#ef4444', fill: 'rgba(239, 68, 68, 0.2)', name: 'Ruby Red' },
|
||||||
|
{ stroke: '#8b5cf6', fill: 'rgba(139, 92, 246, 0.2)', name: 'Royal Purple' },
|
||||||
|
{ stroke: '#06b6d4', fill: 'rgba(6, 182, 212, 0.2)', name: 'Sky Cyan' },
|
||||||
|
{ stroke: '#f97316', fill: 'rgba(249, 115, 22, 0.2)', name: 'Sunset Orange' },
|
||||||
|
{ stroke: '#84cc16', fill: 'rgba(132, 204, 22, 0.2)', name: 'Fresh Lime' },
|
||||||
|
{ stroke: '#ec4899', fill: 'rgba(236, 72, 153, 0.2)', name: 'Vibrant Pink' },
|
||||||
|
{ stroke: '#14b8a6', fill: 'rgba(20, 184, 166, 0.2)', name: 'Ocean Teal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check if we have data from multiple sources
|
||||||
|
const hasMultipleSources = useMemo(() => {
|
||||||
|
const sources = new Set();
|
||||||
|
uptimeData.forEach(data => {
|
||||||
|
const source = data.source || 'default';
|
||||||
|
sources.add(source);
|
||||||
|
});
|
||||||
|
return sources.size > 1;
|
||||||
|
}, [uptimeData]);
|
||||||
|
|
||||||
// Format data for the chart with enhanced time formatting
|
// Format data for the chart with enhanced time formatting
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
if (!uptimeData || uptimeData.length === 0) return [];
|
if (!uptimeData || uptimeData.length === 0) return [];
|
||||||
|
|
||||||
// Sort by timestamp ascending (oldest to newest)
|
if (hasMultipleSources) {
|
||||||
const sortedData = [...uptimeData].sort((a, b) =>
|
// Group data by timestamp for multi-source display
|
||||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
const timeGroups = new Map();
|
||||||
);
|
|
||||||
|
|
||||||
// Get time format based on data density
|
|
||||||
const isShortTimeRange = uptimeData.length > 0 &&
|
|
||||||
(new Date(uptimeData[0].timestamp).getTime() - new Date(uptimeData[uptimeData.length - 1].timestamp).getTime()) < 2 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
return sortedData.map(data => {
|
|
||||||
const timestamp = new Date(data.timestamp);
|
|
||||||
|
|
||||||
return {
|
uptimeData.forEach(data => {
|
||||||
time: isShortTimeRange
|
const timestamp = new Date(data.timestamp);
|
||||||
? format(timestamp, 'HH:mm:ss') // Include seconds for short time ranges like 60min
|
// Round to nearest minute for better grouping
|
||||||
: format(timestamp, 'HH:mm'),
|
const roundedTime = new Date(timestamp.getFullYear(), timestamp.getMonth(), timestamp.getDate(),
|
||||||
rawTime: timestamp.getTime(),
|
timestamp.getHours(), timestamp.getMinutes());
|
||||||
date: format(timestamp, 'MMM dd, yyyy'),
|
const timeKey = roundedTime.getTime();
|
||||||
value: data.status === "paused" ? null : data.responseTime,
|
const source = data.source || 'default';
|
||||||
status: data.status,
|
|
||||||
// Separate values for different statuses with proper positioning
|
if (!timeGroups.has(timeKey)) {
|
||||||
upValue: data.status === "up" ? data.responseTime : null,
|
timeGroups.set(timeKey, {
|
||||||
downValue: data.status === "down" ? data.responseTime : null,
|
time: format(roundedTime, 'HH:mm'),
|
||||||
warningValue: data.status === "warning" ? data.responseTime : null,
|
rawTime: timeKey,
|
||||||
};
|
date: format(roundedTime, 'MMM dd, yyyy'),
|
||||||
});
|
timestamp: data.timestamp
|
||||||
}, [uptimeData]);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = timeGroups.get(timeKey);
|
||||||
|
|
||||||
|
// Handle different data sources properly
|
||||||
|
if (source === 'default') {
|
||||||
|
// Only add pure default if we don't already have regional data with agent_id 1
|
||||||
|
const hasRegionalDefault = uptimeData.some(d =>
|
||||||
|
d.region_name === 'Default' && (d.agent_id === '1' || d.agent_id === 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasRegionalDefault) {
|
||||||
|
group.defaultValue = data.status === "paused" ? null : data.responseTime;
|
||||||
|
group.defaultStatus = data.status;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regional monitoring data - distinguish by region and agent
|
||||||
|
const sourceKey = `regional_${source.replace('|', '_')}`;
|
||||||
|
|
||||||
|
// Group by the same timestamp more accurately
|
||||||
|
const currentValue = group[`${sourceKey}_value`];
|
||||||
|
const newValue = data.status === "paused" ? null : data.responseTime;
|
||||||
|
|
||||||
|
if (currentValue !== undefined && newValue !== null) {
|
||||||
|
// Average multiple values for the same time slot
|
||||||
|
const count = group[`${sourceKey}_count`] || 1;
|
||||||
|
group[`${sourceKey}_value`] = ((currentValue * count) + newValue) / (count + 1);
|
||||||
|
group[`${sourceKey}_count`] = count + 1;
|
||||||
|
} else if (newValue !== null) {
|
||||||
|
group[`${sourceKey}_value`] = newValue;
|
||||||
|
group[`${sourceKey}_count`] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
group[`${sourceKey}_status`] = data.status;
|
||||||
|
group[`${sourceKey}_label`] = data.region_name || source;
|
||||||
|
group[`${sourceKey}_agent_id`] = data.agent_id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(timeGroups.values())
|
||||||
|
.sort((a, b) => a.rawTime - b.rawTime)
|
||||||
|
.slice(-50); // Show last 50 data points for better performance
|
||||||
|
} else {
|
||||||
|
// Single source display - use original styling
|
||||||
|
const sortedData = [...uptimeData].sort((a, b) =>
|
||||||
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const isShortTimeRange = uptimeData.length > 0 &&
|
||||||
|
(new Date(uptimeData[0].timestamp).getTime() - new Date(uptimeData[uptimeData.length - 1].timestamp).getTime()) < 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
return sortedData.map(data => {
|
||||||
|
const timestamp = new Date(data.timestamp);
|
||||||
|
|
||||||
|
return {
|
||||||
|
time: isShortTimeRange
|
||||||
|
? format(timestamp, 'HH:mm:ss')
|
||||||
|
: format(timestamp, 'HH:mm'),
|
||||||
|
rawTime: timestamp.getTime(),
|
||||||
|
date: format(timestamp, 'MMM dd, yyyy'),
|
||||||
|
value: data.status === "paused" ? null : data.responseTime,
|
||||||
|
status: data.status,
|
||||||
|
upValue: data.status === "up" ? data.responseTime : null,
|
||||||
|
downValue: data.status === "down" ? data.responseTime : null,
|
||||||
|
warningValue: data.status === "warning" ? data.responseTime : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [uptimeData, hasMultipleSources]);
|
||||||
|
|
||||||
// Calculate Y-axis domain for better positioning
|
// Calculate Y-axis domain for better positioning
|
||||||
const yAxisDomain = useMemo(() => {
|
const yAxisDomain = useMemo(() => {
|
||||||
if (!chartData.length) return ['dataMin - 10', 'dataMax + 10'];
|
if (!chartData.length) return [0, 100];
|
||||||
|
|
||||||
const allValues = chartData
|
let allValues: number[] = [];
|
||||||
.filter(d => d.value !== null && d.status !== 'paused')
|
|
||||||
.map(d => d.value);
|
if (hasMultipleSources) {
|
||||||
|
chartData.forEach(d => {
|
||||||
|
Object.keys(d).forEach(key => {
|
||||||
|
if (key.endsWith('_value') || key === 'defaultValue') {
|
||||||
|
const value = d[key as keyof typeof d];
|
||||||
|
if (value !== null && value !== undefined && typeof value === 'number') {
|
||||||
|
allValues.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
allValues = chartData
|
||||||
|
.filter(d => d.value !== null && d.status !== 'paused')
|
||||||
|
.map(d => d.value as number)
|
||||||
|
.filter(v => typeof v === 'number');
|
||||||
|
}
|
||||||
|
|
||||||
if (allValues.length === 0) return [0, 100];
|
if (allValues.length === 0) return [0, 100];
|
||||||
|
|
||||||
const minValue = Math.min(...allValues);
|
const minValue = Math.min(...allValues);
|
||||||
const maxValue = Math.max(...allValues);
|
const maxValue = Math.max(...allValues);
|
||||||
const padding = (maxValue - minValue) * 0.1 || 10;
|
const padding = Math.max((maxValue - minValue) * 0.1, 10);
|
||||||
|
|
||||||
return [Math.max(0, minValue - padding), maxValue + padding];
|
return [Math.max(0, minValue - padding), maxValue + padding];
|
||||||
}, [chartData]);
|
}, [chartData, hasMultipleSources]);
|
||||||
|
|
||||||
|
// Get unique sources for legend with consistent fixed colors
|
||||||
|
const sources = useMemo(() => {
|
||||||
|
if (!hasMultipleSources) return [];
|
||||||
|
|
||||||
|
const sourceSet = new Set<string>();
|
||||||
|
const sourceInfo = new Map<string, any>();
|
||||||
|
|
||||||
|
uptimeData.forEach(data => {
|
||||||
|
const source = data.source || 'default';
|
||||||
|
if (source !== 'default') {
|
||||||
|
sourceSet.add(source);
|
||||||
|
sourceInfo.set(source, data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort sources to ensure consistent ordering
|
||||||
|
const sortedSources = Array.from(sourceSet).sort();
|
||||||
|
|
||||||
|
const regionalSources = sortedSources.map((source, index) => {
|
||||||
|
const data = sourceInfo.get(source);
|
||||||
|
const [regionName, agentId] = source.split('|');
|
||||||
|
|
||||||
|
// Create proper label with region and agent info
|
||||||
|
let label = regionName;
|
||||||
|
if (data?.agent_id && data.agent_id !== '1') {
|
||||||
|
label = `${regionName} (Agent ${data.agent_id})`;
|
||||||
|
} else if (regionName === 'Default' && data?.agent_id === '1') {
|
||||||
|
label = `Default System Check`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorIndex = index % fixedColors.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: `regional_${source.replace('|', '_')}`,
|
||||||
|
label: label,
|
||||||
|
stroke: fixedColors[colorIndex].stroke,
|
||||||
|
fill: fixedColors[colorIndex].fill,
|
||||||
|
colorName: fixedColors[colorIndex].name
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only add pure default monitoring if we have it AND no regional Default with agent_id 1
|
||||||
|
const hasRegionalDefault = uptimeData.some(data =>
|
||||||
|
data.region_name === 'Default' && (data.agent_id === '1' || data.agent_id === 1)
|
||||||
|
);
|
||||||
|
const hasPureDefault = uptimeData.some(data => data.source === 'default');
|
||||||
|
|
||||||
|
const defaultSources = (hasPureDefault && !hasRegionalDefault) ? [{
|
||||||
|
key: 'default',
|
||||||
|
label: 'Default System',
|
||||||
|
stroke: fixedColors[regionalSources.length % fixedColors.length].stroke,
|
||||||
|
fill: fixedColors[regionalSources.length % fixedColors.length].fill,
|
||||||
|
colorName: fixedColors[regionalSources.length % fixedColors.length].name
|
||||||
|
}] : [];
|
||||||
|
|
||||||
|
return [...defaultSources, ...regionalSources];
|
||||||
|
}, [uptimeData, hasMultipleSources]);
|
||||||
|
|
||||||
// Create a custom tooltip for the chart
|
// Create a custom tooltip for the chart
|
||||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
const data = payload[0].payload;
|
const data = payload[0].payload;
|
||||||
|
|
||||||
// Set background color based on status
|
|
||||||
let statusColor = "bg-emerald-800";
|
|
||||||
let statusText = "Up";
|
|
||||||
|
|
||||||
if (data.status === "down") {
|
|
||||||
statusColor = "bg-red-800";
|
|
||||||
statusText = "Down";
|
|
||||||
} else if (data.status === "warning") {
|
|
||||||
statusColor = "bg-yellow-800";
|
|
||||||
statusText = "Warning";
|
|
||||||
} else if (data.status === "paused") {
|
|
||||||
statusColor = "bg-gray-800";
|
|
||||||
statusText = "Paused";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} p-2 border ${theme === 'dark' ? 'border-gray-700' : 'border-gray-200'} rounded shadow-md`}>
|
<div className={`${theme === 'dark' ? 'bg-gray-900 border-gray-700' : 'bg-white border-gray-200'} p-4 border rounded-lg shadow-lg min-w-64`}>
|
||||||
<p className="text-sm font-medium">{label}</p>
|
<p className="text-sm font-semibold mb-1">{String(label)}</p>
|
||||||
<p className="text-xs text-muted-foreground">{data.date}</p>
|
<p className="text-xs text-muted-foreground mb-3">{String(data.date)}</p>
|
||||||
<div className={`flex items-center gap-2 mt-1 ${data.status === "paused" ? "opacity-70" : ""}`}>
|
|
||||||
<div className={`w-3 h-3 rounded-full ${statusColor}`}></div>
|
{hasMultipleSources ? (
|
||||||
<span>{statusText}</span>
|
// Multi-source tooltip
|
||||||
</div>
|
<div className="space-y-3">
|
||||||
<p className="mt-1 font-mono text-sm">
|
{sources.map(source => {
|
||||||
{data.status === "paused" ? "Monitoring paused" :
|
const valueKey = source.key === 'default' ? 'defaultValue' : `${source.key}_value`;
|
||||||
data.value !== null ? `${data.value} ms` : "No data"}
|
const statusKey = source.key === 'default' ? 'defaultStatus' : `${source.key}_status`;
|
||||||
</p>
|
const value = data[valueKey];
|
||||||
|
const status = data[statusKey];
|
||||||
|
|
||||||
|
if (value === undefined && status === undefined) return null;
|
||||||
|
|
||||||
|
let statusBadgeClass = "px-2 py-1 rounded-full text-xs font-medium";
|
||||||
|
let statusText = "No Data";
|
||||||
|
|
||||||
|
if (status === "up") {
|
||||||
|
statusBadgeClass += " bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
|
||||||
|
statusText = "Up";
|
||||||
|
} else if (status === "down") {
|
||||||
|
statusBadgeClass += " bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
|
||||||
|
statusText = "Down";
|
||||||
|
} else if (status === "warning") {
|
||||||
|
statusBadgeClass += " bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
|
||||||
|
statusText = "Warning";
|
||||||
|
} else if (status === "paused") {
|
||||||
|
statusBadgeClass += " bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200";
|
||||||
|
statusText = "Paused";
|
||||||
|
} else {
|
||||||
|
statusBadgeClass += " bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={source.key} className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full border-2 border-white shadow-sm flex-shrink-0"
|
||||||
|
style={{ backgroundColor: source.stroke }}
|
||||||
|
></div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-sm font-medium truncate block">{String(source.label)}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{source.colorName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<span className={statusBadgeClass}>
|
||||||
|
{statusText}
|
||||||
|
</span>
|
||||||
|
<div className="text-sm font-mono">
|
||||||
|
{status === "paused" ? "Paused" :
|
||||||
|
value !== null && value !== undefined ? `${value} ms` : "No data"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Single source tooltip - showing status with color
|
||||||
|
<>
|
||||||
|
<div className={`flex items-center gap-3 mt-2 ${data.status === "paused" ? "opacity-70" : ""}`}>
|
||||||
|
<div className={`w-4 h-4 rounded-full ${
|
||||||
|
data.status === "up" ? "bg-green-500" :
|
||||||
|
data.status === "down" ? "bg-red-500" :
|
||||||
|
data.status === "warning" ? "bg-yellow-500" : "bg-gray-500"
|
||||||
|
}`}></div>
|
||||||
|
<span className="font-medium">{
|
||||||
|
data.status === "up" ? "Up" :
|
||||||
|
data.status === "down" ? "Down" :
|
||||||
|
data.status === "warning" ? "Warning" : "Paused"
|
||||||
|
}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 font-mono text-lg">
|
||||||
|
{data.status === "paused" ? "Monitoring paused" :
|
||||||
|
data.value !== null ? `${data.value} ms` : "No data"}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if we have any data to display - be more lenient by checking raw uptimeData
|
// Render different chart types
|
||||||
|
const renderChart = () => {
|
||||||
|
const commonProps = {
|
||||||
|
data: chartData,
|
||||||
|
margin: { top: 10, right: 30, left: 0, bottom: 30 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const commonAxisProps = {
|
||||||
|
xAxis: {
|
||||||
|
dataKey: "time",
|
||||||
|
stroke: theme === 'dark' ? '#666' : '#9ca3af',
|
||||||
|
angle: -45,
|
||||||
|
textAnchor: "end" as const,
|
||||||
|
tick: { fontSize: 10 },
|
||||||
|
height: 60,
|
||||||
|
interval: "preserveStartEnd" as const,
|
||||||
|
minTickGap: 5
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
stroke: theme === 'dark' ? '#666' : '#9ca3af',
|
||||||
|
allowDecimals: false,
|
||||||
|
domain: yAxisDomain
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasMultipleSources) {
|
||||||
|
switch (chartType) {
|
||||||
|
case 'line':
|
||||||
|
return (
|
||||||
|
<LineChart {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={theme === 'dark' ? '#333' : '#e5e7eb'} />
|
||||||
|
<XAxis {...commonAxisProps.xAxis} />
|
||||||
|
<YAxis {...commonAxisProps.yAxis} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
|
||||||
|
{sources.find(s => s.key === 'default') && (
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="defaultValue"
|
||||||
|
stroke={sources.find(s => s.key === 'default')?.stroke}
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={{ r: 4, strokeWidth: 2 }}
|
||||||
|
connectNulls={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sources.filter(s => s.key !== 'default').map((source) => (
|
||||||
|
<Line
|
||||||
|
key={source.key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={`${source.key}_value`}
|
||||||
|
stroke={source.stroke}
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={{ r: 4, strokeWidth: 2 }}
|
||||||
|
connectNulls={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'bar':
|
||||||
|
return (
|
||||||
|
<BarChart {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={theme === 'dark' ? '#333' : '#e5e7eb'} />
|
||||||
|
<XAxis {...commonAxisProps.xAxis} />
|
||||||
|
<YAxis {...commonAxisProps.yAxis} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
|
||||||
|
{sources.find(s => s.key === 'default') && (
|
||||||
|
<Bar
|
||||||
|
dataKey="defaultValue"
|
||||||
|
fill={sources.find(s => s.key === 'default')?.stroke}
|
||||||
|
opacity={0.8}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sources.filter(s => s.key !== 'default').map((source) => (
|
||||||
|
<Bar
|
||||||
|
key={source.key}
|
||||||
|
dataKey={`${source.key}_value`}
|
||||||
|
fill={source.stroke}
|
||||||
|
opacity={0.8}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
default: // area
|
||||||
|
return (
|
||||||
|
<AreaChart {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={theme === 'dark' ? '#333' : '#e5e7eb'} />
|
||||||
|
<XAxis {...commonAxisProps.xAxis} />
|
||||||
|
<YAxis {...commonAxisProps.yAxis} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
|
||||||
|
{sources.find(s => s.key === 'default') && (
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="defaultValue"
|
||||||
|
stroke={sources.find(s => s.key === 'default')?.stroke}
|
||||||
|
fill={sources.find(s => s.key === 'default')?.fill}
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={false}
|
||||||
|
connectNulls={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sources.filter(s => s.key !== 'default').map((source) => (
|
||||||
|
<Area
|
||||||
|
key={source.key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={`${source.key}_value`}
|
||||||
|
stroke={source.stroke}
|
||||||
|
fill={source.fill}
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={false}
|
||||||
|
connectNulls={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single source charts remain the same
|
||||||
|
return (
|
||||||
|
<AreaChart {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={theme === 'dark' ? '#333' : '#e5e7eb'} />
|
||||||
|
<XAxis {...commonAxisProps.xAxis} />
|
||||||
|
<YAxis {...commonAxisProps.yAxis} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="upValue"
|
||||||
|
stroke="#10b981"
|
||||||
|
fill="rgba(16, 185, 129, 0.2)"
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={false}
|
||||||
|
connectNulls={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="downValue"
|
||||||
|
stroke="#ef4444"
|
||||||
|
fill="rgba(239, 68, 68, 0.2)"
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={false}
|
||||||
|
connectNulls={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="warningValue"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
fill="rgba(245, 158, 11, 0.2)"
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={false}
|
||||||
|
connectNulls={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{chartData.map((entry, index) =>
|
||||||
|
entry.status === 'paused' ? (
|
||||||
|
<ReferenceLine
|
||||||
|
key={`ref-${index}`}
|
||||||
|
x={entry.time}
|
||||||
|
stroke="#9ca3af"
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</AreaChart>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if we have any data to display
|
||||||
const hasData = uptimeData.length > 0;
|
const hasData = uptimeData.length > 0;
|
||||||
|
|
||||||
// Get date range for display
|
// Get date range for display
|
||||||
@@ -124,12 +517,59 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex flex-col md:flex-row md:items-center gap-2 justify-between">
|
<CardTitle className="flex flex-col md:flex-row md:items-center gap-2 justify-between">
|
||||||
<span>Response Time History</span>
|
<span>Response Time History</span>
|
||||||
{hasData && (
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm font-normal text-muted-foreground">
|
{hasMultipleSources && (
|
||||||
{dateRangeDisplay}
|
<div className="flex items-center gap-2 bg-muted/30 p-1 rounded-lg border">
|
||||||
</span>
|
<Button
|
||||||
)}
|
variant={chartType === 'area' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setChartType('area')}
|
||||||
|
className="h-8 px-2"
|
||||||
|
>
|
||||||
|
<AreaChartIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={chartType === 'line' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setChartType('line')}
|
||||||
|
className="h-8 px-2"
|
||||||
|
>
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={chartType === 'bar' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setChartType('bar')}
|
||||||
|
className="h-8 px-2"
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasData && (
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
{dateRangeDisplay}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
{hasMultipleSources && (
|
||||||
|
<div className="flex flex-wrap gap-4 text-sm bg-muted/30 p-3 rounded-lg border">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-2 w-full">
|
||||||
|
<span>Monitoring Sources:</span>
|
||||||
|
</div>
|
||||||
|
{sources.map(source => (
|
||||||
|
<div key={source.key} className="flex items-center gap-2 bg-background px-3 py-1.5 rounded-md border shadow-sm">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full border border-white shadow-sm"
|
||||||
|
style={{ backgroundColor: source.stroke }}
|
||||||
|
></div>
|
||||||
|
<span className="font-medium">{String(source.label)}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">({source.colorName})</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{!hasData ? (
|
{!hasData ? (
|
||||||
@@ -139,82 +579,7 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="h-80">
|
<div className="h-80">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 30 }}>
|
{renderChart()}
|
||||||
<defs>
|
|
||||||
<linearGradient id="colorUp" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.8}/>
|
|
||||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0.2}/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="colorDown" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.8}/>
|
|
||||||
<stop offset="95%" stopColor="#ef4444" stopOpacity={0.2}/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="colorWarning" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.8}/>
|
|
||||||
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0.2}/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke={theme === 'dark' ? '#333' : '#e5e7eb'} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="time"
|
|
||||||
stroke={theme === 'dark' ? '#666' : '#9ca3af'}
|
|
||||||
angle={-45}
|
|
||||||
textAnchor="end"
|
|
||||||
tick={{ fontSize: 10 }}
|
|
||||||
height={60}
|
|
||||||
interval="preserveStartEnd"
|
|
||||||
minTickGap={5}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke={theme === 'dark' ? '#666' : '#9ca3af'}
|
|
||||||
allowDecimals={false}
|
|
||||||
domain={yAxisDomain}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
|
|
||||||
{/* Separate area charts for each status - positioned closer together */}
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="upValue"
|
|
||||||
stroke="#10b981"
|
|
||||||
strokeWidth={2}
|
|
||||||
fillOpacity={0.4}
|
|
||||||
fill="url(#colorUp)"
|
|
||||||
connectNulls={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="downValue"
|
|
||||||
stroke="#ef4444"
|
|
||||||
strokeWidth={2}
|
|
||||||
fillOpacity={0.4}
|
|
||||||
fill="url(#colorDown)"
|
|
||||||
connectNulls={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="warningValue"
|
|
||||||
stroke="#f59e0b"
|
|
||||||
strokeWidth={2}
|
|
||||||
fillOpacity={0.4}
|
|
||||||
fill="url(#colorWarning)"
|
|
||||||
connectNulls={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Add reference lines for paused periods */}
|
|
||||||
{chartData.map((entry, index) =>
|
|
||||||
entry.status === 'paused' ? (
|
|
||||||
<ReferenceLine
|
|
||||||
key={`ref-${index}`}
|
|
||||||
x={entry.time}
|
|
||||||
stroke="#9ca3af"
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
)}
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
import { Service, UptimeData } from "@/types/service.types";
|
import { Service, UptimeData } from "@/types/service.types";
|
||||||
|
|
||||||
@@ -18,71 +18,117 @@ export const useRealTimeUpdates = ({
|
|||||||
setService,
|
setService,
|
||||||
setUptimeData
|
setUptimeData
|
||||||
}: UseRealTimeUpdatesProps) => {
|
}: UseRealTimeUpdatesProps) => {
|
||||||
// Listen for real-time updates to this service
|
const subscriptionsRef = useRef<{
|
||||||
|
service?: () => void;
|
||||||
|
uptime?: () => void;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
const lastUpdateRef = useRef<number>(0);
|
||||||
|
const updateThrottleMs = 30000; // Throttle to once per 30 seconds for better performance
|
||||||
|
|
||||||
|
// Listen for real-time updates to this service with throttling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serviceId) return;
|
if (!serviceId) return;
|
||||||
|
|
||||||
console.log(`Setting up real-time updates for service: ${serviceId}`);
|
// console.log(`Setting up real-time updates for service: ${serviceId}`);
|
||||||
|
|
||||||
try {
|
const setupSubscriptions = async () => {
|
||||||
// Subscribe to the service record for real-time updates
|
try {
|
||||||
const subscription = pb.collection('services').subscribe(serviceId, function(e) {
|
// Clean up existing subscriptions first
|
||||||
console.log("Service updated:", e.record);
|
if (subscriptionsRef.current.service) {
|
||||||
|
subscriptionsRef.current.service();
|
||||||
// Update our local state with the new data
|
}
|
||||||
if (e.record) {
|
if (subscriptionsRef.current.uptime) {
|
||||||
setService(prev => {
|
subscriptionsRef.current.uptime();
|
||||||
if (!prev) return null;
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
status: e.record.status || prev.status,
|
|
||||||
responseTime: e.record.response_time || e.record.responseTime || prev.responseTime,
|
|
||||||
uptime: e.record.uptime || prev.uptime,
|
|
||||||
lastChecked: e.record.last_checked || e.record.lastChecked || prev.lastChecked,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Subscribe to uptime data updates
|
// Subscribe to the service record with throttling
|
||||||
const uptimeSubscription = pb.collection('uptime_data').subscribe('*', function(e) {
|
const serviceUnsubscribe = await pb.collection('services').subscribe(serviceId, function(e) {
|
||||||
if (e.record && e.record.service_id === serviceId) {
|
const now = Date.now();
|
||||||
console.log("New uptime data:", e.record);
|
if (now - lastUpdateRef.current < updateThrottleMs) {
|
||||||
|
// console.log("Service update throttled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastUpdateRef.current = now;
|
||||||
|
|
||||||
|
// console.log("Service updated (throttled):", e.record);
|
||||||
|
|
||||||
|
// Update our local state with the new data
|
||||||
|
if (e.record) {
|
||||||
|
setService(prev => {
|
||||||
|
if (!prev) return null;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
status: e.record.status || prev.status,
|
||||||
|
responseTime: e.record.response_time || e.record.responseTime || prev.responseTime,
|
||||||
|
uptime: e.record.uptime || prev.uptime,
|
||||||
|
lastChecked: e.record.last_checked || e.record.lastChecked || prev.lastChecked,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
subscriptionsRef.current.service = serviceUnsubscribe;
|
||||||
|
|
||||||
|
// Subscribe to uptime data updates with throttling
|
||||||
|
const uptimeUnsubscribe = await pb.collection('uptime_data').subscribe('*', function(e) {
|
||||||
|
if (!e.record || e.record.service_id !== serviceId) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastUpdateRef.current < updateThrottleMs) {
|
||||||
|
// console.log("Uptime data update throttled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastUpdateRef.current = now;
|
||||||
|
|
||||||
|
// console.log("New uptime data (throttled):", e.record);
|
||||||
|
|
||||||
// Add the new uptime data to our list if it's within the selected date range
|
// Add the new uptime data to our list if it's within the selected date range
|
||||||
const timestamp = new Date(e.record.timestamp);
|
const timestamp = new Date(e.record.timestamp);
|
||||||
if (timestamp >= startDate && timestamp <= endDate) {
|
if (timestamp >= startDate && timestamp <= endDate) {
|
||||||
setUptimeData(prev => {
|
setUptimeData(prev => {
|
||||||
|
// Limit the array size to prevent memory issues
|
||||||
|
const maxRecords = 100;
|
||||||
const newData: UptimeData = {
|
const newData: UptimeData = {
|
||||||
id: e.record.id,
|
id: e.record.id,
|
||||||
service_id: e.record.service_id, // Include service_id
|
service_id: e.record.service_id,
|
||||||
serviceId: e.record.service_id, // Keep for backward compatibility
|
serviceId: e.record.service_id,
|
||||||
timestamp: e.record.timestamp,
|
timestamp: e.record.timestamp,
|
||||||
status: e.record.status,
|
status: e.record.status,
|
||||||
responseTime: e.record.response_time || 0,
|
responseTime: e.record.response_time || 0,
|
||||||
date: e.record.timestamp, // Adding required date property
|
date: e.record.timestamp,
|
||||||
uptime: e.record.uptime || 0 // Adding required uptime property
|
uptime: e.record.uptime || 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add at the beginning of the array to maintain newest first sorting
|
// Add at the beginning and limit array size
|
||||||
return [newData, ...prev];
|
const updatedData = [newData, ...prev];
|
||||||
|
return updatedData.slice(0, maxRecords);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up the subscriptions
|
subscriptionsRef.current.uptime = uptimeUnsubscribe;
|
||||||
return () => {
|
|
||||||
console.log(`Cleaning up subscriptions for service: ${serviceId}`);
|
} catch (error) {
|
||||||
try {
|
// console.error("Error setting up real-time updates:", error);
|
||||||
pb.collection('services').unsubscribe(serviceId);
|
}
|
||||||
pb.collection('uptime_data').unsubscribe('*');
|
};
|
||||||
} catch (error) {
|
|
||||||
console.error("Error cleaning up subscriptions:", error);
|
setupSubscriptions();
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
// console.log(`Cleaning up subscriptions for service: ${serviceId}`);
|
||||||
|
try {
|
||||||
|
if (subscriptionsRef.current.service) {
|
||||||
|
subscriptionsRef.current.service();
|
||||||
}
|
}
|
||||||
};
|
if (subscriptionsRef.current.uptime) {
|
||||||
} catch (error) {
|
subscriptionsRef.current.uptime();
|
||||||
console.error("Error setting up real-time updates:", error);
|
}
|
||||||
}
|
} catch (error) {
|
||||||
|
// console.error("Error cleaning up subscriptions:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [serviceId, startDate, endDate, setService, setUptimeData]);
|
}, [serviceId, startDate, endDate, setService, setUptimeData]);
|
||||||
};
|
};
|
||||||
@@ -1,20 +1,33 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
import { Service, UptimeData } from "@/types/service.types";
|
import { Service, UptimeData } from "@/types/service.types";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { uptimeService } from "@/services/uptimeService";
|
import { uptimeService } from "@/services/uptimeService";
|
||||||
import { DateRangeOption } from "../../DateRangeFilter";
|
import { DateRangeOption } from "../../DateRangeFilter";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { regionalService } from "@/services/regionalService";
|
||||||
|
|
||||||
export const useServiceData = (serviceId: string | undefined, startDate: Date, endDate: Date) => {
|
export const useServiceData = (serviceId: string | undefined, startDate: Date, endDate: Date) => {
|
||||||
const [service, setService] = useState<Service | null>(null);
|
const [service, setService] = useState<Service | null>(null);
|
||||||
const [uptimeData, setUptimeData] = useState<UptimeData[]>([]);
|
const [uptimeData, setUptimeData] = useState<UptimeData[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [selectedRegionalAgent, setSelectedRegionalAgent] = useState<string>("all");
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleStatusChange = async (newStatus: "up" | "down" | "paused" | "warning") => {
|
// Get regional agents for "all" monitoring with optimized caching
|
||||||
|
const { data: regionalAgents = [] } = useQuery({
|
||||||
|
queryKey: ['regional-services'],
|
||||||
|
queryFn: regionalService.getRegionalServices,
|
||||||
|
enabled: selectedRegionalAgent === "all",
|
||||||
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
|
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleStatusChange = useCallback(async (newStatus: "up" | "down" | "paused" | "warning") => {
|
||||||
if (!service || !serviceId) return;
|
if (!service || !serviceId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -29,7 +42,7 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
|||||||
description: `Service status changed to ${newStatus}`,
|
description: `Service status changed to ${newStatus}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update service status:", error);
|
// console.error("Failed to update service status:", error);
|
||||||
setService(prevService => prevService);
|
setService(prevService => prevService);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -38,16 +51,20 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
|||||||
description: "Could not update service status. Please try again.",
|
description: "Could not update service status. Please try again.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}, [service, serviceId, toast]);
|
||||||
|
|
||||||
const fetchUptimeData = async (serviceId: string, start: Date, end: Date, selectedRange?: DateRangeOption | string) => {
|
const fetchUptimeData = useCallback(async (serviceId: string, start: Date, end: Date, selectedRange?: DateRangeOption | string, regionalAgent?: string) => {
|
||||||
try {
|
try {
|
||||||
if (!service) {
|
if (!service) {
|
||||||
console.log('No service data available for uptime fetch');
|
// console.log('No service data available for uptime fetch');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Fetching uptime data: ${start.toISOString()} to ${end.toISOString()} for range: ${selectedRange}, service type: ${service.type}`);
|
const currentAgent = regionalAgent || selectedRegionalAgent;
|
||||||
|
// console.log(`Fetching uptime data: ${start.toISOString()} to ${end.toISOString()} for range: ${selectedRange}, service type: ${service.type}, regional agent: ${currentAgent}`);
|
||||||
|
|
||||||
|
// Clear existing data immediately when switching agents
|
||||||
|
setUptimeData([]);
|
||||||
|
|
||||||
let limit = 500; // Default limit
|
let limit = 500; // Default limit
|
||||||
|
|
||||||
@@ -57,21 +74,72 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
|||||||
limit = 400;
|
limit = 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Using limit ${limit} for range ${selectedRange}`);
|
// console.log(`Using limit ${limit} for range ${selectedRange}`);
|
||||||
|
|
||||||
// Use the service type to fetch from the correct collection
|
let history: UptimeData[] = [];
|
||||||
const history = await uptimeService.getUptimeHistory(serviceId, limit, start, end, service.type);
|
|
||||||
console.log(`Retrieved ${history.length} uptime records from collection for ${service.type} service`);
|
if (currentAgent === "all") {
|
||||||
|
// Fetch data from all sources (default + all online regional agents)
|
||||||
|
// console.log(`Fetching data from all monitoring sources`);
|
||||||
|
|
||||||
|
// Fetch default monitoring data
|
||||||
|
const defaultData = await uptimeService.getUptimeHistory(serviceId, limit, start, end, service.type);
|
||||||
|
// console.log(`Retrieved ${defaultData.length} default monitoring records`);
|
||||||
|
|
||||||
|
// Mark default data with source identifier
|
||||||
|
const markedDefaultData = defaultData.map(record => ({
|
||||||
|
...record,
|
||||||
|
source: 'default' as const,
|
||||||
|
region_name: undefined,
|
||||||
|
agent_id: undefined
|
||||||
|
}));
|
||||||
|
|
||||||
|
history = [...markedDefaultData];
|
||||||
|
|
||||||
|
// Fetch regional monitoring data from all online agents
|
||||||
|
const onlineAgents = regionalAgents.filter(agent => agent.connection === 'online');
|
||||||
|
|
||||||
|
for (const agent of onlineAgents) {
|
||||||
|
try {
|
||||||
|
const regionalData = await uptimeService.getUptimeHistoryByRegionalAgent(
|
||||||
|
serviceId, limit, start, end, service.type, agent.region_name, agent.agent_id
|
||||||
|
);
|
||||||
|
// console.log(`Retrieved ${regionalData.length} records from ${agent.region_name}`);
|
||||||
|
|
||||||
|
// Mark regional data with source identifier
|
||||||
|
const markedRegionalData = regionalData.map(record => ({
|
||||||
|
...record,
|
||||||
|
source: `${agent.region_name}|${agent.agent_id}` as const,
|
||||||
|
region_name: agent.region_name,
|
||||||
|
agent_id: agent.agent_id
|
||||||
|
}));
|
||||||
|
|
||||||
|
history = [...history, ...markedRegionalData];
|
||||||
|
} catch (error) {
|
||||||
|
// console.error(`Error fetching data from ${agent.region_name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log(`Total combined records: ${history.length}`);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Fetch regional agent specific data
|
||||||
|
const [regionName, agentId] = currentAgent.split("|");
|
||||||
|
console.log(`Fetching regional agent data for region: ${regionName}, agent: ${agentId} from ${service.type} collection`);
|
||||||
|
history = await uptimeService.getUptimeHistoryByRegionalAgent(serviceId, limit, start, end, service.type, regionName, agentId);
|
||||||
|
// console.log(`Retrieved ${history.length} regional monitoring records`);
|
||||||
|
}
|
||||||
|
|
||||||
// Sort by timestamp (newest first)
|
// Sort by timestamp (newest first)
|
||||||
const filteredHistory = [...history].sort((a, b) =>
|
const filteredHistory = [...history].sort((a, b) =>
|
||||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// console.log(`Final dataset: ${filteredHistory.length} records for ${currentAgent === "all" ? "all sources" : "regional"} monitoring`);
|
||||||
setUptimeData(filteredHistory);
|
setUptimeData(filteredHistory);
|
||||||
return filteredHistory;
|
return filteredHistory;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching uptime data:", error);
|
// console.error("Error fetching uptime data:", error);
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
@@ -79,81 +147,102 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
|||||||
});
|
});
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
}, [service, selectedRegionalAgent, regionalAgents, toast]);
|
||||||
|
|
||||||
|
const handleRegionalAgentChange = useCallback((agent: string) => {
|
||||||
|
// console.log(`Regional agent changed from ${selectedRegionalAgent} to: ${agent}`);
|
||||||
|
|
||||||
|
// Clear data immediately when switching
|
||||||
|
setUptimeData([]);
|
||||||
|
setSelectedRegionalAgent(agent);
|
||||||
|
|
||||||
|
// Refetch data with new agent selection
|
||||||
|
if (serviceId && !isLoading && service) {
|
||||||
|
// console.log(`Refetching data for new agent: ${agent}`);
|
||||||
|
fetchUptimeData(serviceId, startDate, endDate, '24h', agent);
|
||||||
|
}
|
||||||
|
}, [selectedRegionalAgent, serviceId, isLoading, service, fetchUptimeData, startDate, endDate]);
|
||||||
|
|
||||||
|
// Memoize the service data fetching to prevent unnecessary re-runs
|
||||||
|
const fetchServiceData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (!serviceId) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error("Request timed out")), 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchPromise = pb.collection('services').getOne(serviceId);
|
||||||
|
const serviceData = await Promise.race([fetchPromise, timeoutPromise]) as any;
|
||||||
|
|
||||||
|
const formattedService: Service = {
|
||||||
|
id: serviceData.id,
|
||||||
|
name: serviceData.name,
|
||||||
|
url: serviceData.url || "",
|
||||||
|
host: serviceData.host || "",
|
||||||
|
port: serviceData.port || undefined,
|
||||||
|
domain: serviceData.domain || "",
|
||||||
|
type: serviceData.service_type || serviceData.type || "HTTP",
|
||||||
|
status: serviceData.status || "paused",
|
||||||
|
responseTime: serviceData.response_time || serviceData.responseTime || 0,
|
||||||
|
uptime: serviceData.uptime || 0,
|
||||||
|
lastChecked: serviceData.last_checked || serviceData.lastChecked || new Date().toLocaleString(),
|
||||||
|
interval: serviceData.heartbeat_interval || serviceData.interval || 60,
|
||||||
|
retries: serviceData.max_retries || serviceData.retries || 3,
|
||||||
|
notificationChannel: serviceData.notification_id,
|
||||||
|
alertTemplate: serviceData.template_id,
|
||||||
|
alerts: serviceData.alerts || "unmuted"
|
||||||
|
};
|
||||||
|
|
||||||
|
// console.log(`Loaded service: ${formattedService.name} (${formattedService.type})`);
|
||||||
|
setService(formattedService);
|
||||||
|
|
||||||
|
// Small delay to ensure state is updated before fetching uptime data
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("Error fetching service:", error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load service data. Please try again.",
|
||||||
|
});
|
||||||
|
navigate("/dashboard");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [serviceId, navigate, toast]);
|
||||||
|
|
||||||
// Initial data loading
|
// Initial data loading
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchServiceData = async () => {
|
|
||||||
try {
|
|
||||||
if (!serviceId) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise((_, reject) => {
|
|
||||||
setTimeout(() => reject(new Error("Request timed out")), 10000);
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchPromise = pb.collection('services').getOne(serviceId);
|
|
||||||
const serviceData = await Promise.race([fetchPromise, timeoutPromise]) as any;
|
|
||||||
|
|
||||||
const formattedService: Service = {
|
|
||||||
id: serviceData.id,
|
|
||||||
name: serviceData.name,
|
|
||||||
url: serviceData.url || "",
|
|
||||||
host: serviceData.host || "",
|
|
||||||
port: serviceData.port || undefined,
|
|
||||||
domain: serviceData.domain || "",
|
|
||||||
type: serviceData.service_type || serviceData.type || "HTTP",
|
|
||||||
status: serviceData.status || "paused",
|
|
||||||
responseTime: serviceData.response_time || serviceData.responseTime || 0,
|
|
||||||
uptime: serviceData.uptime || 0,
|
|
||||||
lastChecked: serviceData.last_checked || serviceData.lastChecked || new Date().toLocaleString(),
|
|
||||||
interval: serviceData.heartbeat_interval || serviceData.interval || 60,
|
|
||||||
retries: serviceData.max_retries || serviceData.retries || 3,
|
|
||||||
notificationChannel: serviceData.notification_id,
|
|
||||||
alertTemplate: serviceData.template_id,
|
|
||||||
alerts: serviceData.alerts || "unmuted"
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`Loaded service: ${formattedService.name} (${formattedService.type})`);
|
|
||||||
setService(formattedService);
|
|
||||||
|
|
||||||
// Fetch initial uptime history with 24h default - wait for service to be set
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100)); // Small delay to ensure state is updated
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching service:", error);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to load service data. Please try again.",
|
|
||||||
});
|
|
||||||
navigate("/dashboard");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchServiceData();
|
fetchServiceData();
|
||||||
}, [serviceId, navigate, toast]);
|
}, [fetchServiceData]);
|
||||||
|
|
||||||
// Update data when date range changes or when service is loaded
|
// Update data when date range changes or when service is loaded - with debouncing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (serviceId && !isLoading && service) {
|
if (serviceId && !isLoading && service) {
|
||||||
console.log(`Date range changed or service loaded, refetching data for ${serviceId}: ${startDate.toISOString()} to ${endDate.toISOString()}`);
|
const timeoutId = setTimeout(() => {
|
||||||
fetchUptimeData(serviceId, startDate, endDate, '24h');
|
// console.log(`Date range changed or service loaded, refetching data for ${serviceId}: ${startDate.toISOString()} to ${endDate.toISOString()}`);
|
||||||
}
|
fetchUptimeData(serviceId, startDate, endDate, '24h', selectedRegionalAgent);
|
||||||
}, [startDate, endDate, serviceId, isLoading, service]);
|
}, 500); // Debounce API calls by 500ms
|
||||||
|
|
||||||
return {
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [startDate, endDate, serviceId, isLoading, service, selectedRegionalAgent, regionalAgents, fetchUptimeData]);
|
||||||
|
|
||||||
|
return useMemo(() => ({
|
||||||
service,
|
service,
|
||||||
setService,
|
setService,
|
||||||
uptimeData,
|
uptimeData,
|
||||||
setUptimeData,
|
setUptimeData,
|
||||||
isLoading,
|
isLoading,
|
||||||
handleStatusChange,
|
handleStatusChange,
|
||||||
fetchUptimeData
|
fetchUptimeData,
|
||||||
};
|
selectedRegionalAgent,
|
||||||
|
handleRegionalAgentChange
|
||||||
|
}), [service, uptimeData, isLoading, handleStatusChange, fetchUptimeData, selectedRegionalAgent, handleRegionalAgentChange]);
|
||||||
};
|
};
|
||||||
@@ -80,7 +80,9 @@ export const ServiceDetailContainer = () => {
|
|||||||
handleStatusChange,
|
handleStatusChange,
|
||||||
fetchUptimeData,
|
fetchUptimeData,
|
||||||
setService,
|
setService,
|
||||||
setUptimeData
|
setUptimeData,
|
||||||
|
selectedRegionalAgent,
|
||||||
|
handleRegionalAgentChange
|
||||||
} = useServiceData(id, startDate, endDate);
|
} = useServiceData(id, startDate, endDate);
|
||||||
|
|
||||||
// Set up real-time updates
|
// Set up real-time updates
|
||||||
@@ -104,9 +106,9 @@ export const ServiceDetailContainer = () => {
|
|||||||
// Also explicitly fetch data with the new range to ensure immediate update
|
// Also explicitly fetch data with the new range to ensure immediate update
|
||||||
if (id) {
|
if (id) {
|
||||||
console.log(`ServiceDetailContainer: Explicitly fetching data for service ${id} with new range`);
|
console.log(`ServiceDetailContainer: Explicitly fetching data for service ${id} with new range`);
|
||||||
fetchUptimeData(id, start, end, option);
|
fetchUptimeData(id, start, end, option, selectedRegionalAgent);
|
||||||
}
|
}
|
||||||
}, [id, fetchUptimeData]);
|
}, [id, fetchUptimeData, selectedRegionalAgent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServiceDetailWrapper
|
<ServiceDetailWrapper
|
||||||
@@ -124,6 +126,8 @@ export const ServiceDetailContainer = () => {
|
|||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
selectedDateOption={selectedRange}
|
selectedDateOption={selectedRange}
|
||||||
|
selectedRegionalAgent={selectedRegionalAgent}
|
||||||
|
onRegionalAgentChange={handleRegionalAgentChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ServiceDetailWrapper>
|
</ServiceDetailWrapper>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { Service, UptimeData } from "@/types/service.types";
|
import { Service, UptimeData } from "@/types/service.types";
|
||||||
import { ServiceHeader } from "@/components/services/ServiceHeader";
|
import { ServiceHeader } from "@/components/services/ServiceHeader";
|
||||||
import { ServiceStatsCards } from "@/components/services/ServiceStatsCards";
|
import { ServiceStatsCards } from "@/components/services/ServiceStatsCards";
|
||||||
@@ -14,6 +13,8 @@ interface ServiceDetailContentProps {
|
|||||||
onDateRangeChange: (start: Date, end: Date, option: DateRangeOption) => void;
|
onDateRangeChange: (start: Date, end: Date, option: DateRangeOption) => void;
|
||||||
onStatusChange: (newStatus: "up" | "down" | "paused" | "warning") => void;
|
onStatusChange: (newStatus: "up" | "down" | "paused" | "warning") => void;
|
||||||
selectedDateOption: DateRangeOption;
|
selectedDateOption: DateRangeOption;
|
||||||
|
selectedRegionalAgent: string;
|
||||||
|
onRegionalAgentChange: (agent: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ServiceDetailContent = ({
|
export const ServiceDetailContent = ({
|
||||||
@@ -21,28 +22,38 @@ export const ServiceDetailContent = ({
|
|||||||
uptimeData,
|
uptimeData,
|
||||||
onDateRangeChange,
|
onDateRangeChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
selectedDateOption
|
selectedDateOption,
|
||||||
|
selectedRegionalAgent,
|
||||||
|
onRegionalAgentChange
|
||||||
}: ServiceDetailContentProps) => {
|
}: ServiceDetailContentProps) => {
|
||||||
// Check if data is available
|
// Check if data is available
|
||||||
const hasUptimeData = uptimeData && uptimeData.length > 0;
|
const hasUptimeData = uptimeData && uptimeData.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6 pb-0 h-full overflow-auto">
|
<div className="p-4 md:p-6 pb-0 h-full overflow-auto">
|
||||||
<ServiceHeader service={service} onStatusChange={onStatusChange} />
|
<ServiceHeader
|
||||||
|
service={service}
|
||||||
|
onStatusChange={onStatusChange}
|
||||||
|
selectedRegionalAgent={selectedRegionalAgent}
|
||||||
|
onRegionalAgentChange={onRegionalAgentChange}
|
||||||
|
uptimeData={uptimeData}
|
||||||
|
/>
|
||||||
<ServiceStatsCards service={service} uptimeData={uptimeData} />
|
<ServiceStatsCards service={service} uptimeData={uptimeData} />
|
||||||
|
|
||||||
<div className="mb-4 md:mb-6 mt-6 md:mt-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 md:gap-0">
|
<div className="mb-4 md:mb-6 mt-6 md:mt-8 flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
|
||||||
<h2 className="text-lg md:text-xl font-medium">Response Time History</h2>
|
<h2 className="text-lg md:text-xl font-medium">Response Time History</h2>
|
||||||
<div className="flex flex-col md:flex-row items-start md:items-center gap-2">
|
<div className="flex flex-col md:flex-row items-start md:items-center gap-4">
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="flex flex-col md:flex-row items-start md:items-center gap-2">
|
||||||
Collection: {service.type.toLowerCase() === 'ping' || service.type.toLowerCase() === 'icmp' ? 'ping_data' :
|
<span className="text-sm text-muted-foreground">
|
||||||
service.type.toLowerCase() === 'dns' ? 'dns_data' :
|
Collection: {service.type.toLowerCase() === 'ping' || service.type.toLowerCase() === 'icmp' ? 'ping_data' :
|
||||||
service.type.toLowerCase() === 'tcp' ? 'tcp_data' : 'uptime_data'}
|
service.type.toLowerCase() === 'dns' ? 'dns_data' :
|
||||||
</span>
|
service.type.toLowerCase() === 'tcp' ? 'tcp_data' : 'uptime_data'}
|
||||||
<DateRangeFilter
|
</span>
|
||||||
onRangeChange={onDateRangeChange}
|
<DateRangeFilter
|
||||||
selectedOption={selectedDateOption}
|
onRangeChange={onDateRangeChange}
|
||||||
/>
|
selectedOption={selectedDateOption}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -53,7 +64,8 @@ export const ServiceDetailContent = ({
|
|||||||
<AlertTriangle className="h-10 w-10 md:h-12 md:w-12 text-amber-500 mx-auto mb-2 md:mb-3 opacity-70" />
|
<AlertTriangle className="h-10 w-10 md:h-12 md:w-12 text-amber-500 mx-auto mb-2 md:mb-3 opacity-70" />
|
||||||
<h3 className="text-base md:text-lg font-medium mb-1">No uptime data available</h3>
|
<h3 className="text-base md:text-lg font-medium mb-1">No uptime data available</h3>
|
||||||
<p className="text-muted-foreground max-w-md text-sm md:text-base">
|
<p className="text-muted-foreground max-w-md text-sm md:text-base">
|
||||||
There's no monitoring data for this service in the selected time period.
|
There's no monitoring data for this service in the selected time period
|
||||||
|
{selectedRegionalAgent !== "default" ? " from the selected regional agent" : ""}.
|
||||||
This could be because the service was recently added or monitoring is paused.
|
This could be because the service was recently added or monitoring is paused.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,182 +1,3 @@
|
|||||||
|
|
||||||
import { Form } from "@/components/ui/form";
|
// Re-export the ServiceForm component from the add-service directory
|
||||||
import { useForm } from "react-hook-form";
|
export { ServiceForm } from './add-service/ServiceForm';
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { serviceSchema, ServiceFormData } from "./add-service/types";
|
|
||||||
import { ServiceBasicFields } from "./add-service/ServiceBasicFields";
|
|
||||||
import { ServiceTypeField } from "./add-service/ServiceTypeField";
|
|
||||||
import { ServiceConfigFields } from "./add-service/ServiceConfigFields";
|
|
||||||
import { ServiceNotificationFields } from "./add-service/ServiceNotificationFields";
|
|
||||||
import { ServiceFormActions } from "./add-service/ServiceFormActions";
|
|
||||||
import { serviceService } from "@/services/serviceService";
|
|
||||||
import { Service } from "@/types/service.types";
|
|
||||||
|
|
||||||
interface ServiceFormProps {
|
|
||||||
onSuccess: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
initialData?: Service | null;
|
|
||||||
isEdit?: boolean;
|
|
||||||
onSubmitStart?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ServiceForm({
|
|
||||||
onSuccess,
|
|
||||||
onCancel,
|
|
||||||
initialData,
|
|
||||||
isEdit = false,
|
|
||||||
onSubmitStart
|
|
||||||
}: ServiceFormProps) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
// Initialize form with default values
|
|
||||||
const form = useForm<ServiceFormData>({
|
|
||||||
resolver: zodResolver(serviceSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: "",
|
|
||||||
type: "http",
|
|
||||||
url: "",
|
|
||||||
port: "",
|
|
||||||
interval: "60",
|
|
||||||
retries: "3",
|
|
||||||
notificationChannel: "",
|
|
||||||
alertTemplate: "",
|
|
||||||
},
|
|
||||||
mode: "onBlur",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Populate form when initialData changes (separate from initialization)
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialData && isEdit) {
|
|
||||||
// Ensure the type is one of the allowed values
|
|
||||||
const serviceType = (initialData.type || "http").toLowerCase();
|
|
||||||
const validType = ["http", "ping", "tcp", "dns"].includes(serviceType)
|
|
||||||
? serviceType as "http" | "ping" | "tcp" | "dns"
|
|
||||||
: "http";
|
|
||||||
|
|
||||||
// For PING services, use host field; for DNS use domain field; for TCP use host field; others use url
|
|
||||||
let urlValue = "";
|
|
||||||
let portValue = "";
|
|
||||||
|
|
||||||
if (validType === "ping") {
|
|
||||||
urlValue = initialData.host || "";
|
|
||||||
} else if (validType === "dns") {
|
|
||||||
urlValue = initialData.domain || "";
|
|
||||||
} else if (validType === "tcp") {
|
|
||||||
urlValue = initialData.host || "";
|
|
||||||
portValue = String(initialData.port || "");
|
|
||||||
} else {
|
|
||||||
urlValue = initialData.url || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the form with initial data values
|
|
||||||
form.reset({
|
|
||||||
name: initialData.name || "",
|
|
||||||
type: validType,
|
|
||||||
url: urlValue,
|
|
||||||
port: portValue,
|
|
||||||
interval: String(initialData.interval || 60),
|
|
||||||
retries: String(initialData.retries || 3),
|
|
||||||
notificationChannel: initialData.notificationChannel === "none" ? "" : initialData.notificationChannel || "",
|
|
||||||
alertTemplate: initialData.alertTemplate === "default" ? "" : initialData.alertTemplate || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log for debugging
|
|
||||||
console.log("Populating form with data:", { type: validType, url: urlValue, port: portValue });
|
|
||||||
}
|
|
||||||
}, [initialData, isEdit, form]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: ServiceFormData) => {
|
|
||||||
if (isSubmitting) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
if (onSubmitStart) onSubmitStart();
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("Form data being submitted:", data); // Debug log for submitted data
|
|
||||||
|
|
||||||
// Prepare service data with proper field mapping
|
|
||||||
const serviceData = {
|
|
||||||
name: data.name,
|
|
||||||
type: data.type,
|
|
||||||
interval: parseInt(data.interval),
|
|
||||||
retries: parseInt(data.retries),
|
|
||||||
notificationChannel: data.notificationChannel === "none" ? "" : data.notificationChannel,
|
|
||||||
alertTemplate: data.alertTemplate === "default" ? "" : data.alertTemplate,
|
|
||||||
// Map the URL field to appropriate database field based on service type
|
|
||||||
...(data.type === "dns"
|
|
||||||
? { domain: data.url, url: "", host: "", port: undefined } // DNS: store in domain field
|
|
||||||
: data.type === "ping"
|
|
||||||
? { host: data.url, url: "", domain: "", port: undefined } // PING: store in host field
|
|
||||||
: data.type === "tcp"
|
|
||||||
? { host: data.url, port: parseInt(data.port || "80"), url: "", domain: "" } // TCP: store in host and port fields
|
|
||||||
: { url: data.url, domain: "", host: "", port: undefined } // HTTP: store in url field
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEdit && initialData) {
|
|
||||||
// Update existing service
|
|
||||||
await serviceService.updateService(initialData.id, serviceData);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Service updated",
|
|
||||||
description: `${data.name} has been updated successfully.`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Create new service
|
|
||||||
await serviceService.createService(serviceData);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Service created",
|
|
||||||
description: `${data.name} has been added to monitoring.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onSuccess();
|
|
||||||
if (!isEdit) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error ${isEdit ? 'updating' : 'creating'} service:`, error);
|
|
||||||
toast({
|
|
||||||
title: `Failed to ${isEdit ? 'update' : 'create'} service`,
|
|
||||||
description: `An error occurred while ${isEdit ? 'updating' : 'creating'} the service.`,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Basic Information</h3>
|
|
||||||
<ServiceBasicFields form={form} />
|
|
||||||
<ServiceTypeField form={form} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Configuration</h3>
|
|
||||||
<ServiceConfigFields form={form} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Notifications</h3>
|
|
||||||
<ServiceNotificationFields form={form} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ServiceFormActions
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
onCancel={onCancel}
|
|
||||||
submitLabel={isEdit ? "Update Service" : "Create Service"}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,83 +1,116 @@
|
|||||||
|
|
||||||
import { ArrowLeft, Globe, MoreVertical, FileText, Github, Twitter, MessageSquare, Bell } from "lucide-react";
|
import { ArrowLeft, Globe, BarChart3 } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { StatusBadge } from "@/components/services/StatusBadge";
|
import { StatusBadge } from "@/components/services/StatusBadge";
|
||||||
import { ServiceMonitoringButton } from "@/components/services/ServiceMonitoringButton";
|
import { ServiceMonitoringButton } from "@/components/services/ServiceMonitoringButton";
|
||||||
import { Service } from "@/types/service.types";
|
import { RegionalAgentFilter } from "@/components/services/RegionalAgentFilter";
|
||||||
|
import { HeatmapDialog } from "./HeatmapDialog";
|
||||||
|
import { Service, UptimeData } from "@/types/service.types";
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
interface ServiceHeaderProps {
|
interface ServiceHeaderProps {
|
||||||
service: Service;
|
service: Service;
|
||||||
onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void;
|
onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void;
|
||||||
|
selectedRegionalAgent?: string;
|
||||||
|
onRegionalAgentChange?: (agent: string) => void;
|
||||||
|
uptimeData?: UptimeData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceHeader({ service, onStatusChange }: ServiceHeaderProps) {
|
export function ServiceHeader({
|
||||||
|
service,
|
||||||
|
onStatusChange,
|
||||||
|
selectedRegionalAgent,
|
||||||
|
onRegionalAgentChange,
|
||||||
|
uptimeData = []
|
||||||
|
}: ServiceHeaderProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6">
|
<>
|
||||||
<Button
|
<div className="mb-6">
|
||||||
variant="ghost"
|
<Button
|
||||||
className="mb-4 pl-0 hover:bg-transparent"
|
variant="ghost"
|
||||||
onClick={() => navigate("/dashboard")}
|
className="mb-4 pl-0 hover:bg-transparent"
|
||||||
>
|
onClick={() => navigate("/dashboard")}
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
>
|
||||||
{t("back")}
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
{t("back")}
|
||||||
|
</Button>
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold">{service.name}</h1>
|
<div className="flex items-center">
|
||||||
|
<h1 className="text-2xl font-bold">{service.name}</h1>
|
||||||
{/* Pulsating Circle Animation */}
|
|
||||||
<div className="relative ml-2 flex items-center">
|
{/* Pulsating Circle Animation */}
|
||||||
<span
|
<div className="relative ml-2 flex items-center">
|
||||||
className={cn(
|
<span
|
||||||
"flex h-3 w-3 relative",
|
className={cn(
|
||||||
service.status === "up" ? "bg-green-500" :
|
"flex h-3 w-3 relative",
|
||||||
service.status === "down" ? "bg-red-500" :
|
service.status === "up" ? "bg-green-500" :
|
||||||
service.status === "warning" ? "bg-yellow-500" :
|
service.status === "down" ? "bg-red-500" :
|
||||||
"bg-blue-500",
|
service.status === "warning" ? "bg-yellow-500" :
|
||||||
"rounded-full"
|
"bg-blue-500",
|
||||||
)}
|
"rounded-full"
|
||||||
/>
|
)}
|
||||||
<span
|
/>
|
||||||
className={cn(
|
<span
|
||||||
"animate-ping absolute h-3 w-3",
|
className={cn(
|
||||||
service.status === "up" ? "bg-green-400" :
|
"animate-ping absolute h-3 w-3",
|
||||||
service.status === "down" ? "bg-red-400" :
|
service.status === "up" ? "bg-green-400" :
|
||||||
service.status === "warning" ? "bg-yellow-400" :
|
service.status === "down" ? "bg-red-400" :
|
||||||
"bg-blue-400",
|
service.status === "warning" ? "bg-yellow-400" :
|
||||||
"rounded-full opacity-75"
|
"bg-blue-400",
|
||||||
)}
|
"rounded-full opacity-75"
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{service.url && (
|
||||||
|
<a
|
||||||
|
href={service.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary/80 hover:text-primary text-sm flex items-center mt-1 ml-1"
|
||||||
|
>
|
||||||
|
<Globe className="h-3 w-3 mr-1" />
|
||||||
|
{service.url}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{service.url && (
|
<div className="flex items-center space-x-4">
|
||||||
<a
|
<StatusBadge status={service.status} size="lg" />
|
||||||
href={service.url}
|
<ServiceMonitoringButton service={service} onStatusChange={onStatusChange} />
|
||||||
target="_blank"
|
<Button
|
||||||
rel="noopener noreferrer"
|
variant="outline"
|
||||||
className="text-primary/80 hover:text-primary text-sm flex items-center mt-1 ml-1"
|
size="sm"
|
||||||
|
onClick={() => setShowHeatmap(true)}
|
||||||
|
className="bg-blue-900/20 hover:bg-blue-900/30"
|
||||||
>
|
>
|
||||||
<Globe className="h-3 w-3 mr-1" />
|
<BarChart3 className="h-4 w-4 mr-2" />
|
||||||
{service.url}
|
Heatmap
|
||||||
</a>
|
</Button>
|
||||||
)}
|
{selectedRegionalAgent !== undefined && onRegionalAgentChange && (
|
||||||
</div>
|
<RegionalAgentFilter
|
||||||
|
selectedAgent={selectedRegionalAgent}
|
||||||
<div className="flex items-center space-x-4">
|
onAgentChange={onRegionalAgentChange}
|
||||||
<StatusBadge status={service.status} size="lg" />
|
/>
|
||||||
<ServiceMonitoringButton service={service} onStatusChange={onStatusChange} />
|
)}
|
||||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
</div>
|
||||||
<span className="sr-only">More options</span>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<HeatmapDialog
|
||||||
|
open={showHeatmap}
|
||||||
|
onOpenChange={setShowHeatmap}
|
||||||
|
serviceName={service.name}
|
||||||
|
uptimeData={uptimeData}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -64,6 +64,7 @@ export const ServiceHistoryDialog = ({
|
|||||||
{selectedService && (
|
{selectedService && (
|
||||||
<ServiceUptimeHistory
|
<ServiceUptimeHistory
|
||||||
serviceId={selectedService.id}
|
serviceId={selectedService.id}
|
||||||
|
serviceType={selectedService.type}
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
endDate={endDate}
|
endDate={endDate}
|
||||||
/>
|
/>
|
||||||
@@ -71,4 +72,4 @@ export const ServiceHistoryDialog = ({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -28,7 +28,7 @@ export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMoni
|
|||||||
|
|
||||||
if (isMonitoring) {
|
if (isMonitoring) {
|
||||||
// Pause monitoring
|
// Pause monitoring
|
||||||
console.log(`Pausing monitoring for service ${service.id} (${service.name})`);
|
// console.log(`Pausing monitoring for service ${service.id} (${service.name})`);
|
||||||
await serviceService.pauseMonitoring(service.id);
|
await serviceService.pauseMonitoring(service.id);
|
||||||
setIsMonitoring(false);
|
setIsMonitoring(false);
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMoni
|
|||||||
|
|
||||||
// Send notification for paused status (only here, not in pauseMonitoring.ts)
|
// Send notification for paused status (only here, not in pauseMonitoring.ts)
|
||||||
if (service.alerts !== "muted") {
|
if (service.alerts !== "muted") {
|
||||||
console.log("Sending pause notification from UI component");
|
// console.log("Sending pause notification from UI component");
|
||||||
// IMPORTANT: Direct call to the notification service to ensure a message is sent
|
// IMPORTANT: Direct call to the notification service to ensure a message is sent
|
||||||
await notificationService.sendNotification({
|
await notificationService.sendNotification({
|
||||||
service: service,
|
service: service,
|
||||||
@@ -51,7 +51,7 @@ export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMoni
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Start/resume monitoring
|
// Start/resume monitoring
|
||||||
console.log(`Starting monitoring for service ${service.id} (${service.name})`);
|
// console.log(`Starting monitoring for service ${service.id} (${service.name})`);
|
||||||
|
|
||||||
// First ensure we update the status in the database to not be paused anymore
|
// First ensure we update the status in the database to not be paused anymore
|
||||||
await serviceService.resumeMonitoring(service.id);
|
await serviceService.resumeMonitoring(service.id);
|
||||||
@@ -66,7 +66,7 @@ export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMoni
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error toggling monitoring:", error);
|
// console.error("Error toggling monitoring:", error);
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const ServiceRow = ({
|
|||||||
<TableCell className="py-4">
|
<TableCell className="py-4">
|
||||||
<ServiceRowResponseTime responseTime={service.responseTime} />
|
<ServiceRowResponseTime responseTime={service.responseTime} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-52 py-4">
|
<TableCell className="w-60 py-4">
|
||||||
<UptimeBar
|
<UptimeBar
|
||||||
uptime={service.uptime}
|
uptime={service.uptime}
|
||||||
status={service.status}
|
status={service.status}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function ServiceUptimeHistory({
|
|||||||
const { data: uptimeHistory, isLoading, error } = useQuery({
|
const { data: uptimeHistory, isLoading, error } = useQuery({
|
||||||
queryKey: ['uptimeHistory', serviceId, serviceType, startDate?.toISOString(), endDate?.toISOString()],
|
queryKey: ['uptimeHistory', serviceId, serviceType, startDate?.toISOString(), endDate?.toISOString()],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
console.log(`ServiceUptimeHistory: Fetching for service ${serviceId} of type ${serviceType}`);
|
// console.log(`ServiceUptimeHistory: Fetching for service ${serviceId} of type ${serviceType}`);
|
||||||
return uptimeService.getUptimeHistory(serviceId, 200, startDate, endDate, serviceType);
|
return uptimeService.getUptimeHistory(serviceId, 200, startDate, endDate, serviceType);
|
||||||
},
|
},
|
||||||
enabled: !!serviceId && !!serviceType,
|
enabled: !!serviceId && !!serviceType,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { PageSize } from "@/hooks/useServicesPagination";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
|
interface ServicesPaginationProps {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
pageSize: PageSize;
|
||||||
|
totalItems: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (size: PageSize) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServicesPagination({
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
pageSize,
|
||||||
|
totalItems,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: ServicesPaginationProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
// Generate page numbers to display
|
||||||
|
const getPageNumbers = () => {
|
||||||
|
const pages = [];
|
||||||
|
const maxVisiblePages = 5;
|
||||||
|
const halfVisible = Math.floor(maxVisiblePages / 2);
|
||||||
|
|
||||||
|
let startPage = Math.max(1, currentPage - halfVisible);
|
||||||
|
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
||||||
|
|
||||||
|
// Adjust start page if we don't have enough pages at the end
|
||||||
|
if (endPage - startPage + 1 < maxVisiblePages) {
|
||||||
|
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startItem = Math.min((currentPage - 1) * pageSize + 1, totalItems);
|
||||||
|
const endItem = Math.min(currentPage * pageSize, totalItems);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-4 px-4 border-t border-border">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("rowsPerPage") ?? "Rows per page"}:
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={pageSize.toString()}
|
||||||
|
onValueChange={(value) => onPageSizeChange(parseInt(value) as PageSize)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
<SelectItem value="30">30</SelectItem>
|
||||||
|
<SelectItem value="50">50</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
{totalItems > 0
|
||||||
|
? `${startItem}-${endItem} of ${totalItems} ${t("services") || "services"}`
|
||||||
|
: `0 ${t("services") || "services"}`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||||
|
className={
|
||||||
|
currentPage === 1
|
||||||
|
? "pointer-events-none opacity-50"
|
||||||
|
: "cursor-pointer"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{getPageNumbers().map((page) => (
|
||||||
|
<PaginationItem key={page}>
|
||||||
|
<PaginationLink
|
||||||
|
isActive={page === currentPage}
|
||||||
|
onClick={() => onPageChange(page)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||||
|
className={
|
||||||
|
currentPage === totalPages
|
||||||
|
? "pointer-events-none opacity-50"
|
||||||
|
: "cursor-pointer"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Service } from "@/types/service.types";
|
import { Service } from "@/types/service.types";
|
||||||
import { ServicesTableView } from "./ServicesTableView";
|
import { ServicesTableView } from "./ServicesTableView";
|
||||||
|
import { ServicesPagination } from "./ServicesPagination";
|
||||||
import { ServiceDeleteDialog } from "./ServiceDeleteDialog";
|
import { ServiceDeleteDialog } from "./ServiceDeleteDialog";
|
||||||
import { ServiceHistoryDialog } from "./ServiceHistoryDialog";
|
import { ServiceHistoryDialog } from "./ServiceHistoryDialog";
|
||||||
import { ServiceEditDialog } from "./ServiceEditDialog";
|
import { ServiceEditDialog } from "./ServiceEditDialog";
|
||||||
import { useServiceActions, useDialogState } from "./hooks";
|
import { useServiceActions, useDialogState } from "./hooks";
|
||||||
|
import { useServicesPagination } from "@/hooks/useServicesPagination";
|
||||||
|
|
||||||
interface ServicesTableContainerProps {
|
interface ServicesTableContainerProps {
|
||||||
services: Service[];
|
services: Service[];
|
||||||
@@ -36,6 +38,16 @@ export const ServicesTableContainer = ({ services }: ServicesTableContainerProps
|
|||||||
handleDeleteDialogChange
|
handleDeleteDialogChange
|
||||||
} = useDialogState();
|
} = useDialogState();
|
||||||
|
|
||||||
|
const {
|
||||||
|
paginatedServices,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
pageSize,
|
||||||
|
totalItems,
|
||||||
|
handlePageChange,
|
||||||
|
handlePageSizeChange,
|
||||||
|
} = useServicesPagination({ services: localServices });
|
||||||
|
|
||||||
// Update local services state when props change
|
// Update local services state when props change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateServices(services);
|
updateServices(services);
|
||||||
@@ -62,7 +74,7 @@ export const ServicesTableContainer = ({ services }: ServicesTableContainerProps
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col h-full">
|
<div className="flex-1 flex flex-col h-full">
|
||||||
<ServicesTableView
|
<ServicesTableView
|
||||||
services={localServices}
|
services={paginatedServices}
|
||||||
onViewDetail={handleViewDetail}
|
onViewDetail={handleViewDetail}
|
||||||
onPauseResume={handlePauseResume}
|
onPauseResume={handlePauseResume}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
@@ -70,6 +82,15 @@ export const ServicesTableContainer = ({ services }: ServicesTableContainerProps
|
|||||||
onMuteAlerts={handleMuteAlerts}
|
onMuteAlerts={handleMuteAlerts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ServicesPagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalItems={totalItems}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
/>
|
||||||
|
|
||||||
<ServiceHistoryDialog
|
<ServiceHistoryDialog
|
||||||
isOpen={isHistoryDialogOpen}
|
isOpen={isHistoryDialogOpen}
|
||||||
onOpenChange={setIsHistoryDialogOpen}
|
onOpenChange={setIsHistoryDialogOpen}
|
||||||
@@ -91,4 +112,4 @@ export const ServicesTableContainer = ({ services }: ServicesTableContainerProps
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -26,8 +26,8 @@ export const ServicesTableView = ({
|
|||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg overflow-hidden border border-border flex-1 flex flex-col shadow-sm`}>
|
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg overflow-hidden border border-border shadow-sm`}>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="overflow-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'} sticky top-0 z-10`}>
|
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'} sticky top-0 z-10`}>
|
||||||
<TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
|
<TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
|
||||||
@@ -65,4 +65,4 @@ export const ServicesTableView = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||