Merge branch 'operacle:develop' into develop
@@ -1,6 +1,6 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
github: #[tolaleng]
|
github: [tolaleng]
|
||||||
patreon: # Replace with a single Patreon username
|
patreon: # Replace with a single Patreon username
|
||||||
open_collective: #checkcle
|
open_collective: #checkcle
|
||||||
ko_fi: # Replace with a single Ko-fi username
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
name: 🐛 Bug Report
|
||||||
|
description: Report a reproducible bug to help us improve.
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: [bug]
|
||||||
|
assignees: []
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
**Thank you for taking the time to report a bug!**
|
||||||
|
Please complete the form below to help us reproduce and fix the issue more effectively.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment
|
||||||
|
description: |
|
||||||
|
Describe the environment where the issue occurred.
|
||||||
|
(e.g., OS, browser, Node.js version, Docker version, etc.)
|
||||||
|
placeholder: "Example: Ubuntu 22.04, Chrome 125, Node.js 20.3.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Bug Description
|
||||||
|
description: |
|
||||||
|
A clear and concise description of the issue you encountered.
|
||||||
|
placeholder: "When I do X, Y happens instead of Z..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: |
|
||||||
|
Provide step-by-step instructions to reproduce the issue.
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. Scroll down to '...'
|
||||||
|
4. See the error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: What did you expect to happen?
|
||||||
|
placeholder: "The app should..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: Screenshots or Videos
|
||||||
|
description: |
|
||||||
|
If applicable, add screenshots or screen recordings to help illustrate the issue.
|
||||||
|
placeholder: "Drag and drop or paste screenshots here."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs
|
||||||
|
description: |
|
||||||
|
If applicable, paste any relevant logs or error messages.
|
||||||
|
Please remove sensitive information before submitting.
|
||||||
|
render: shell
|
||||||
|
placeholder: |
|
||||||
|
```
|
||||||
|
Error: Something went wrong
|
||||||
|
at index.js:123:45
|
||||||
|
```
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: terms
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting:
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues to avoid creating duplicates.
|
||||||
|
required: true
|
||||||
|
- label: I have provided sufficient information to reproduce the issue.
|
||||||
|
required: true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: 📝 Documentation Improvement
|
||||||
|
description: Suggest improvements or report issues in documentation.
|
||||||
|
title: "[Docs]: "
|
||||||
|
labels: [documentation]
|
||||||
|
assignees: []
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thank you for helping improve our documentation!
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: What part of the documentation needs improvement?
|
||||||
|
description: |
|
||||||
|
Describe the issue clearly.
|
||||||
|
placeholder: "The installation guide is missing steps for..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: location
|
||||||
|
attributes:
|
||||||
|
label: Location
|
||||||
|
description: |
|
||||||
|
Provide a link or path to the affected documentation.
|
||||||
|
placeholder: "https://github.com/operacle/checkcle/docs/INSTALL.md"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: suggestion
|
||||||
|
attributes:
|
||||||
|
label: Suggested Change
|
||||||
|
description: |
|
||||||
|
How would you improve it?
|
||||||
|
placeholder: "I suggest adding a section about..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: confirmation
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues for similar documentation problems.
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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,53 @@
|
|||||||
|
name: ❓ Ask for Help
|
||||||
|
description: Ask a question or request guidance related to this project.
|
||||||
|
title: "[Question]: "
|
||||||
|
labels: [question]
|
||||||
|
assignees: []
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
**Thanks for reaching out!**
|
||||||
|
Please provide as much detail as possible so we can better assist you.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: question
|
||||||
|
attributes:
|
||||||
|
label: What do you need help with?
|
||||||
|
description: Clearly describe your question or what you're trying to achieve.
|
||||||
|
placeholder: |
|
||||||
|
I am trying to do X, but I'm not sure how to...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: |
|
||||||
|
Provide any relevant context, related issues, pull requests, or documentation links that could help us understand your question better.
|
||||||
|
placeholder: "Mention related issues, links to docs, or previous discussions..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment Details
|
||||||
|
description: |
|
||||||
|
If relevant, provide details about your environment (e.g., OS, browser, Node.js version).
|
||||||
|
placeholder: "Example: Windows 11, Node.js v20.3.0, Firefox 125"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Before submitting, please confirm the following:
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and discussions for a solution.
|
||||||
|
required: true
|
||||||
|
- label: I have read the relevant documentation.
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
name: 🛡️ Security Issue
|
||||||
|
description: Report a potential security vulnerability.
|
||||||
|
title: "[Security]: "
|
||||||
|
labels: [security]
|
||||||
|
assignees: []
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
⚠️ **IMPORTANT: Please do NOT disclose sensitive security details in this public issue.**
|
||||||
|
|
||||||
|
If you believe you have found a security vulnerability, **please report it privately** to help keep users safe.
|
||||||
|
|
||||||
|
📧 **Disclosure Process:**
|
||||||
|
- Email: [security@checkcle.io](mailto:security@checkcle.io)
|
||||||
|
- Or follow our [SECURITY.md](../../blob/main/SECURITY.md) policy.
|
||||||
|
|
||||||
|
You can use this issue only to let maintainers know that you have sent or will send a report.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Summary
|
||||||
|
description: |
|
||||||
|
Briefly describe the type of vulnerability you believe you have found (without including sensitive details).
|
||||||
|
placeholder: "Example: Possible SQL injection in the user login endpoint."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: contact
|
||||||
|
attributes:
|
||||||
|
label: Contact Information
|
||||||
|
description: |
|
||||||
|
Provide an email or other way for maintainers to contact you if we need clarification.
|
||||||
|
placeholder: "your.email@example.com"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: confirmation
|
||||||
|
attributes:
|
||||||
|
label: Confirmation
|
||||||
|
description: |
|
||||||
|
Please confirm:
|
||||||
|
options:
|
||||||
|
- label: I will not share sensitive exploit details in this issue.
|
||||||
|
required: true
|
||||||
|
- label: I have sent or will send a full report to the private disclosure contact.
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: 🌎 Translation Request
|
||||||
|
description: Request a translation or report translation issues.
|
||||||
|
title: "[Translation]: "
|
||||||
|
labels: [translation]
|
||||||
|
assignees: []
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Help us make this project accessible in more languages!
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: language
|
||||||
|
attributes:
|
||||||
|
label: Language
|
||||||
|
description: What language is this about?
|
||||||
|
placeholder: "Example: Spanish"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Details
|
||||||
|
description: |
|
||||||
|
Describe what you’d like to have translated or what issue you found.
|
||||||
|
placeholder: "Please translate the Getting Started guide..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: confirmation
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues for similar requests.
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -39,6 +39,7 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
|
|||||||
|
|
||||||
## 🎯 Live Demo
|
## 🎯 Live Demo
|
||||||
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io)
|
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io)
|
||||||
|
User: admin@example.com | Passwd: Admin123456
|
||||||
|
|
||||||
## 🌟 Core Features
|
## 🌟 Core Features
|
||||||
|
|
||||||
@@ -110,11 +111,12 @@ services:
|
|||||||
4. Follow the Quick Start Guide at https://docs.checkcle.io
|
4. Follow the Quick Start Guide at https://docs.checkcle.io
|
||||||
|
|
||||||
###
|
###
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||

|

|
||||||

|
|
||||||
|
|
||||||
## 📝 Development Roadmap
|
## 📝 Development Roadmap
|
||||||
|
|
||||||
@@ -128,10 +130,10 @@ services:
|
|||||||
- ✅ SSL & Domain Monitoring
|
- ✅ SSL & Domain Monitoring
|
||||||
- ✅ Schedule Maintenance
|
- ✅ Schedule Maintenance
|
||||||
- ✅ Incident Management
|
- ✅ Incident Management
|
||||||
- [ ] Infrastructure Server Monitoring
|
- ✅ Infrastructure Server Monitoring
|
||||||
- ✅ Operational Status / Public Status Pages
|
- ✅ Operational Status / Public Status Pages
|
||||||
- ✅ Uptime monitoring (HTTP, TCP, PING, DNS) Full functionality
|
- ✅ Uptime monitoring (HTTP, TCP, PING, DNS) Full functionality
|
||||||
- ✅ Distributed Regional Monitoring Agent [Support Operation](https://github.com/operacle/Distributed-Regional-Monitoring)
|
- ✅ Distributed Regional Monitoring Agent
|
||||||
- ✅ 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)
|
||||||
@@ -161,7 +163,7 @@ Here are some ways you can help improve CheckCle:
|
|||||||
- Website: [checkcle.io](https://checkcle.io)
|
- Website: [checkcle.io](https://checkcle.io)
|
||||||
- Documentation: [docs.checkcle.io](https://docs.checkcle.io)
|
- Documentation: [docs.checkcle.io](https://docs.checkcle.io)
|
||||||
- GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git)
|
- GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git)
|
||||||
- Community Channels: Engage via discussions and issues!
|
- Reddit Community: [reddit](https://www.reddit.com/r/checkcle)
|
||||||
- Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
|
- Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
|
||||||
- X: [@tlengoss](https://x.com/tlengoss)
|
- X: [@tlengoss](https://x.com/tlengoss)
|
||||||
|
|
||||||
@@ -171,12 +173,7 @@ CheckCle is released under the MIT License.
|
|||||||
|
|
||||||
---
|
---
|
||||||
## 👥 Contributors
|
## 👥 Contributors
|
||||||
|
Thank you for contributing and continuously making CheckCle better, you're awesome 🫶
|
||||||
|
|
||||||
[](https://github.com/operacle/checkcle/graphs/contributors)
|
[](https://github.com/operacle/checkcle/graphs/contributors)
|
||||||
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#operacle/checkcle&Date)
|
|
||||||
|
|
||||||
Stay informed, stay online, with CheckCle! 🌐
|
|
||||||
|
|||||||
|
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 |
@@ -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) {
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -13,7 +13,7 @@ export const SidebarHeader: React.FC<SidebarHeaderProps> = ({ collapsed }) => {
|
|||||||
<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-gray-600 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">
|
||||||
<img
|
<img
|
||||||
src="/favicon.ico"
|
src="/favicon_sidebar.ico"
|
||||||
alt="CheckCle"
|
alt="CheckCle"
|
||||||
className="h-6 w-6"
|
className="h-6 w-6"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const DockerContainersTable = ({ containers, isLoading, onRefresh }: Dock
|
|||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<div className="min-w-full inline-block align-middle">
|
<div className="min-w-full inline-block align-middle">
|
||||||
<div className="overflow-hidden border border-border rounded-lg shadow-sm bg-card">
|
<div className="overflow-hidden border border-border rounded-lg shadow-sm">
|
||||||
<Table>
|
<Table>
|
||||||
<DockerTableHeader />
|
<DockerTableHeader />
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|||||||
@@ -3,44 +3,51 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Container, Play, Square, AlertTriangle } from "lucide-react";
|
import { Container, Play, Square, AlertTriangle } from "lucide-react";
|
||||||
import { DockerStats } from "@/types/docker.types";
|
import { DockerStats } from "@/types/docker.types";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
interface DockerStatsCardsProps {
|
interface DockerStatsCardsProps {
|
||||||
stats: DockerStats;
|
stats: DockerStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
|
export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
title: "Total Containers",
|
title: "Total Containers",
|
||||||
value: stats.total,
|
value: stats.total,
|
||||||
icon: Container,
|
icon: Container,
|
||||||
color: "text-blue-600",
|
color: "text-blue-600",
|
||||||
bgColor: "bg-blue-50",
|
gradient: theme === 'dark'
|
||||||
borderColor: "border-blue-200",
|
? "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",
|
title: "Running",
|
||||||
value: stats.running,
|
value: stats.running,
|
||||||
icon: Play,
|
icon: Play,
|
||||||
color: "text-green-600",
|
color: "text-green-600",
|
||||||
bgColor: "bg-green-50",
|
gradient: theme === 'dark'
|
||||||
borderColor: "border-green-200",
|
? "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",
|
title: "Stopped",
|
||||||
value: stats.stopped,
|
value: stats.stopped,
|
||||||
icon: Square,
|
icon: Square,
|
||||||
color: "text-gray-600",
|
color: "text-gray-600",
|
||||||
bgColor: "bg-gray-50",
|
gradient: theme === 'dark'
|
||||||
borderColor: "border-gray-200",
|
? "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",
|
title: "Warning",
|
||||||
value: stats.warning,
|
value: stats.warning,
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
color: "text-amber-600",
|
color: "text-amber-600",
|
||||||
bgColor: "bg-amber-50",
|
gradient: theme === 'dark'
|
||||||
borderColor: "border-amber-200",
|
? "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%)"
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -49,23 +56,39 @@ export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
|
|||||||
{cards.map((card) => {
|
{cards.map((card) => {
|
||||||
const IconComponent = card.icon;
|
const IconComponent = card.icon;
|
||||||
return (
|
return (
|
||||||
<Card key={card.title} className={`${card.borderColor} bg-card hover:shadow-md transition-shadow`}>
|
<Card
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
key={card.title}
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
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}
|
{card.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className={`${card.bgColor} ${card.color} p-2 rounded-md`}>
|
<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" />
|
<IconComponent className="h-4 w-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="relative z-10">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-2xl font-bold text-foreground">
|
<div className="text-2xl font-bold text-white">
|
||||||
{card.value}
|
{card.value}
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`${card.color} ${card.borderColor} text-xs`}
|
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
|
Containers
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -6,7 +7,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Plus, X, Server, Shield, AlertTriangle } from 'lucide-react';
|
import { Plus, X, Server } from 'lucide-react';
|
||||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { serviceService } from '@/services/serviceService';
|
import { serviceService } from '@/services/serviceService';
|
||||||
@@ -17,12 +18,6 @@ interface ComponentsSelectorProps {
|
|||||||
onComponentDelete?: (componentId: string) => void;
|
onComponentDelete?: (componentId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentTypes = [
|
|
||||||
{ value: 'uptime', label: 'Uptime Service', icon: Server },
|
|
||||||
{ value: 'ssl', label: 'SSL Certificate', icon: Shield },
|
|
||||||
{ value: 'incident', label: 'Incident Monitoring', icon: AlertTriangle },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => {
|
export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => {
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [newComponent, setNewComponent] = useState({
|
const [newComponent, setNewComponent] = useState({
|
||||||
@@ -169,7 +164,7 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
|
|||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select an uptime service" />
|
<SelectValue placeholder="Select an uptime service" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-50 bg-white border shadow-lg">
|
<SelectContent>
|
||||||
{services.map((service) => (
|
{services.map((service) => (
|
||||||
<SelectItem key={service.id} value={service.id}>
|
<SelectItem key={service.id} value={service.id}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -206,37 +201,6 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<Label>Quick Add Templates</Label>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-2">
|
|
||||||
{componentTypes.map((type) => {
|
|
||||||
const Icon = type.icon;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={type.value}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const component: Partial<StatusPageComponentRecord> = {
|
|
||||||
name: type.label,
|
|
||||||
description: `Monitor ${type.label.toLowerCase()}`,
|
|
||||||
service_id: '',
|
|
||||||
server_id: '',
|
|
||||||
display_order: selectedComponents.length + 1,
|
|
||||||
operational_status_id: '',
|
|
||||||
};
|
|
||||||
onComponentsChange([...selectedComponents, component]);
|
|
||||||
}}
|
|
||||||
className="justify-start"
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4 mr-2" />
|
|
||||||
{type.label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -67,13 +67,13 @@ export const CreateOperationalPageDialog = () => {
|
|||||||
page_style: data.page_style || '',
|
page_style: data.page_style || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Creating operational page with payload:', payload);
|
// console.log('Creating operational page with payload:', payload);
|
||||||
const createdPage = await createMutation.mutateAsync(payload);
|
const createdPage = await createMutation.mutateAsync(payload);
|
||||||
console.log('Created page:', createdPage);
|
// console.log('Created page:', createdPage);
|
||||||
|
|
||||||
// Create components after page is created
|
// Create components after page is created
|
||||||
if (selectedComponents.length > 0) {
|
if (selectedComponents.length > 0) {
|
||||||
console.log('Creating components for page:', createdPage.id);
|
// console.log('Creating components for page:', createdPage.id);
|
||||||
for (const component of selectedComponents) {
|
for (const component of selectedComponents) {
|
||||||
const componentPayload = {
|
const componentPayload = {
|
||||||
operational_status_id: createdPage.id,
|
operational_status_id: createdPage.id,
|
||||||
@@ -84,7 +84,7 @@ export const CreateOperationalPageDialog = () => {
|
|||||||
display_order: component.display_order || 1,
|
display_order: component.display_order || 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Creating component with payload:', componentPayload);
|
// console.log('Creating component with payload:', componentPayload);
|
||||||
await createComponentMutation.mutateAsync(componentPayload);
|
await createComponentMutation.mutateAsync(componentPayload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ export const CreateOperationalPageDialog = () => {
|
|||||||
form.reset();
|
form.reset();
|
||||||
setSelectedComponents([]);
|
setSelectedComponents([]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating operational page:', error);
|
// console.error('Error creating operational page:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: Operatio
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-muted-foreground">Public:</span>
|
<span className="font-medium text-muted-foreground">Public:</span>
|
||||||
<p className="mt-1">
|
<div className="mt-1">
|
||||||
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
|
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
|
||||||
{page.is_public === 'true' ? 'Yes' : 'No'}
|
{page.is_public === 'true' ? 'Yes' : 'No'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-muted-foreground">Updated:</span>
|
<span className="font-medium text-muted-foreground">Updated:</span>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { PublicStatusPageFooter } from './PublicStatusPageFooter';
|
|||||||
|
|
||||||
export const PublicStatusPage = () => {
|
export const PublicStatusPage = () => {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
console.log('PublicStatusPage - slug from params:', slug);
|
// console.log('PublicStatusPage - slug from params:', slug);
|
||||||
|
|
||||||
const { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug);
|
const { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug);
|
||||||
const [lastUpdated, setLastUpdated] = useState(new Date());
|
const [lastUpdated, setLastUpdated] = useState(new Date());
|
||||||
@@ -51,7 +51,7 @@ export const PublicStatusPage = () => {
|
|||||||
};
|
};
|
||||||
}, [page?.theme]);
|
}, [page?.theme]);
|
||||||
|
|
||||||
console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length });
|
// console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length });
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -19,61 +19,61 @@ export const usePublicStatusPageData = (slug: string | undefined) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPublicPage = async () => {
|
const fetchPublicPage = async () => {
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
console.log('No slug provided');
|
// console.log('No slug provided');
|
||||||
setError('No status page slug provided');
|
setError('No status page slug provided');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Fetching public status page for slug:', slug);
|
// console.log('Fetching public status page for slug:', slug);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Fetch operational page
|
// Fetch operational page
|
||||||
console.log('Fetching operational pages...');
|
// console.log('Fetching operational pages...');
|
||||||
const pages = await operationalPageService.getOperationalPages();
|
const pages = await operationalPageService.getOperationalPages();
|
||||||
console.log('All pages:', pages);
|
// console.log('All pages:', pages);
|
||||||
|
|
||||||
const foundPage = pages.find(p => p.slug === slug && p.is_public === 'true');
|
const foundPage = pages.find(p => p.slug === slug && p.is_public === 'true');
|
||||||
console.log('Found page:', foundPage);
|
// console.log('Found page:', foundPage);
|
||||||
|
|
||||||
if (!foundPage) {
|
if (!foundPage) {
|
||||||
console.log('Page not found or not public');
|
// console.log('Page not found or not public');
|
||||||
setError('Status page not found or not public');
|
setError('Status page not found or not public');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPage(foundPage);
|
setPage(foundPage);
|
||||||
console.log('Page set successfully');
|
// console.log('Page set successfully');
|
||||||
|
|
||||||
// Fetch components for this page
|
// Fetch components for this page
|
||||||
console.log('Fetching components for page:', foundPage.id);
|
// console.log('Fetching components for page:', foundPage.id);
|
||||||
const pageComponents = await statusPageComponentsService.getStatusPageComponentsByOperationalId(foundPage.id);
|
const pageComponents = await statusPageComponentsService.getStatusPageComponentsByOperationalId(foundPage.id);
|
||||||
console.log('Components found:', pageComponents);
|
// console.log('Components found:', pageComponents);
|
||||||
setComponents(pageComponents);
|
setComponents(pageComponents);
|
||||||
|
|
||||||
// Fetch all services
|
// Fetch all services
|
||||||
console.log('Fetching all services...');
|
// console.log('Fetching all services...');
|
||||||
const allServices = await serviceService.getServices();
|
const allServices = await serviceService.getServices();
|
||||||
console.log('Services found:', allServices);
|
// console.log('Services found:', allServices);
|
||||||
setServices(allServices);
|
setServices(allServices);
|
||||||
|
|
||||||
// Fetch uptime data for each component that has a service
|
// Fetch uptime data for each component that has a service
|
||||||
console.log('Fetching uptime data...');
|
// console.log('Fetching uptime data...');
|
||||||
const uptimePromises = pageComponents
|
const uptimePromises = pageComponents
|
||||||
.filter(component => component.service_id)
|
.filter(component => component.service_id)
|
||||||
.map(async (component) => {
|
.map(async (component) => {
|
||||||
try {
|
try {
|
||||||
console.log('Fetching uptime for service:', component.service_id);
|
// console.log('Fetching uptime for service:', component.service_id);
|
||||||
const endDate = new Date();
|
const endDate = new Date();
|
||||||
const startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // Last 90 days
|
const startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // Last 90 days
|
||||||
const history = await uptimeService.getUptimeHistory(component.service_id, 2000, startDate, endDate);
|
const history = await uptimeService.getUptimeHistory(component.service_id, 2000, startDate, endDate);
|
||||||
console.log(`Uptime history for ${component.service_id}:`, history.length, 'records');
|
// console.log(`Uptime history for ${component.service_id}:`, history.length, 'records');
|
||||||
return { serviceId: component.service_id, history };
|
return { serviceId: component.service_id, history };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching uptime for service ${component.service_id}:`, error);
|
// console.error(`Error fetching uptime for service ${component.service_id}:`, error);
|
||||||
return { serviceId: component.service_id, history: [] };
|
return { serviceId: component.service_id, history: [] };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -84,12 +84,12 @@ export const usePublicStatusPageData = (slug: string | undefined) => {
|
|||||||
uptimeMap[result.serviceId] = result.history;
|
uptimeMap[result.serviceId] = result.history;
|
||||||
});
|
});
|
||||||
setUptimeData(uptimeMap);
|
setUptimeData(uptimeMap);
|
||||||
console.log('Uptime data set successfully');
|
// console.log('Uptime data set successfully');
|
||||||
|
|
||||||
console.log('All data fetched successfully');
|
// console.log('All data fetched successfully');
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching public page:', err);
|
// console.error('Error fetching public page:', err);
|
||||||
setError(`Failed to load status page: ${err}`);
|
setError(`Failed to load status page: ${err}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -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%)"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -24,7 +24,7 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
|
|||||||
isFetching
|
isFetching
|
||||||
} = useServerHistoryData(serverId);
|
} = useServerHistoryData(serverId);
|
||||||
|
|
||||||
//console.log('ServerHistoryCharts: Rendering with serverId:', serverId, 'timeRange:', timeRange);
|
// console.log('ServerHistoryCharts: Rendering with serverId:', serverId, 'timeRange:', timeRange);
|
||||||
|
|
||||||
// Memoize latest data calculation to prevent unnecessary recalculations
|
// Memoize latest data calculation to prevent unnecessary recalculations
|
||||||
const latestData = useMemo(() => {
|
const latestData = useMemo(() => {
|
||||||
@@ -48,10 +48,10 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skeleton loading cards */}
|
{/* Skeleton loading cards */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6">
|
||||||
{[1, 2, 3, 4].map((index) => (
|
{[1, 2, 3, 4].map((index) => (
|
||||||
<Card key={index} className="bg-gradient-to-br from-background to-muted/20">
|
<Card key={index} className="bg-gradient-to-br from-background to-muted/20">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-4 lg:p-6">
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -60,7 +60,7 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-16 h-4 bg-muted rounded"></div>
|
<div className="w-16 h-4 bg-muted rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-80 bg-muted rounded"></div>
|
<div className="w-full h-64 lg:h-80 bg-muted rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -122,11 +122,11 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('ServerHistoryCharts: Rendering charts with', chartData.length, 'data points for time range:', timeRange);
|
// console.log('ServerHistoryCharts: Rendering charts with', chartData.length, 'data points for time range:', timeRange);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<TrendingUp className="h-5 w-5" />
|
<TrendingUp className="h-5 w-5" />
|
||||||
<h2 className="text-lg font-medium">Historical Performance</h2>
|
<h2 className="text-lg font-medium">Historical Performance</h2>
|
||||||
@@ -143,13 +143,21 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
|
|||||||
<TimeRangeSelector value={timeRange} onChange={setTimeRange} />
|
<TimeRangeSelector value={timeRange} onChange={setTimeRange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Use CSS Grid for better performance than flexbox */}
|
{/* Improved responsive grid layout */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<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} />
|
<CPUChart data={chartData} latestData={latestData} />
|
||||||
|
</div>
|
||||||
|
<div className="w-full min-w-0">
|
||||||
<MemoryChart data={chartData} latestData={latestData} />
|
<MemoryChart data={chartData} latestData={latestData} />
|
||||||
|
</div>
|
||||||
|
<div className="w-full min-w-0">
|
||||||
<DiskChart data={chartData} latestData={latestData} />
|
<DiskChart data={chartData} latestData={latestData} />
|
||||||
|
</div>
|
||||||
|
<div className="w-full min-w-0">
|
||||||
<NetworkChart data={chartData} latestData={latestData} />
|
<NetworkChart data={chartData} latestData={latestData} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -28,7 +28,10 @@ export const ServerMetricsCharts = ({ serverId }: ServerMetricsChartsProps) => {
|
|||||||
queryKey: ['server-metrics', serverId, timeRange],
|
queryKey: ['server-metrics', serverId, timeRange],
|
||||||
queryFn: () => serverService.getServerMetrics(serverId, timeRange),
|
queryFn: () => serverService.getServerMetrics(serverId, timeRange),
|
||||||
enabled: !!serverId,
|
enabled: !!serverId,
|
||||||
refetchInterval: 30000
|
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);
|
const chartData = formatChartData(metrics, timeRange);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
@@ -6,13 +5,17 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
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 { RefreshCw, Search, Eye, Activity, MoreHorizontal, Pause, Play, Edit, Trash2 } from "lucide-react";
|
||||||
import { Server } from "@/types/server.types";
|
import { Server } from "@/types/server.types";
|
||||||
import { ServerStatusBadge } from "./ServerStatusBadge";
|
import { ServerStatusBadge } from "./ServerStatusBadge";
|
||||||
import { OSTypeIcon } from "./OSTypeIcon";
|
import { OSTypeIcon } from "./OSTypeIcon";
|
||||||
|
import { EditServerDialog } from "./EditServerDialog";
|
||||||
import { serverService } from "@/services/serverService";
|
import { serverService } from "@/services/serverService";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
interface ServerTableProps {
|
interface ServerTableProps {
|
||||||
servers: Server[];
|
servers: Server[];
|
||||||
@@ -21,9 +24,15 @@ interface ServerTableProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) => {
|
export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [pausedServers, setPausedServers] = useState<Set<string>>(new Set());
|
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 navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const filteredServers = servers.filter(server =>
|
const filteredServers = servers.filter(server =>
|
||||||
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
@@ -39,38 +48,165 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
navigate(`/container-monitoring/${serverId}`);
|
navigate(`/container-monitoring/${serverId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePauseResume = (serverId: string) => {
|
const handlePauseResume = async (server: Server) => {
|
||||||
const isPaused = pausedServers.has(serverId);
|
const serverId = server.id;
|
||||||
if (isPaused) {
|
const isPaused = server.status === "paused";
|
||||||
setPausedServers(prev => {
|
|
||||||
|
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);
|
const newSet = new Set(prev);
|
||||||
newSet.delete(serverId);
|
newSet.delete(serverId);
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
// console.log('Resume server monitoring:', serverId);
|
|
||||||
} else {
|
|
||||||
setPausedServers(prev => new Set(prev).add(serverId));
|
|
||||||
// console.log('Pause server monitoring:', serverId);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (serverId: string) => {
|
const handleEdit = (server: Server) => {
|
||||||
// TODO: Implement edit functionality
|
setSelectedServer(server);
|
||||||
// console.log('Edit server:', serverId);
|
setEditDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (serverId: string) => {
|
const handleDelete = (server: Server) => {
|
||||||
// TODO: Implement delete functionality
|
setSelectedServer(server);
|
||||||
// console.log('Delete server:', serverId);
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="flex-1 flex flex-col">
|
||||||
<CardHeader>
|
<CardHeader className="flex-shrink-0">
|
||||||
<CardTitle>Servers</CardTitle>
|
<CardTitle>Servers</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex-1 flex items-center justify-center">
|
||||||
<div className="flex items-center justify-center h-32">
|
<div className="flex items-center justify-center h-32">
|
||||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
<span className="ml-2">Loading servers...</span>
|
<span className="ml-2">Loading servers...</span>
|
||||||
@@ -81,8 +217,9 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<>
|
||||||
<CardHeader>
|
<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">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<CardTitle className="text-xl font-semibold">Servers</CardTitle>
|
<CardTitle className="text-xl font-semibold">Servers</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -101,26 +238,26 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
{filteredServers.length === 0 ? (
|
{filteredServers.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<p className="text-muted-foreground">No servers found</p>
|
<p className="text-muted-foreground">No servers found</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border overflow-x-auto">
|
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg border border-border shadow-sm`}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
||||||
<TableRow>
|
<TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Name</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Status</TableHead>
|
||||||
<TableHead>OS</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>OS</TableHead>
|
||||||
<TableHead>IP Address</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>IP Address</TableHead>
|
||||||
<TableHead>CPU</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>CPU</TableHead>
|
||||||
<TableHead>Memory</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Memory</TableHead>
|
||||||
<TableHead>Disk</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Disk</TableHead>
|
||||||
<TableHead>Uptime</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Uptime</TableHead>
|
||||||
<TableHead>Last Checked</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Last Checked</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4 text-right`}>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -128,14 +265,18 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
const cpuUsage = server.cpu_usage || 0;
|
const cpuUsage = server.cpu_usage || 0;
|
||||||
const memoryUsage = server.ram_total > 0 ? (server.ram_used / server.ram_total) * 100 : 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 diskUsage = server.disk_total > 0 ? (server.disk_used / server.disk_total) * 100 : 0;
|
||||||
const isPaused = pausedServers.has(server.id);
|
const isPaused = server.status === "paused";
|
||||||
|
const isProcessing = pausingServers.has(server.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={server.id} className="hover:bg-muted/50">
|
<TableRow
|
||||||
<TableCell>
|
key={server.id}
|
||||||
<div>
|
className="hover:bg-muted/50 cursor-pointer"
|
||||||
<div className="font-medium">{server.name}</div>
|
onClick={() => handleViewDetails(server.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="truncate" title={server.name}>
|
||||||
|
{server.name}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -144,91 +285,78 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<OSTypeIcon osType={server.os_type} />
|
<OSTypeIcon osType={server.os_type} />
|
||||||
<span className="text-sm">{server.os_type}</span>
|
<span className="text-sm truncate" title={server.os_type}>
|
||||||
|
{server.os_type}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<code className="text-sm bg-muted px-1 py-0.5 rounded">{server.ip_address}</code>
|
<code className="text-sm bg-muted px-1 py-0.5 rounded text-xs">
|
||||||
|
{server.ip_address}
|
||||||
|
</code>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="space-y-1 min-w-[120px]">
|
<CustomProgressBar
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span>{cpuUsage.toFixed(1)}%</span>
|
|
||||||
<span className="text-muted-foreground">{server.cpu_cores} cores</span>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
value={cpuUsage}
|
value={cpuUsage}
|
||||||
className="h-2"
|
label={`${cpuUsage.toFixed(1)}%`}
|
||||||
indicatorClassName={
|
subtitle={`${server.cpu_cores} cores`}
|
||||||
cpuUsage > 90 ? "bg-red-00" :
|
type="cpu"
|
||||||
cpuUsage > 75 ? "bg-orange-500" :
|
|
||||||
cpuUsage > 60 ? "bg-yellow-500" : "bg-green-500"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="space-y-1 min-w-[120px]">
|
<CustomProgressBar
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">{memoryUsage.toFixed(1)}%</span>
|
|
||||||
<span>{serverService.formatBytes(server.ram_total)}</span>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
value={memoryUsage}
|
value={memoryUsage}
|
||||||
className="h-2"
|
label={`${memoryUsage.toFixed(1)}%`}
|
||||||
indicatorClassName={
|
subtitle={serverService.formatBytes(server.ram_total)}
|
||||||
memoryUsage > 90 ? "bg-red-500" :
|
type="memory"
|
||||||
memoryUsage > 75 ? "bg-yellow-500" : "bg-blue-500"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="space-y-1 min-w-[120px]">
|
<CustomProgressBar
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">{diskUsage.toFixed(1)}%</span>
|
|
||||||
<span>{serverService.formatBytes(server.disk_total)}</span>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
value={diskUsage}
|
value={diskUsage}
|
||||||
className="h-2"
|
label={`${diskUsage.toFixed(1)}%`}
|
||||||
indicatorClassName={
|
subtitle={serverService.formatBytes(server.disk_total)}
|
||||||
diskUsage > 95 ? "bg-red-500" :
|
type="disk"
|
||||||
diskUsage > 85 ? "bg-yellow-500" : "bg-orange-500"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm truncate" title={server.uptime}>
|
||||||
|
{server.uptime}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="text-sm">{server.uptime}</div>
|
<div className="text-sm text-muted-foreground text-xs">
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{new Date(server.last_checked).toLocaleString()}
|
{new Date(server.last_checked).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
<Button variant="ghost" className="h-8 w-8 p-0" disabled={isProcessing}>
|
||||||
<span className="sr-only">Open menu</span>
|
<span className="sr-only">Open menu</span>
|
||||||
|
{isProcessing ? (
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-[200px]">
|
<DropdownMenuContent align="end" className="w-[200px]">
|
||||||
<DropdownMenuItem onClick={() => handleViewDetails(server.id)}>
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewDetails(server.id); }}>
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
View Server Detail
|
View Server Detail
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{server.docker === 'true' && (
|
{server.docker === 'true' && (
|
||||||
<DropdownMenuItem onClick={() => handleViewContainers(server.id)}>
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewContainers(server.id); }}>
|
||||||
<Activity className="mr-2 h-4 w-4" />
|
<Activity className="mr-2 h-4 w-4" />
|
||||||
Container Monitoring
|
Container Monitoring
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => handlePauseResume(server.id)}>
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => { e.stopPropagation(); handlePauseResume(server); }}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
{isPaused ? (
|
{isPaused ? (
|
||||||
<>
|
<>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
@@ -242,12 +370,12 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => handleEdit(server.id)}>
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleEdit(server); }}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit Server
|
Edit Server
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDelete(server.id)}
|
onClick={(e) => { e.stopPropagation(); handleDelete(server); }}
|
||||||
className="text-red-600 focus:text-red-600"
|
className="text-red-600 focus:text-red-600"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
@@ -265,5 +393,42 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid } from "recharts";
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from "recharts";
|
||||||
import { Cpu } from "lucide-react";
|
import { Cpu } from "lucide-react";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent";
|
import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent";
|
||||||
@@ -18,31 +18,27 @@ export const CPUChart = ({ data, latestData }: CPUChartProps) => {
|
|||||||
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<CardHeader className="pb-2 flex-shrink-0">
|
||||||
<CardTitle className="flex items-center justify-between text-foreground">
|
<CardTitle className="flex items-center justify-between text-foreground">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="p-2 rounded-lg bg-blue-500/15">
|
<div className="p-2 rounded-lg bg-blue-500/15">
|
||||||
<Cpu className="h-5 w-5 text-blue-500" />
|
<Cpu className="h-4 w-4 lg:h-5 lg:w-5 text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
CPU Usage
|
<span className="text-sm lg:text-base">CPU Usage</span>
|
||||||
</div>
|
</div>
|
||||||
{latestData && (
|
{latestData && (
|
||||||
<div className="text-right text-sm">
|
<div className="text-right text-xs lg:text-sm">
|
||||||
<div className="text-blue-500 font-semibold">{latestData.cpuUsage}%</div>
|
<div className="text-blue-500 font-semibold">{latestData.cpuUsage}%</div>
|
||||||
<div className="text-xs text-muted-foreground">{latestData.cpuCores} cores</div>
|
<div className="text-xs text-muted-foreground">{latestData.cpuCores} cores</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-2">
|
<CardContent className="pt-2 flex-1 min-h-0">
|
||||||
<ChartContainer config={{
|
<div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
|
||||||
cpuUsage: {
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
label: "CPU Usage (%)",
|
<AreaChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||||
color: theme === 'dark' ? "#3b82f6" : "#2563eb",
|
|
||||||
}
|
|
||||||
}} className="h-80">
|
|
||||||
<AreaChart data={data}>
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="cpuGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="cpuGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={theme === 'dark' ? "#3b82f6" : "#2563eb"} stopOpacity={0.4}/>
|
<stop offset="5%" stopColor={theme === 'dark' ? "#3b82f6" : "#2563eb"} stopOpacity={0.4}/>
|
||||||
@@ -69,14 +65,15 @@ export const CPUChart = ({ data, latestData }: CPUChartProps) => {
|
|||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="cpuUsage"
|
dataKey="cpuUsage"
|
||||||
stroke={theme === 'dark' ? "#60a5fa" : "#2563eb"}
|
stroke={theme === 'dark' ? "#60a5fa" : "#2563eb"}
|
||||||
strokeWidth={3}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
fill="url(#cpuGradient)"
|
fill="url(#cpuGradient)"
|
||||||
fillOpacity={1}
|
fillOpacity={1}
|
||||||
name="CPU Usage (%)"
|
name="CPU Usage (%)"
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid } from "recharts";
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from "recharts";
|
||||||
import { HardDrive } from "lucide-react";
|
import { HardDrive } from "lucide-react";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent";
|
import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent";
|
||||||
@@ -18,31 +18,27 @@ export const DiskChart = ({ data, latestData }: DiskChartProps) => {
|
|||||||
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<CardHeader className="pb-2 flex-shrink-0">
|
||||||
<CardTitle className="flex items-center justify-between text-foreground">
|
<CardTitle className="flex items-center justify-between text-foreground">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="p-2 rounded-lg bg-amber-500/15">
|
<div className="p-2 rounded-lg bg-amber-500/15">
|
||||||
<HardDrive className="h-5 w-5 text-amber-500" />
|
<HardDrive className="h-4 w-4 lg:h-5 lg:w-5 text-amber-500" />
|
||||||
</div>
|
</div>
|
||||||
Disk Usage
|
<span className="text-sm lg:text-base">Disk Usage</span>
|
||||||
</div>
|
</div>
|
||||||
{latestData && (
|
{latestData && (
|
||||||
<div className="text-right text-sm">
|
<div className="text-right text-xs lg:text-sm">
|
||||||
<div className="text-amber-500 font-semibold">{latestData.diskUsagePercent}%</div>
|
<div className="text-amber-500 font-semibold">{latestData.diskUsagePercent}%</div>
|
||||||
<div className="text-xs text-muted-foreground">{latestData.diskUsed} / {latestData.diskTotal}</div>
|
<div className="text-xs text-muted-foreground">{latestData.diskUsed} / {latestData.diskTotal}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-2">
|
<CardContent className="pt-2 flex-1 min-h-0">
|
||||||
<ChartContainer config={{
|
<div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
|
||||||
diskUsagePercent: {
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
label: "Disk Usage (%)",
|
<AreaChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||||
color: theme === 'dark' ? "#f59e0b" : "#d97706",
|
|
||||||
}
|
|
||||||
}} className="h-80">
|
|
||||||
<AreaChart data={data}>
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="diskGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="diskGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={theme === 'dark' ? "#f59e0b" : "#d97706"} stopOpacity={0.6}/>
|
<stop offset="5%" stopColor={theme === 'dark' ? "#f59e0b" : "#d97706"} stopOpacity={0.6}/>
|
||||||
@@ -69,14 +65,15 @@ export const DiskChart = ({ data, latestData }: DiskChartProps) => {
|
|||||||
type="step"
|
type="step"
|
||||||
dataKey="diskUsagePercent"
|
dataKey="diskUsagePercent"
|
||||||
stroke={theme === 'dark' ? "#fbbf24" : "#d97706"}
|
stroke={theme === 'dark' ? "#fbbf24" : "#d97706"}
|
||||||
strokeWidth={3}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
fill="url(#diskGradient)"
|
fill="url(#diskGradient)"
|
||||||
fillOpacity={1}
|
fillOpacity={1}
|
||||||
name="Disk Usage (%)"
|
name="Disk Usage (%)"
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid } from "recharts";
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from "recharts";
|
||||||
import { MemoryStick } from "lucide-react";
|
import { MemoryStick } from "lucide-react";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent";
|
import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent";
|
||||||
@@ -18,31 +18,27 @@ export const MemoryChart = ({ data, latestData }: MemoryChartProps) => {
|
|||||||
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<CardHeader className="pb-2 flex-shrink-0">
|
||||||
<CardTitle className="flex items-center justify-between text-foreground">
|
<CardTitle className="flex items-center justify-between text-foreground">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="p-2 rounded-lg bg-green-500/15">
|
<div className="p-2 rounded-lg bg-green-500/15">
|
||||||
<MemoryStick className="h-5 w-5 text-green-500" />
|
<MemoryStick className="h-4 w-4 lg:h-5 lg:w-5 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
Memory Usage
|
<span className="text-sm lg:text-base">Memory Usage</span>
|
||||||
</div>
|
</div>
|
||||||
{latestData && (
|
{latestData && (
|
||||||
<div className="text-right text-sm">
|
<div className="text-right text-xs lg:text-sm">
|
||||||
<div className="text-green-500 font-semibold">{latestData.ramUsagePercent}%</div>
|
<div className="text-green-500 font-semibold">{latestData.ramUsagePercent}%</div>
|
||||||
<div className="text-xs text-muted-foreground">{latestData.ramUsed} / {latestData.ramTotal}</div>
|
<div className="text-xs text-muted-foreground">{latestData.ramUsed} / {latestData.ramTotal}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-2">
|
<CardContent className="pt-2 flex-1 min-h-0">
|
||||||
<ChartContainer config={{
|
<div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
|
||||||
ramUsagePercent: {
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
label: "Memory Usage (%)",
|
<AreaChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||||
color: theme === 'dark' ? "#10b981" : "#059669",
|
|
||||||
}
|
|
||||||
}} className="h-80">
|
|
||||||
<AreaChart data={data}>
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="memoryGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="memoryGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={theme === 'dark' ? "#10b981" : "#059669"} stopOpacity={0.5}/>
|
<stop offset="5%" stopColor={theme === 'dark' ? "#10b981" : "#059669"} stopOpacity={0.5}/>
|
||||||
@@ -69,14 +65,15 @@ export const MemoryChart = ({ data, latestData }: MemoryChartProps) => {
|
|||||||
type="basis"
|
type="basis"
|
||||||
dataKey="ramUsagePercent"
|
dataKey="ramUsagePercent"
|
||||||
stroke={theme === 'dark' ? "#34d399" : "#059669"}
|
stroke={theme === 'dark' ? "#34d399" : "#059669"}
|
||||||
strokeWidth={3}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
fill="url(#memoryGradient)"
|
fill="url(#memoryGradient)"
|
||||||
fillOpacity={1}
|
fillOpacity={1}
|
||||||
name="Memory Usage (%)"
|
name="Memory Usage (%)"
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid } from "recharts";
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from "recharts";
|
||||||
import { Wifi } from "lucide-react";
|
import { Wifi } from "lucide-react";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { NetworkTooltipContent } from "./tooltips/NetworkTooltipContent";
|
import { NetworkTooltipContent } from "./tooltips/NetworkTooltipContent";
|
||||||
@@ -18,35 +18,27 @@ export const NetworkChart = ({ data, latestData }: NetworkChartProps) => {
|
|||||||
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<CardHeader className="pb-2 flex-shrink-0">
|
||||||
<CardTitle className="flex items-center justify-between text-foreground">
|
<CardTitle className="flex items-center justify-between text-foreground">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="p-2 rounded-lg bg-purple-500/15">
|
<div className="p-2 rounded-lg bg-purple-500/15">
|
||||||
<Wifi className="h-5 w-5 text-purple-500" />
|
<Wifi className="h-4 w-4 lg:h-5 lg:w-5 text-purple-500" />
|
||||||
</div>
|
</div>
|
||||||
Network Traffic
|
<span className="text-sm lg:text-base">Network Traffic</span>
|
||||||
</div>
|
</div>
|
||||||
{latestData && (
|
{latestData && (
|
||||||
<div className="text-right text-sm">
|
<div className="text-right text-xs lg:text-sm">
|
||||||
<div className="text-purple-500 font-semibold">{latestData.networkRxSpeed} KB/s ↓</div>
|
<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 className="text-red-500 font-semibold">{latestData.networkTxSpeed} KB/s ↑</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-2">
|
<CardContent className="pt-2 flex-1 min-h-0">
|
||||||
<ChartContainer config={{
|
<div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
|
||||||
networkRxSpeed: {
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
label: "RX Speed",
|
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||||
color: theme === 'dark' ? "#8b5cf6" : "#7c3aed",
|
|
||||||
},
|
|
||||||
networkTxSpeed: {
|
|
||||||
label: "TX Speed",
|
|
||||||
color: theme === 'dark' ? "#ef4444" : "#dc2626",
|
|
||||||
}
|
|
||||||
}} className="h-80">
|
|
||||||
<LineChart data={data}>
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="networkGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="networkGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={theme === 'dark' ? "#8b5cf6" : "#7c3aed"} stopOpacity={0.2}/>
|
<stop offset="5%" stopColor={theme === 'dark' ? "#8b5cf6" : "#7c3aed"} stopOpacity={0.2}/>
|
||||||
@@ -79,7 +71,7 @@ export const NetworkChart = ({ data, latestData }: NetworkChartProps) => {
|
|||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="networkRxSpeed"
|
dataKey="networkRxSpeed"
|
||||||
stroke={theme === 'dark' ? "#a78bfa" : "#7c3aed"}
|
stroke={theme === 'dark' ? "#a78bfa" : "#7c3aed"}
|
||||||
strokeWidth={3}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
name="RX Speed"
|
name="RX Speed"
|
||||||
filter="url(#glow)"
|
filter="url(#glow)"
|
||||||
@@ -89,13 +81,14 @@ export const NetworkChart = ({ data, latestData }: NetworkChartProps) => {
|
|||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="networkTxSpeed"
|
dataKey="networkTxSpeed"
|
||||||
stroke={theme === 'dark' ? "#f87171" : "#dc2626"}
|
stroke={theme === 'dark' ? "#f87171" : "#dc2626"}
|
||||||
strokeWidth={3}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
name="TX Speed"
|
name="TX Speed"
|
||||||
strokeDasharray="5 5"
|
strokeDasharray="5 5"
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ChartContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
export const formatBytes = (bytes: number, decimals = 2) => {
|
export const formatBytes = (bytes: number, decimals = 2) => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -49,34 +48,82 @@ export const timeRangeOptions = [
|
|||||||
{ value: '3m' as TimeRange, label: 'Last 90 days', hours: 24 * 90 },
|
{ value: '3m' as TimeRange, label: 'Last 90 days', hours: 24 * 90 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Optimized time range filtering with early returns
|
|
||||||
export const filterMetricsByTimeRange = (metrics: any[], timeRange: TimeRange): any[] => {
|
export const filterMetricsByTimeRange = (metrics: any[], timeRange: TimeRange): any[] => {
|
||||||
if (!metrics?.length) return [];
|
if (!metrics?.length) {
|
||||||
|
// console.log('🔍 filterMetricsByTimeRange: No metrics provided');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
//console.log('🔍 filterMetricsByTimeRange: Starting with', metrics.length, 'metrics for', timeRange);
|
||||||
|
|
||||||
const now = new Date();
|
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);
|
const selectedRange = timeRangeOptions.find(opt => opt.value === timeRange);
|
||||||
if (!selectedRange) return metrics;
|
if (!selectedRange) return metrics;
|
||||||
|
|
||||||
// Add small buffer to avoid edge cases
|
const cutoffTime = new Date(now.getTime() - (selectedRange.hours * 60 * 60 * 1000));
|
||||||
const bufferMinutes = timeRange === '60m' ? 5 : timeRange === '1d' ? 30 : 60;
|
|
||||||
const cutoffTime = new Date(now.getTime() - (selectedRange.hours * 60 * 60 * 1000) - (bufferMinutes * 60 * 1000));
|
|
||||||
|
|
||||||
// console.log('filterMetricsByTimeRange: timeRange:', timeRange, 'cutoffTime:', cutoffTime.toISOString());
|
|
||||||
|
|
||||||
const filtered = metrics.filter(metric => {
|
const filtered = metrics.filter(metric => {
|
||||||
const metricTime = new Date(metric.created || metric.timestamp);
|
const metricTime = new Date(metric.created || metric.timestamp);
|
||||||
return metricTime >= cutoffTime && metricTime <= now;
|
return metricTime >= cutoffTime && metricTime <= now;
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log('filterMetricsByTimeRange: Filtered', metrics.length, 'to', filtered.length, 'metrics');
|
// console.log('✅ Filtered', metrics.length, 'to', filtered.length, 'metrics for', timeRange);
|
||||||
|
|
||||||
// Sort by timestamp for proper chart display
|
|
||||||
return filtered.sort((a, b) =>
|
return filtered.sort((a, b) =>
|
||||||
new Date(a.created || a.timestamp).getTime() - new Date(b.created || b.timestamp).getTime()
|
new Date(a.created || a.timestamp).getTime() - new Date(b.created || b.timestamp).getTime()
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optimized timestamp formatting with caching
|
|
||||||
const timestampCache = new Map<string, string>();
|
const timestampCache = new Map<string, string>();
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string, timeRange: TimeRange): string => {
|
const formatTimestamp = (timestamp: string, timeRange: TimeRange): string => {
|
||||||
@@ -118,7 +165,6 @@ const formatTimestamp = (timestamp: string, timeRange: TimeRange): string => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result (limit cache size)
|
|
||||||
if (timestampCache.size > 1000) {
|
if (timestampCache.size > 1000) {
|
||||||
timestampCache.clear();
|
timestampCache.clear();
|
||||||
}
|
}
|
||||||
@@ -127,37 +173,49 @@ const formatTimestamp = (timestamp: string, timeRange: TimeRange): string => {
|
|||||||
return formatted;
|
return formatted;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optimized chart data formatting with better performance
|
|
||||||
export const formatChartData = (metrics: any[], timeRange: TimeRange) => {
|
export const formatChartData = (metrics: any[], timeRange: TimeRange) => {
|
||||||
// console.log('formatChartData: Input metrics count:', metrics?.length || 0, '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) return [];
|
if (!metrics?.length) {
|
||||||
|
// console.log('❌ formatChartData: No metrics provided');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const filteredMetrics = filterMetricsByTimeRange(metrics, timeRange);
|
const filteredMetrics = filterMetricsByTimeRange(metrics, timeRange);
|
||||||
// console.log('formatChartData: After time filtering:', filteredMetrics?.length || 0, 'metrics');
|
// console.log('📊 formatChartData: After time filtering:', filteredMetrics?.length || 0, 'metrics');
|
||||||
|
|
||||||
if (!filteredMetrics.length) return [];
|
if (!filteredMetrics.length) {
|
||||||
|
// console.log('❌ formatChartData: No metrics after time filtering');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// Dynamic sampling based on time range and data volume
|
// Dynamic sampling based on time range and data volume
|
||||||
let maxDataPoints: number;
|
let maxDataPoints: number;
|
||||||
switch (timeRange) {
|
switch (timeRange) {
|
||||||
case '60m': maxDataPoints = 60; break; // 1 point per minute max
|
case '60m': maxDataPoints = 60; break;
|
||||||
case '1d': maxDataPoints = 144; break; // 1 point per 10 minutes max
|
case '1d': maxDataPoints = 144; break;
|
||||||
case '7d': maxDataPoints = 168; break; // 1 point per hour max
|
case '7d': maxDataPoints = 168; break;
|
||||||
case '1m': maxDataPoints = 120; break; // 1 point per 6 hours max
|
case '1m': maxDataPoints = 120; break;
|
||||||
case '3m': maxDataPoints = 90; break; // 1 point per day max
|
case '3m': maxDataPoints = 90; break;
|
||||||
default: maxDataPoints = 100;
|
default: maxDataPoints = 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Smart sampling - only sample if we have significantly more data
|
|
||||||
const sampledMetrics = filteredMetrics.length > maxDataPoints * 1.2
|
const sampledMetrics = filteredMetrics.length > maxDataPoints * 1.2
|
||||||
? filteredMetrics.filter((_, index) => index % Math.ceil(filteredMetrics.length / maxDataPoints) === 0)
|
? filteredMetrics.filter((_, index) => index % Math.ceil(filteredMetrics.length / maxDataPoints) === 0)
|
||||||
: filteredMetrics;
|
: filteredMetrics;
|
||||||
|
|
||||||
// console.log('formatChartData: After sampling:', sampledMetrics.length, 'metrics for display');
|
// console.log('📊 formatChartData: After sampling:', sampledMetrics.length, 'metrics for display');
|
||||||
|
|
||||||
// Batch process the data transformation for better performance
|
const formattedData = sampledMetrics.map((metric) => {
|
||||||
return sampledMetrics.map((metric) => {
|
|
||||||
const cpuUsage = typeof metric.cpu_usage === 'string' ?
|
const cpuUsage = typeof metric.cpu_usage === 'string' ?
|
||||||
parseFloat(metric.cpu_usage.replace('%', '')) :
|
parseFloat(metric.cpu_usage.replace('%', '')) :
|
||||||
parseFloat(metric.cpu_usage) || 0;
|
parseFloat(metric.cpu_usage) || 0;
|
||||||
@@ -210,4 +268,7 @@ export const formatChartData = (metrics: any[], timeRange: TimeRange) => {
|
|||||||
networkTxSpeed: Math.round(networkTxSpeed * 100) / 100,
|
networkTxSpeed: Math.round(networkTxSpeed * 100) / 100,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// console.log('✅ formatChartData: Final formatted data count:', formattedData.length);
|
||||||
|
return formattedData;
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { serverService } from "@/services/serverService";
|
import { serverService } from "@/services/serverService";
|
||||||
import { formatChartData } from "../dataUtils";
|
import { formatChartData } from "../dataUtils";
|
||||||
@@ -7,7 +7,7 @@ import { formatChartData } from "../dataUtils";
|
|||||||
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
|
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
|
||||||
|
|
||||||
export const useServerHistoryData = (serverId: string) => {
|
export const useServerHistoryData = (serverId: string) => {
|
||||||
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
|
const [timeRange, setTimeRange] = useState<TimeRange>("60m");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: metrics = [],
|
data: metrics = [],
|
||||||
@@ -23,15 +23,19 @@ export const useServerHistoryData = (serverId: string) => {
|
|||||||
return result || [];
|
return result || [];
|
||||||
},
|
},
|
||||||
enabled: !!serverId,
|
enabled: !!serverId,
|
||||||
refetchInterval: timeRange === '60m' ? 30000 : timeRange === '1d' ? 60000 : 120000, // Reduced frequency
|
refetchInterval: timeRange === '60m' ? 60000 : timeRange === '1d' ? 120000 : 300000, // Significantly increased intervals
|
||||||
staleTime: timeRange === '60m' ? 15000 : timeRange === '1d' ? 30000 : 60000, // Increased stale time
|
staleTime: timeRange === '60m' ? 30000 : timeRange === '1d' ? 60000 : 120000, // Increased stale time
|
||||||
retry: 2, // Reduced retries
|
retry: 1, // Reduced retries to prevent excessive requests
|
||||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 3000), // Faster retry
|
retryDelay: 2000, // Slower retry
|
||||||
gcTime: 5 * 60 * 1000, // 5 minutes cache
|
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
|
// Memoize chart data formatting to prevent unnecessary recalculations
|
||||||
const chartData = formatChartData(metrics, timeRange);
|
const chartData = useMemo(() => {
|
||||||
|
return formatChartData(metrics, timeRange);
|
||||||
|
}, [metrics, timeRange]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
timeRange,
|
timeRange,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { ServiceForm } from "./ServiceForm";
|
import { ServiceForm } from "./ServiceForm";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
interface AddServiceDialogProps {
|
interface AddServiceDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -15,7 +16,11 @@ interface AddServiceDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps) {
|
export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps) {
|
||||||
const handleSuccess = () => {
|
const queryClient = useQueryClient();
|
||||||
|
const handleSuccess = async () => {
|
||||||
|
// Immediately invalidate and refetch services data
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["services"] });
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,27 +1,34 @@
|
|||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, LineChart, Line } from "recharts";
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, LineChart, Line, BarChart, Bar } 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');
|
||||||
|
|
||||||
// Modern color palette for different chart lines with solid colors at 90-100% opacity
|
// Fixed color palette - consistent colors that don't change
|
||||||
const modernColors = [
|
const fixedColors = [
|
||||||
{ stroke: '#f59e0b', fill: 'rgba(111, 86, 63, 0.95)' }, // Yellow (changed from blue)
|
{ stroke: '#3b82f6', fill: 'rgba(59, 130, 246, 0.2)', name: 'Ocean Blue' },
|
||||||
{ stroke: '#10b981', fill: 'rgba(0, 84, 56, 0.95)' }, // Emerald
|
{ stroke: '#10b981', fill: 'rgba(16, 185, 129, 0.2)', name: 'Emerald Green' },
|
||||||
{ stroke: '#3b82f6', fill: 'rgba(59, 130, 246, 0.95)' }, // Blue (moved to second position)
|
{ stroke: '#f59e0b', fill: 'rgba(245, 158, 11, 0.2)', name: 'Golden Amber' },
|
||||||
{ stroke: '#ef4444', fill: 'rgba(239, 68, 68, 0.95)' }, // Red
|
{ stroke: '#ef4444', fill: 'rgba(239, 68, 68, 0.2)', name: 'Ruby Red' },
|
||||||
{ stroke: '#8b5cf6', fill: 'rgba(139, 92, 246, 0.95)' }, // Violet
|
{ stroke: '#8b5cf6', fill: 'rgba(139, 92, 246, 0.2)', name: 'Royal Purple' },
|
||||||
{ stroke: '#06b6d4', fill: 'rgba(6, 182, 212, 0.95)' }, // Cyan
|
{ stroke: '#06b6d4', fill: 'rgba(6, 182, 212, 0.2)', name: 'Sky Cyan' },
|
||||||
{ stroke: '#f97316', fill: 'rgba(231, 148, 89, 0.95)' }, // Orange
|
{ stroke: '#f97316', fill: 'rgba(249, 115, 22, 0.2)', name: 'Sunset Orange' },
|
||||||
{ stroke: '#84cc16', fill: 'rgba(132, 204, 22, 0.95)' }, // Lime
|
{ 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
|
// Check if we have data from multiple sources
|
||||||
@@ -160,7 +167,7 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
|||||||
return [Math.max(0, minValue - padding), maxValue + padding];
|
return [Math.max(0, minValue - padding), maxValue + padding];
|
||||||
}, [chartData, hasMultipleSources]);
|
}, [chartData, hasMultipleSources]);
|
||||||
|
|
||||||
// Get unique sources for legend with proper labeling and colors
|
// Get unique sources for legend with consistent fixed colors
|
||||||
const sources = useMemo(() => {
|
const sources = useMemo(() => {
|
||||||
if (!hasMultipleSources) return [];
|
if (!hasMultipleSources) return [];
|
||||||
|
|
||||||
@@ -175,25 +182,29 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const regionalSources = Array.from(sourceSet).map((source, index) => {
|
// Sort sources to ensure consistent ordering
|
||||||
|
const sortedSources = Array.from(sourceSet).sort();
|
||||||
|
|
||||||
|
const regionalSources = sortedSources.map((source, index) => {
|
||||||
const data = sourceInfo.get(source);
|
const data = sourceInfo.get(source);
|
||||||
const [regionName, agentId] = source.split('|');
|
const [regionName, agentId] = source.split('|');
|
||||||
|
|
||||||
// Create proper label with region and agent info
|
// Create proper label with region and agent info
|
||||||
let label = regionName;
|
let label = regionName;
|
||||||
if (data?.agent_id && data.agent_id !== '1') {
|
if (data?.agent_id && data.agent_id !== '1') {
|
||||||
label = `${regionName} (${data.agent_id})`;
|
label = `${regionName} (Agent ${data.agent_id})`;
|
||||||
} else if (regionName === 'Default' && data?.agent_id === '1') {
|
} else if (regionName === 'Default' && data?.agent_id === '1') {
|
||||||
label = `Default System Check (Agent 1)`;
|
label = `Default System Check`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorIndex = index % modernColors.length;
|
const colorIndex = index % fixedColors.length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: `regional_${source.replace('|', '_')}`,
|
key: `regional_${source.replace('|', '_')}`,
|
||||||
label: label,
|
label: label,
|
||||||
stroke: modernColors[colorIndex].stroke,
|
stroke: fixedColors[colorIndex].stroke,
|
||||||
fill: modernColors[colorIndex].fill
|
fill: fixedColors[colorIndex].fill,
|
||||||
|
colorName: fixedColors[colorIndex].name
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -205,9 +216,10 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
|||||||
|
|
||||||
const defaultSources = (hasPureDefault && !hasRegionalDefault) ? [{
|
const defaultSources = (hasPureDefault && !hasRegionalDefault) ? [{
|
||||||
key: 'default',
|
key: 'default',
|
||||||
label: 'Default',
|
label: 'Default System',
|
||||||
stroke: modernColors[regionalSources.length % modernColors.length].stroke,
|
stroke: fixedColors[regionalSources.length % fixedColors.length].stroke,
|
||||||
fill: modernColors[regionalSources.length % modernColors.length].fill
|
fill: fixedColors[regionalSources.length % fixedColors.length].fill,
|
||||||
|
colorName: fixedColors[regionalSources.length % fixedColors.length].name
|
||||||
}] : [];
|
}] : [];
|
||||||
|
|
||||||
return [...defaultSources, ...regionalSources];
|
return [...defaultSources, ...regionalSources];
|
||||||
@@ -219,13 +231,13 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
|||||||
const data = payload[0].payload;
|
const data = payload[0].payload;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} p-3 border ${theme === 'dark' ? 'border-gray-700' : 'border-gray-200'} rounded shadow-md min-w-48`}>
|
<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">{String(label)}</p>
|
<p className="text-sm font-semibold mb-1">{String(label)}</p>
|
||||||
<p className="text-xs text-muted-foreground mb-2">{String(data.date)}</p>
|
<p className="text-xs text-muted-foreground mb-3">{String(data.date)}</p>
|
||||||
|
|
||||||
{hasMultipleSources ? (
|
{hasMultipleSources ? (
|
||||||
// Multi-source tooltip
|
// Multi-source tooltip
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{sources.map(source => {
|
{sources.map(source => {
|
||||||
const valueKey = source.key === 'default' ? 'defaultValue' : `${source.key}_value`;
|
const valueKey = source.key === 'default' ? 'defaultValue' : `${source.key}_value`;
|
||||||
const statusKey = source.key === 'default' ? 'defaultStatus' : `${source.key}_status`;
|
const statusKey = source.key === 'default' ? 'defaultStatus' : `${source.key}_status`;
|
||||||
@@ -234,56 +246,66 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
|||||||
|
|
||||||
if (value === undefined && status === undefined) return null;
|
if (value === undefined && status === undefined) return null;
|
||||||
|
|
||||||
let statusColor = "bg-gray-800";
|
let statusBadgeClass = "px-2 py-1 rounded-full text-xs font-medium";
|
||||||
let statusText = "No Data";
|
let statusText = "No Data";
|
||||||
|
|
||||||
if (status === "up") {
|
if (status === "up") {
|
||||||
statusColor = "bg-emerald-800";
|
statusBadgeClass += " bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
|
||||||
statusText = "Up";
|
statusText = "Up";
|
||||||
} else if (status === "down") {
|
} else if (status === "down") {
|
||||||
statusColor = "bg-red-800";
|
statusBadgeClass += " bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
|
||||||
statusText = "Down";
|
statusText = "Down";
|
||||||
} else if (status === "warning") {
|
} else if (status === "warning") {
|
||||||
statusColor = "bg-yellow-800";
|
statusBadgeClass += " bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
|
||||||
statusText = "Warning";
|
statusText = "Warning";
|
||||||
} else if (status === "paused") {
|
} else if (status === "paused") {
|
||||||
statusColor = "bg-gray-800";
|
statusBadgeClass += " bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200";
|
||||||
statusText = "Paused";
|
statusText = "Paused";
|
||||||
|
} else {
|
||||||
|
statusBadgeClass += " bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400";
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={source.key} className="flex items-center justify-between">
|
<div key={source.key} className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full"
|
className="w-4 h-4 rounded-full border-2 border-white shadow-sm flex-shrink-0"
|
||||||
style={{ backgroundColor: source.stroke }}
|
style={{ backgroundColor: source.stroke }}
|
||||||
></div>
|
></div>
|
||||||
<span className="text-xs">{String(source.label)}</span>
|
<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="text-xs font-mono">
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<span className={statusBadgeClass}>
|
||||||
|
{statusText}
|
||||||
|
</span>
|
||||||
|
<div className="text-sm font-mono">
|
||||||
{status === "paused" ? "Paused" :
|
{status === "paused" ? "Paused" :
|
||||||
value !== null && value !== undefined ? `${value} ms` : "No data"}
|
value !== null && value !== undefined ? `${value} ms` : "No data"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Single source tooltip - showing status with color
|
// Single source tooltip - showing status with color
|
||||||
<>
|
<>
|
||||||
<div className={`flex items-center gap-2 mt-1 ${data.status === "paused" ? "opacity-70" : ""}`}>
|
<div className={`flex items-center gap-3 mt-2 ${data.status === "paused" ? "opacity-70" : ""}`}>
|
||||||
<div className={`w-3 h-3 rounded-full ${
|
<div className={`w-4 h-4 rounded-full ${
|
||||||
data.status === "up" ? "bg-emerald-800" :
|
data.status === "up" ? "bg-green-500" :
|
||||||
data.status === "down" ? "bg-red-800" :
|
data.status === "down" ? "bg-red-500" :
|
||||||
data.status === "warning" ? "bg-yellow-800" : "bg-gray-800"
|
data.status === "warning" ? "bg-yellow-500" : "bg-gray-500"
|
||||||
}`}></div>
|
}`}></div>
|
||||||
<span>{
|
<span className="font-medium">{
|
||||||
data.status === "up" ? "Up" :
|
data.status === "up" ? "Up" :
|
||||||
data.status === "down" ? "Down" :
|
data.status === "down" ? "Down" :
|
||||||
data.status === "warning" ? "Warning" : "Paused"
|
data.status === "warning" ? "Warning" : "Paused"
|
||||||
}</span>
|
}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 font-mono text-sm">
|
<p className="mt-2 font-mono text-lg">
|
||||||
{data.status === "paused" ? "Monitoring paused" :
|
{data.status === "paused" ? "Monitoring paused" :
|
||||||
data.value !== null ? `${data.value} ms` : "No data"}
|
data.value !== null ? `${data.value} ms` : "No data"}
|
||||||
</p>
|
</p>
|
||||||
@@ -295,6 +317,182 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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
|
// Check if we have any data to display
|
||||||
const hasData = uptimeData.length > 0;
|
const hasData = uptimeData.length > 0;
|
||||||
|
|
||||||
@@ -319,21 +517,55 @@ 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>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{hasMultipleSources && (
|
||||||
|
<div className="flex items-center gap-2 bg-muted/30 p-1 rounded-lg border">
|
||||||
|
<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 && (
|
{hasData && (
|
||||||
<span className="text-sm font-normal text-muted-foreground">
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
{dateRangeDisplay}
|
{dateRangeDisplay}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{hasMultipleSources && (
|
{hasMultipleSources && (
|
||||||
<div className="flex flex-wrap gap-4 text-sm">
|
<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 => (
|
{sources.map(source => (
|
||||||
<div key={source.key} className="flex items-center gap-2">
|
<div key={source.key} className="flex items-center gap-2 bg-background px-3 py-1.5 rounded-md border shadow-sm">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full border border-white shadow-sm"
|
||||||
style={{ backgroundColor: source.stroke }}
|
style={{ backgroundColor: source.stroke }}
|
||||||
></div>
|
></div>
|
||||||
<span>{String(source.label)}</span>
|
<span className="font-medium">{String(source.label)}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">({source.colorName})</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -347,118 +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%">
|
||||||
{hasMultipleSources ? (
|
{renderChart()}
|
||||||
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 30 }}>
|
|
||||||
<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 />} />
|
|
||||||
|
|
||||||
{/* Default monitoring area with modern solid background */}
|
|
||||||
{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={2.5}
|
|
||||||
dot={false}
|
|
||||||
connectNulls={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Regional monitoring areas with modern solid backgrounds */}
|
|
||||||
{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={2.5}
|
|
||||||
dot={false}
|
|
||||||
connectNulls={false}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AreaChart>
|
|
||||||
) : (
|
|
||||||
// For single regional agent or default monitoring only - use solid background colors
|
|
||||||
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 30 }}>
|
|
||||||
<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 />} />
|
|
||||||
|
|
||||||
{/* Modern solid background areas for different statuses */}
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="upValue"
|
|
||||||
stroke="#10b981"
|
|
||||||
fill="rgba(16, 185, 129, 0.9)"
|
|
||||||
strokeWidth={2.5}
|
|
||||||
dot={false}
|
|
||||||
connectNulls={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="downValue"
|
|
||||||
stroke="#ef4444"
|
|
||||||
fill="rgba(239, 68, 68, 0.9)"
|
|
||||||
strokeWidth={2.5}
|
|
||||||
dot={false}
|
|
||||||
connectNulls={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="warningValue"
|
|
||||||
stroke="#f59e0b"
|
|
||||||
fill="rgba(245, 158, 11, 0.9)"
|
|
||||||
strokeWidth={2.5}
|
|
||||||
dot={false}
|
|
||||||
connectNulls={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Reference lines for paused status */}
|
|
||||||
{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,16 +18,40 @@ 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}`);
|
||||||
|
|
||||||
|
const setupSubscriptions = async () => {
|
||||||
try {
|
try {
|
||||||
// Subscribe to the service record for real-time updates
|
// Clean up existing subscriptions first
|
||||||
const subscription = pb.collection('services').subscribe(serviceId, function(e) {
|
if (subscriptionsRef.current.service) {
|
||||||
// console.log("Service updated:", e.record);
|
subscriptionsRef.current.service();
|
||||||
|
}
|
||||||
|
if (subscriptionsRef.current.uptime) {
|
||||||
|
subscriptionsRef.current.uptime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to the service record with throttling
|
||||||
|
const serviceUnsubscribe = await pb.collection('services').subscribe(serviceId, function(e) {
|
||||||
|
const now = Date.now();
|
||||||
|
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
|
// Update our local state with the new data
|
||||||
if (e.record) {
|
if (e.record) {
|
||||||
@@ -44,45 +68,67 @@ export const useRealTimeUpdates = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to uptime data updates
|
subscriptionsRef.current.service = serviceUnsubscribe;
|
||||||
const uptimeSubscription = pb.collection('uptime_data').subscribe('*', function(e) {
|
|
||||||
if (e.record && e.record.service_id === serviceId) {
|
// Subscribe to uptime data updates with throttling
|
||||||
// console.log("New uptime data:", e.record);
|
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;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("Error setting up real-time updates:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setupSubscriptions();
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
return () => {
|
return () => {
|
||||||
// console.log(`Cleaning up subscriptions for service: ${serviceId}`);
|
// console.log(`Cleaning up subscriptions for service: ${serviceId}`);
|
||||||
try {
|
try {
|
||||||
pb.collection('services').unsubscribe(serviceId);
|
if (subscriptionsRef.current.service) {
|
||||||
pb.collection('uptime_data').unsubscribe('*');
|
subscriptionsRef.current.service();
|
||||||
|
}
|
||||||
|
if (subscriptionsRef.current.uptime) {
|
||||||
|
subscriptionsRef.current.uptime();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("Error cleaning up subscriptions:", error);
|
// console.error("Error cleaning up subscriptions:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
// console.error("Error setting up real-time updates:", error);
|
|
||||||
}
|
|
||||||
}, [serviceId, startDate, endDate, setService, setUptimeData]);
|
}, [serviceId, startDate, endDate, setService, setUptimeData]);
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
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";
|
||||||
@@ -16,14 +17,17 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Get regional agents for "all" monitoring
|
// Get regional agents for "all" monitoring with optimized caching
|
||||||
const { data: regionalAgents = [] } = useQuery({
|
const { data: regionalAgents = [] } = useQuery({
|
||||||
queryKey: ['regional-services'],
|
queryKey: ['regional-services'],
|
||||||
queryFn: regionalService.getRegionalServices,
|
queryFn: regionalService.getRegionalServices,
|
||||||
enabled: selectedRegionalAgent === "all"
|
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 = async (newStatus: "up" | "down" | "paused" | "warning") => {
|
const handleStatusChange = useCallback(async (newStatus: "up" | "down" | "paused" | "warning") => {
|
||||||
if (!service || !serviceId) return;
|
if (!service || !serviceId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -47,9 +51,9 @@ 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, regionalAgent?: 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');
|
||||||
@@ -121,7 +125,7 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
|||||||
} else {
|
} else {
|
||||||
// Fetch regional agent specific data
|
// Fetch regional agent specific data
|
||||||
const [regionName, agentId] = currentAgent.split("|");
|
const [regionName, agentId] = currentAgent.split("|");
|
||||||
// console.log(`Fetching regional agent data for region: ${regionName}, agent: ${agentId} from ${service.type} collection`);
|
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);
|
history = await uptimeService.getUptimeHistoryByRegionalAgent(serviceId, limit, start, end, service.type, regionName, agentId);
|
||||||
// console.log(`Retrieved ${history.length} regional monitoring records`);
|
// console.log(`Retrieved ${history.length} regional monitoring records`);
|
||||||
}
|
}
|
||||||
@@ -131,7 +135,7 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
|||||||
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`);
|
// 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) {
|
||||||
@@ -143,9 +147,9 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
|||||||
});
|
});
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
}, [service, selectedRegionalAgent, regionalAgents, toast]);
|
||||||
|
|
||||||
const handleRegionalAgentChange = (agent: string) => {
|
const handleRegionalAgentChange = useCallback((agent: string) => {
|
||||||
// console.log(`Regional agent changed from ${selectedRegionalAgent} to: ${agent}`);
|
// console.log(`Regional agent changed from ${selectedRegionalAgent} to: ${agent}`);
|
||||||
|
|
||||||
// Clear data immediately when switching
|
// Clear data immediately when switching
|
||||||
@@ -157,11 +161,10 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
|||||||
// console.log(`Refetching data for new agent: ${agent}`);
|
// console.log(`Refetching data for new agent: ${agent}`);
|
||||||
fetchUptimeData(serviceId, startDate, endDate, '24h', agent);
|
fetchUptimeData(serviceId, startDate, endDate, '24h', agent);
|
||||||
}
|
}
|
||||||
};
|
}, [selectedRegionalAgent, serviceId, isLoading, service, fetchUptimeData, startDate, endDate]);
|
||||||
|
|
||||||
// Initial data loading
|
// Memoize the service data fetching to prevent unnecessary re-runs
|
||||||
useEffect(() => {
|
const fetchServiceData = useCallback(async () => {
|
||||||
const fetchServiceData = async () => {
|
|
||||||
try {
|
try {
|
||||||
if (!serviceId) {
|
if (!serviceId) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -212,20 +215,26 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
fetchServiceData();
|
|
||||||
}, [serviceId, navigate, toast]);
|
}, [serviceId, navigate, toast]);
|
||||||
|
|
||||||
// Update data when date range changes or when service is loaded
|
// Initial data loading
|
||||||
|
useEffect(() => {
|
||||||
|
fetchServiceData();
|
||||||
|
}, [fetchServiceData]);
|
||||||
|
|
||||||
|
// Update data when date range changes or when service is loaded - with debouncing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (serviceId && !isLoading && service) {
|
if (serviceId && !isLoading && service) {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
// console.log(`Date range changed or service loaded, refetching data for ${serviceId}: ${startDate.toISOString()} to ${endDate.toISOString()}`);
|
// console.log(`Date range changed or service loaded, refetching data for ${serviceId}: ${startDate.toISOString()} to ${endDate.toISOString()}`);
|
||||||
fetchUptimeData(serviceId, startDate, endDate, '24h', selectedRegionalAgent);
|
fetchUptimeData(serviceId, startDate, endDate, '24h', selectedRegionalAgent);
|
||||||
}
|
}, 500); // Debounce API calls by 500ms
|
||||||
}, [startDate, endDate, serviceId, isLoading, service, selectedRegionalAgent, regionalAgents]);
|
|
||||||
|
|
||||||
return {
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [startDate, endDate, serviceId, isLoading, service, selectedRegionalAgent, regionalAgents, fetchUptimeData]);
|
||||||
|
|
||||||
|
return useMemo(() => ({
|
||||||
service,
|
service,
|
||||||
setService,
|
setService,
|
||||||
uptimeData,
|
uptimeData,
|
||||||
@@ -235,5 +244,5 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
|||||||
fetchUptimeData,
|
fetchUptimeData,
|
||||||
selectedRegionalAgent,
|
selectedRegionalAgent,
|
||||||
handleRegionalAgentChange
|
handleRegionalAgentChange
|
||||||
};
|
}), [service, uptimeData, isLoading, handleStatusChange, fetchUptimeData, selectedRegionalAgent, handleRegionalAgentChange]);
|
||||||
};
|
};
|
||||||
@@ -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";
|
||||||
@@ -37,6 +36,7 @@ export const ServiceDetailContent = ({
|
|||||||
onStatusChange={onStatusChange}
|
onStatusChange={onStatusChange}
|
||||||
selectedRegionalAgent={selectedRegionalAgent}
|
selectedRegionalAgent={selectedRegionalAgent}
|
||||||
onRegionalAgentChange={onRegionalAgentChange}
|
onRegionalAgentChange={onRegionalAgentChange}
|
||||||
|
uptimeData={uptimeData}
|
||||||
/>
|
/>
|
||||||
<ServiceStatsCards service={service} uptimeData={uptimeData} />
|
<ServiceStatsCards service={service} uptimeData={uptimeData} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,37 @@
|
|||||||
|
|
||||||
import { ArrowLeft, Globe } 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 { RegionalAgentFilter } from "@/components/services/RegionalAgentFilter";
|
import { RegionalAgentFilter } from "@/components/services/RegionalAgentFilter";
|
||||||
import { Service } from "@/types/service.types";
|
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;
|
selectedRegionalAgent?: string;
|
||||||
onRegionalAgentChange?: (agent: string) => void;
|
onRegionalAgentChange?: (agent: string) => void;
|
||||||
|
uptimeData?: UptimeData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceHeader({
|
export function ServiceHeader({
|
||||||
service,
|
service,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
selectedRegionalAgent,
|
selectedRegionalAgent,
|
||||||
onRegionalAgentChange
|
onRegionalAgentChange,
|
||||||
|
uptimeData = []
|
||||||
}: ServiceHeaderProps) {
|
}: ServiceHeaderProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -80,6 +86,15 @@ export function ServiceHeader({
|
|||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<StatusBadge status={service.status} size="lg" />
|
<StatusBadge status={service.status} size="lg" />
|
||||||
<ServiceMonitoringButton service={service} onStatusChange={onStatusChange} />
|
<ServiceMonitoringButton service={service} onStatusChange={onStatusChange} />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowHeatmap(true)}
|
||||||
|
className="bg-blue-900/20 hover:bg-blue-900/30"
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4 mr-2" />
|
||||||
|
Heatmap
|
||||||
|
</Button>
|
||||||
{selectedRegionalAgent !== undefined && onRegionalAgentChange && (
|
{selectedRegionalAgent !== undefined && onRegionalAgentChange && (
|
||||||
<RegionalAgentFilter
|
<RegionalAgentFilter
|
||||||
selectedAgent={selectedRegionalAgent}
|
selectedAgent={selectedRegionalAgent}
|
||||||
@@ -89,5 +104,13 @@ export function ServiceHeader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<HeatmapDialog
|
||||||
|
open={showHeatmap}
|
||||||
|
onOpenChange={setShowHeatmap}
|
||||||
|
serviceName={service.name}
|
||||||
|
uptimeData={uptimeData}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,71 +1,70 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React, { memo } from "react";
|
||||||
import { Check, X, Pause, AlertTriangle } from "lucide-react";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
export interface StatusBadgeProps {
|
interface StatusBadgeProps {
|
||||||
status: string;
|
status: "up" | "down" | "paused" | "warning";
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StatusBadge = ({ status, size = "sm" }: StatusBadgeProps) => {
|
const StatusBadgeComponent = ({ status, size = "sm" }: StatusBadgeProps) => {
|
||||||
// Determine the sizing classes based on the size prop
|
const getStatusConfig = (status: string) => {
|
||||||
const getSizeClasses = () => {
|
|
||||||
switch (size) {
|
|
||||||
case "lg":
|
|
||||||
return "px-3 py-1.5 text-sm gap-1.5";
|
|
||||||
case "md":
|
|
||||||
return "px-2.5 py-1 text-sm gap-1.5";
|
|
||||||
case "sm":
|
|
||||||
default:
|
|
||||||
return "px-2 py-0.5 text-xs gap-0.5";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIconSize = () => {
|
|
||||||
switch (size) {
|
|
||||||
case "lg":
|
|
||||||
return "h-4 w-4";
|
|
||||||
case "md":
|
|
||||||
return "h-4 w-4";
|
|
||||||
case "sm":
|
|
||||||
default:
|
|
||||||
return "h-3 w-3";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizeClasses = getSizeClasses();
|
|
||||||
const iconSize = getIconSize();
|
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "up":
|
case "up":
|
||||||
return (
|
return {
|
||||||
<div className={`flex items-center bg-emerald-950/60 dark:bg-emerald-950/60 text-emerald-400 font-medium rounded-full w-fit ${sizeClasses}`}>
|
variant: "default" as const,
|
||||||
<Check className={iconSize} />
|
className: "bg-emerald-700 text-emerald-100 border-emerald-200 hover:bg-emerald-200",
|
||||||
<span>Up</span>
|
label:
|
||||||
</div>
|
<span className="flex items-center gap-1">
|
||||||
);
|
<Check className="w-4 h-4" /> Up
|
||||||
|
</span>
|
||||||
|
|
||||||
|
};
|
||||||
case "down":
|
case "down":
|
||||||
return (
|
return {
|
||||||
<div className={`flex items-center bg-red-950/60 dark:bg-red-950/60 text-red-400 font-medium rounded-full w-fit ${sizeClasses}`}>
|
variant: "destructive" as const,
|
||||||
<X className={iconSize} />
|
className: "bg-red-700 text-red-100 border-red-200 hover:bg-red-200",
|
||||||
<span>Down</span>
|
label: "Down"
|
||||||
</div>
|
};
|
||||||
);
|
|
||||||
case "warning":
|
case "warning":
|
||||||
return (
|
return {
|
||||||
<div className={`flex items-center bg-yellow-950/60 dark:bg-yellow-950/60 text-yellow-400 font-medium rounded-full w-fit ${sizeClasses}`}>
|
variant: "destructive" as const,
|
||||||
<AlertTriangle className={iconSize} />
|
className: "bg-amber-700 text-amber-100 border-amber-200 hover:bg-amber-200",
|
||||||
<span>Warning</span>
|
label: "Warning"
|
||||||
</div>
|
};
|
||||||
);
|
|
||||||
case "paused":
|
case "paused":
|
||||||
return (
|
return {
|
||||||
<div className={`flex items-center bg-gray-200/30 dark:bg-gray-800/80 text-gray-600 dark:text-gray-400 font-medium rounded-full w-fit ${sizeClasses}`}>
|
variant: "secondary" as const,
|
||||||
<Pause className={iconSize} />
|
className: "bg-gray-700 text-gray-100 border-gray-200 hover:bg-gray-200",
|
||||||
<span>Paused</span>
|
label: "Paused"
|
||||||
</div>
|
};
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return {
|
||||||
|
variant: "outline" as const,
|
||||||
|
className: "bg-gray-700 text-gray-100 border-gray-200",
|
||||||
|
label: "Unknown"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "text-xs px-2 py-1",
|
||||||
|
md: "text-sm px-3 py-1.5",
|
||||||
|
lg: "text-base px-4 py-2"
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getStatusConfig(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={config.variant}
|
||||||
|
className={`${config.className} ${sizeClasses[size]} font-medium`}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Memoize the component to prevent unnecessary re-renders
|
||||||
|
export const StatusBadge = memo(StatusBadgeComponent);
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React, { memo, useMemo } from 'react';
|
||||||
import { useConsolidatedUptimeData } from "./hooks/useConsolidatedUptimeData";
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import { UptimeSummary } from "./uptime/UptimeSummary";
|
import { UptimeStatusItem } from './uptime/UptimeStatusItem';
|
||||||
import { formatRelative } from "date-fns";
|
import { uptimeService } from '@/services/uptimeService';
|
||||||
|
import { UptimeData } from '@/types/service.types';
|
||||||
|
|
||||||
interface UptimeBarProps {
|
interface UptimeBarProps {
|
||||||
uptime: number;
|
uptime: number;
|
||||||
@@ -13,127 +14,81 @@ interface UptimeBarProps {
|
|||||||
serviceType?: string;
|
serviceType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UptimeBar = ({ uptime, status, serviceId, interval, serviceType }: UptimeBarProps) => {
|
const UptimeBarComponent = ({ uptime, status, serviceId, interval, serviceType = "HTTP" }: UptimeBarProps) => {
|
||||||
// Use consolidated hook to get properly merged data
|
// Calculate date range for last 20 checks with much more aggressive caching
|
||||||
const { consolidatedItems, isLoading } = useConsolidatedUptimeData({
|
const endDate = useMemo(() => new Date(), []);
|
||||||
serviceId,
|
const startDate = useMemo(() => {
|
||||||
serviceType,
|
const start = new Date(endDate);
|
||||||
status,
|
start.setHours(start.getHours() - Math.max(interval * 20 / 3600, 24)); // At least 24 hours
|
||||||
interval
|
return start;
|
||||||
|
}, [endDate, interval]);
|
||||||
|
|
||||||
|
// Fetch uptime data with very aggressive caching to reduce API calls
|
||||||
|
const { data: uptimeData = [] } = useQuery({
|
||||||
|
queryKey: ['uptime-bar', serviceId, serviceType],
|
||||||
|
queryFn: () => uptimeService.getUptimeHistory(serviceId, 20, startDate, endDate, serviceType),
|
||||||
|
enabled: !!serviceId,
|
||||||
|
staleTime: 30000, // Data is fresh for 30 seconds
|
||||||
|
gcTime: 600000, // Keep in cache for 10 minutes
|
||||||
|
refetchInterval: 60000, // 1 minute polling
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
retry: 3,
|
||||||
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
|
||||||
});
|
});
|
||||||
|
|
||||||
const getStatusColor = (itemStatus: string, hasData: boolean = true) => {
|
// Memoize the uptime calculation to prevent unnecessary recalculations
|
||||||
if (!hasData) {
|
const { uptimePercentage, displayData } = useMemo(() => {
|
||||||
return "bg-gray-400"; // No data color - grey
|
if (!uptimeData || uptimeData.length === 0) {
|
||||||
}
|
return {
|
||||||
|
uptimePercentage: uptime || 0,
|
||||||
switch (itemStatus) {
|
displayData: []
|
||||||
case "up":
|
|
||||||
return "bg-emerald-500";
|
|
||||||
case "down":
|
|
||||||
return "bg-red-500";
|
|
||||||
case "warning":
|
|
||||||
return "bg-yellow-500";
|
|
||||||
case "paused":
|
|
||||||
return "bg-gray-400"; // Paused status - grey
|
|
||||||
case "unknown":
|
|
||||||
case "NA":
|
|
||||||
default:
|
|
||||||
return "bg-gray-400"; // Unknown/NA/default - grey
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="w-52">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{Array(20).fill(null).map((_, index) => (
|
|
||||||
<div key={index} className="h-6 w-1 bg-gray-200 rounded animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<UptimeSummary uptime={uptime} interval={interval} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('UptimeBar rendering consolidated items:', consolidatedItems.length);
|
// Calculate uptime percentage from actual data
|
||||||
|
const upCount = uptimeData.filter(item => item.status === 'up').length;
|
||||||
|
const totalCount = uptimeData.length;
|
||||||
|
const calculatedUptime = totalCount > 0 ? (upCount / totalCount) * 100 : 0;
|
||||||
|
|
||||||
|
// Limit display to last 20 items for performance
|
||||||
|
const limitedData = uptimeData.slice(0, 20);
|
||||||
|
|
||||||
|
return {
|
||||||
|
uptimePercentage: Math.round(calculatedUptime * 100) / 100,
|
||||||
|
displayData: limitedData
|
||||||
|
};
|
||||||
|
}, [uptimeData, uptime]);
|
||||||
|
|
||||||
|
// Memoize the status items to prevent unnecessary re-renders
|
||||||
|
const statusItems = useMemo(() =>
|
||||||
|
displayData.map((item, index) => (
|
||||||
|
<UptimeStatusItem key={`${item.id}-${index}`} item={item} index={index} />
|
||||||
|
)),
|
||||||
|
[displayData]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-52">
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center space-x-3">
|
||||||
{consolidatedItems.map((slot, index) => {
|
<div className="flex space-x-0.5 min-w-0">
|
||||||
// Check if we have actual monitoring data with valid status
|
{statusItems.length > 0 ? statusItems : (
|
||||||
const hasValidData = slot.items.length > 0 && slot.items.some(item =>
|
// Fallback display when no data
|
||||||
item.status && ['up', 'down', 'warning', 'paused'].includes(item.status)
|
<div className="h-5 w-1.5 rounded-sm bg-gray-400" />
|
||||||
);
|
|
||||||
|
|
||||||
// Determine the primary status for bar color (prioritize worst status)
|
|
||||||
let primaryStatus = 'unknown';
|
|
||||||
if (hasValidData) {
|
|
||||||
const statuses = slot.items.map(item => item.status);
|
|
||||||
primaryStatus = statuses.includes('down') ? 'down' :
|
|
||||||
statuses.includes('warning') ? 'warning' :
|
|
||||||
statuses.includes('up') ? 'up' :
|
|
||||||
statuses.includes('paused') ? 'paused' : 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log(`Bar ${index} - Timestamp: ${slot.timestamp}, Items: ${slot.items.length}, Primary Status: ${primaryStatus}, Has Valid Data: ${hasValidData}`);
|
|
||||||
slot.items.forEach((item, itemIndex) => {
|
|
||||||
// console.log(` Item ${itemIndex}: Source=${item.source}, Status=${item.status}, ResponseTime=${item.responseTime}, IsDefault=${item.isDefault}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip key={index}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div
|
|
||||||
className={`h-6 w-1 rounded cursor-pointer ${getStatusColor(primaryStatus, hasValidData)}`}
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="max-w-xs">
|
|
||||||
<div className="text-sm">
|
|
||||||
<div className="font-medium mb-2 text-center">
|
|
||||||
{formatRelative(new Date(slot.timestamp), new Date())}
|
|
||||||
</div>
|
|
||||||
{hasValidData ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{slot.items.map((item, itemIndex) => (
|
|
||||||
<div key={itemIndex} className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
|
||||||
item.status === 'up' ? 'bg-emerald-500' :
|
|
||||||
item.status === 'down' ? 'bg-red-500' :
|
|
||||||
item.status === 'warning' ? 'bg-yellow-500' :
|
|
||||||
item.status === 'paused' ? 'bg-gray-400' : 'bg-gray-400'
|
|
||||||
}`} />
|
|
||||||
<span className="text-xs font-medium truncate">{item.source}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs font-mono text-right flex-shrink-0">
|
|
||||||
{item.status === 'paused' ? 'Paused' :
|
|
||||||
item.responseTime && item.responseTime > 0 ? `${item.responseTime}ms` :
|
|
||||||
'No response'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{slot.items.length > 1 && (
|
|
||||||
<div className="border-t pt-1 mt-2 text-xs text-muted-foreground text-center">
|
|
||||||
{slot.items.length} monitoring sources
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<span className="text-sm font-medium whitespace-nowrap">
|
||||||
<div className="text-xs text-muted-foreground text-center">
|
{uptimePercentage.toFixed(1)}%
|
||||||
No monitoring data available
|
</span>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Last 20 checks
|
||||||
|
</span>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<UptimeSummary uptime={uptime} interval={interval} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Memoize the component to prevent unnecessary re-renders
|
||||||
|
export const UptimeBar = memo(UptimeBarComponent);
|
||||||
@@ -13,6 +13,7 @@ import { serviceService } from "@/services/serviceService";
|
|||||||
import { Service } from "@/types/service.types";
|
import { Service } from "@/types/service.types";
|
||||||
import { ServiceRegionalFields } from "./ServiceRegionalFields";
|
import { ServiceRegionalFields } from "./ServiceRegionalFields";
|
||||||
import { getServiceFormDefaults, mapServiceToFormData, mapFormDataToServiceData } from "./serviceFormUtils";
|
import { getServiceFormDefaults, mapServiceToFormData, mapFormDataToServiceData } from "./serviceFormUtils";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
interface ServiceFormProps {
|
interface ServiceFormProps {
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
@@ -31,6 +32,7 @@ export function ServiceForm({
|
|||||||
}: ServiceFormProps) {
|
}: ServiceFormProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Initialize form with default values
|
// Initialize form with default values
|
||||||
const form = useForm<ServiceFormData>({
|
const form = useForm<ServiceFormData>({
|
||||||
@@ -68,10 +70,10 @@ export function ServiceForm({
|
|||||||
if (onSubmitStart) onSubmitStart();
|
if (onSubmitStart) onSubmitStart();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("Form data being submitted:", data);
|
// console.log("Form data being submitted:", data);
|
||||||
|
|
||||||
const serviceData = mapFormDataToServiceData(data);
|
const serviceData = mapFormDataToServiceData(data);
|
||||||
console.log("Service data being sent:", serviceData);
|
// console.log("Service data being sent:", serviceData);
|
||||||
|
|
||||||
if (isEdit && initialData) {
|
if (isEdit && initialData) {
|
||||||
// Update existing service
|
// Update existing service
|
||||||
@@ -91,6 +93,16 @@ export function ServiceForm({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force immediate refresh of services data
|
||||||
|
queryClient.setQueryData(["services"], (oldData: any) => {
|
||||||
|
// Invalidate the cache to force a fresh fetch
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate and refetch services query
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["services"] });
|
||||||
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
if (!isEdit) {
|
if (!isEdit) {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
|
|||||||
placeholder={getPlaceholder()}
|
placeholder={getPlaceholder()}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
console.log(`${serviceType === "dns" ? "Domain" : serviceType === "tcp" ? "Host" : "URL"} field changed:`, e.target.value);
|
// console.log(`${serviceType === "dns" ? "Domain" : serviceType === "tcp" ? "Host" : "URL"} field changed:`, e.target.value);
|
||||||
field.onChange(e);
|
field.onChange(e);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -93,7 +93,7 @@ export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
|
|||||||
type="number"
|
type="number"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
console.log("Port field changed:", e.target.value);
|
// console.log("Port field changed:", e.target.value);
|
||||||
field.onChange(e);
|
field.onChange(e);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface UseUptimeDataProps {
|
|||||||
export const useUptimeData = ({ serviceId, serviceType, status, interval }: UseUptimeDataProps) => {
|
export const useUptimeData = ({ serviceId, serviceType, status, interval }: UseUptimeDataProps) => {
|
||||||
const [historyItems, setHistoryItems] = useState<UptimeData[]>([]);
|
const [historyItems, setHistoryItems] = useState<UptimeData[]>([]);
|
||||||
|
|
||||||
// Fetch ALL uptime history data including regional monitoring data
|
// Fetch ALL uptime history data including regional monitoring data with 1-minute polling
|
||||||
const { data: uptimeData, isLoading, error, isFetching, refetch } = useQuery({
|
const { data: uptimeData, isLoading, error, isFetching, refetch } = useQuery({
|
||||||
queryKey: ['allUptimeHistory', serviceId, serviceType],
|
queryKey: ['allUptimeHistory', serviceId, serviceType],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -34,8 +34,8 @@ export const useUptimeData = ({ serviceId, serviceType, status, interval }: UseU
|
|||||||
return allMonitoringData;
|
return allMonitoringData;
|
||||||
},
|
},
|
||||||
enabled: !!serviceId,
|
enabled: !!serviceId,
|
||||||
refetchInterval: 30000,
|
refetchInterval: 60000, // 1 minute polling
|
||||||
staleTime: 15000,
|
staleTime: 30000, // Data is fresh for 30 seconds
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
retry: 3,
|
retry: 3,
|
||||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export const AboutSystem: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{t('aboutSystem')}</h1>
|
<h1 className="text-3xl font-bold tracking-tight">{t('aboutSystem')}</h1>
|
||||||
<p className="text-muted-foreground text-base leading-relaxed mt-2">
|
<p className="text-muted-foreground text-base leading-relaxed mt-2">
|
||||||
{t('aboutCheckcle')}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -45,20 +45,20 @@ export const SSLCertificateActions = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => onView(certificate)}>
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onView(certificate); }}>
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
{t('view')}
|
{t('view')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={handleCheck}>
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleCheck(); }}>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Check
|
Check
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => onEdit(certificate)}>
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onEdit(certificate); }}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
{t('edit')}
|
{t('edit')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onDelete(certificate)}
|
onClick={(e) => { e.stopPropagation(); onDelete(certificate); }}
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -37,10 +37,12 @@ import { fetchSSLCertificates, addSSLCertificate, deleteSSLCertificate } from "@
|
|||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
import { SSLCertificate } from "@/types/ssl.types";
|
import { SSLCertificate } from "@/types/ssl.types";
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const SSLCertificatesTable = () => {
|
export const SSLCertificatesTable = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const { theme } = useTheme();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||||
@@ -65,7 +67,7 @@ export const SSLCertificatesTable = () => {
|
|||||||
setShowAddDialog(false);
|
setShowAddDialog(false);
|
||||||
toast.success(t('certificateAdded'));
|
toast.success(t('certificateAdded'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error adding certificate:", error);
|
// console.error("Error adding certificate:", error);
|
||||||
toast.error(t('failedToAddCertificate'));
|
toast.error(t('failedToAddCertificate'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -87,7 +89,7 @@ export const SSLCertificatesTable = () => {
|
|||||||
setSelectedCertificate(null);
|
setSelectedCertificate(null);
|
||||||
toast.success(t('certificateUpdated'));
|
toast.success(t('certificateUpdated'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating certificate:", error);
|
// console.error("Error updating certificate:", error);
|
||||||
toast.error(t('failedToUpdateCertificate'));
|
toast.error(t('failedToUpdateCertificate'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -105,7 +107,7 @@ export const SSLCertificatesTable = () => {
|
|||||||
setSelectedCertificate(null);
|
setSelectedCertificate(null);
|
||||||
toast.success(t('certificateDeleted'));
|
toast.success(t('certificateDeleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting certificate:", error);
|
// console.error("Error deleting certificate:", error);
|
||||||
toast.error(t('failedToDeleteCertificate'));
|
toast.error(t('failedToDeleteCertificate'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -128,24 +130,32 @@ export const SSLCertificatesTable = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex-1 flex flex-col h-full">
|
||||||
<Card>
|
{certificates.length === 0 ? (
|
||||||
<CardContent>
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{t('noCertificatesFound')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg border border-border shadow-sm`}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
||||||
<TableRow>
|
<TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
|
||||||
<TableHead>{t('domain')}</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('domain')}</TableHead>
|
||||||
<TableHead>{t('status')}</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('status')}</TableHead>
|
||||||
<TableHead>{t('issuer')}</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('issuer')}</TableHead>
|
||||||
<TableHead>{t('validUntil')}</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('validUntil')}</TableHead>
|
||||||
<TableHead>{t('daysLeft')}</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('daysLeft')}</TableHead>
|
||||||
<TableHead>Check Interval</TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Check Interval</TableHead>
|
||||||
<TableHead className="w-[50px]"></TableHead>
|
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4 text-right w-[50px]`}>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{certificates.map((certificate) => (
|
{certificates.map((certificate) => (
|
||||||
<TableRow key={certificate.id}>
|
<TableRow
|
||||||
|
key={certificate.id}
|
||||||
|
className="hover:bg-muted/50 cursor-pointer"
|
||||||
|
onClick={() => openViewDialog(certificate)}
|
||||||
|
>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
{certificate.domain}
|
{certificate.domain}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -164,7 +174,7 @@ export const SSLCertificatesTable = () => {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
{certificate.check_interval || 1} {t('days')}
|
{certificate.check_interval || 1} {t('days')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
<SSLCertificateActions
|
<SSLCertificateActions
|
||||||
certificate={certificate}
|
certificate={certificate}
|
||||||
onView={openViewDialog}
|
onView={openViewDialog}
|
||||||
@@ -176,14 +186,8 @@ export const SSLCertificatesTable = () => {
|
|||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{certificates.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
{t('noCertificatesFound')}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* View Certificate Dialog */}
|
{/* View Certificate Dialog */}
|
||||||
<SSLCertificateDetailDialog
|
<SSLCertificateDetailDialog
|
||||||
@@ -250,6 +254,6 @@ export const SSLCertificatesTable = () => {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -30,12 +30,12 @@ export const SSLDomainContent = () => {
|
|||||||
queryKey: ['ssl-certificates'],
|
queryKey: ['ssl-certificates'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
// console.log("Fetching SSL certificates from SSLDomainContent...");
|
console.log("Fetching SSL certificates from SSLDomainContent...");
|
||||||
const result = await fetchSSLCertificates();
|
const result = await fetchSSLCertificates();
|
||||||
// console.log("Received SSL certificates:", result);
|
console.log("Received SSL certificates:", result);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("Error fetching certificates:", error);
|
console.error("Error fetching certificates:", error);
|
||||||
toast.error(t('failedToLoadCertificates'));
|
toast.error(t('failedToLoadCertificates'));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ export const SSLDomainContent = () => {
|
|||||||
toast.success(t('sslCertificateAdded'));
|
toast.success(t('sslCertificateAdded'));
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// console.error("Error adding SSL certificate:", error);
|
console.error("Error adding SSL certificate:", error);
|
||||||
toast.error(error instanceof Error ? error.message : t('failedToAddCertificate'));
|
toast.error(error instanceof Error ? error.message : t('failedToAddCertificate'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -61,7 +61,7 @@ export const SSLDomainContent = () => {
|
|||||||
// Edit certificate mutation - Updated to ensure thresholds are properly updated
|
// Edit certificate mutation - Updated to ensure thresholds are properly updated
|
||||||
const editMutation = useMutation({
|
const editMutation = useMutation({
|
||||||
mutationFn: async (certificate: SSLCertificate) => {
|
mutationFn: async (certificate: SSLCertificate) => {
|
||||||
// console.log("Updating certificate with data:", certificate);
|
console.log("Updating certificate with data:", certificate);
|
||||||
|
|
||||||
// Create the update data object
|
// Create the update data object
|
||||||
const updateData = {
|
const updateData = {
|
||||||
@@ -70,12 +70,12 @@ export const SSLDomainContent = () => {
|
|||||||
notification_channel: certificate.notification_channel,
|
notification_channel: certificate.notification_channel,
|
||||||
};
|
};
|
||||||
|
|
||||||
// console.log("Update data to be sent:", updateData);
|
console.log("Update data to be sent:", updateData);
|
||||||
|
|
||||||
// Update certificate in the database using PocketBase directly
|
// Update certificate in the database using PocketBase directly
|
||||||
const updated = await pb.collection('ssl_certificates').update(certificate.id, updateData);
|
const updated = await pb.collection('ssl_certificates').update(certificate.id, updateData);
|
||||||
|
|
||||||
// console.log("PocketBase update response:", updated);
|
console.log("PocketBase update response:", updated);
|
||||||
|
|
||||||
// After updating the settings, refresh the certificate to ensure it's up to date
|
// After updating the settings, refresh the certificate to ensure it's up to date
|
||||||
// This will also check if notification needs to be sent based on updated thresholds
|
// This will also check if notification needs to be sent based on updated thresholds
|
||||||
@@ -90,7 +90,7 @@ export const SSLDomainContent = () => {
|
|||||||
toast.success(t('sslCertificateUpdated'));
|
toast.success(t('sslCertificateUpdated'));
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// console.error("Error updating SSL certificate:", error);
|
console.error("Error updating SSL certificate:", error);
|
||||||
toast.error(error instanceof Error ? error.message : t('failedToUpdateCertificate'));
|
toast.error(error instanceof Error ? error.message : t('failedToUpdateCertificate'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -103,33 +103,26 @@ export const SSLDomainContent = () => {
|
|||||||
toast.success(t('sslCertificateDeleted'));
|
toast.success(t('sslCertificateDeleted'));
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// console.error("Error deleting SSL certificate:", error);
|
console.error("Error deleting SSL certificate:", error);
|
||||||
toast.error(error instanceof Error ? error.message : t('failedToDeleteCertificate'));
|
toast.error(error instanceof Error ? error.message : t('failedToDeleteCertificate'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh certificate mutation - Updated to ensure notifications are sent
|
// Refresh certificate mutation - Updated to remove individual toast notifications
|
||||||
const refreshMutation = useMutation({
|
const refreshMutation = useMutation({
|
||||||
mutationFn: checkAndUpdateCertificate,
|
mutationFn: checkAndUpdateCertificate,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
|
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
|
||||||
setRefreshingId(null);
|
setRefreshingId(null);
|
||||||
toast.success(t('sslCertificateRefreshed').replace('{domain}', data.domain));
|
// Removed individual success toast notification
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// console.error("Error refreshing SSL certificate:", error);
|
console.error("Error refreshing SSL certificate:", error);
|
||||||
|
|
||||||
let errorMessage = t('failedToCheckCertificate');
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
errorMessage = error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error(errorMessage);
|
|
||||||
setRefreshingId(null);
|
setRefreshingId(null);
|
||||||
|
|
||||||
// Still refresh the data to show any partial information that was saved
|
// Still refresh the data to show any partial information that was saved
|
||||||
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
|
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
|
||||||
|
// Removed individual error toast notification
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,7 +142,7 @@ export const SSLDomainContent = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// console.error("Error refreshing all certificates:", error);
|
console.error("Error refreshing all certificates:", error);
|
||||||
toast.error(t('failedToCheckCertificate'));
|
toast.error(t('failedToCheckCertificate'));
|
||||||
setIsRefreshingAll(false);
|
setIsRefreshingAll(false);
|
||||||
|
|
||||||
@@ -174,7 +167,7 @@ export const SSLDomainContent = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateCertificate = (certificate: SSLCertificate) => {
|
const handleUpdateCertificate = (certificate: SSLCertificate) => {
|
||||||
// console.log("Handling certificate update with data:", certificate);
|
console.log("Handling certificate update with data:", certificate);
|
||||||
editMutation.mutate(certificate);
|
editMutation.mutate(certificate);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const ContainerMonitoring = () => {
|
|||||||
});
|
});
|
||||||
const [currentUser, setCurrentUser] = useState(authService.getCurrentUser());
|
const [currentUser, setCurrentUser] = useState(authService.getCurrentUser());
|
||||||
|
|
||||||
console.log('ContainerMonitoring component loaded with serverId:', serverId);
|
// console.log('ContainerMonitoring component loaded with serverId:', serverId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: containers = [],
|
data: containers = [],
|
||||||
@@ -39,26 +39,26 @@ const ContainerMonitoring = () => {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['docker-containers', serverId],
|
queryKey: ['docker-containers', serverId],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
console.log('Query function called with serverId:', serverId);
|
// console.log('Query function called with serverId:', serverId);
|
||||||
return serverId ? dockerService.getContainersByServerId(serverId) : dockerService.getContainers();
|
return serverId ? dockerService.getContainersByServerId(serverId) : dockerService.getContainers();
|
||||||
},
|
},
|
||||||
refetchInterval: 30000 // Refetch every 30 seconds
|
refetchInterval: 30000 // Refetch every 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Query state:', { containers, isLoading, error });
|
// console.log('Query state:', { containers, isLoading, error });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('Containers changed:', containers);
|
// console.log('Containers changed:', containers);
|
||||||
if (containers.length > 0) {
|
if (containers.length > 0) {
|
||||||
dockerService.getContainerStats(containers).then(newStats => {
|
dockerService.getContainerStats(containers).then(newStats => {
|
||||||
console.log('Stats calculated:', newStats);
|
// console.log('Stats calculated:', newStats);
|
||||||
setStats(newStats);
|
setStats(newStats);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [containers]);
|
}, [containers]);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
console.log('Manual refresh triggered');
|
// console.log('Manual refresh triggered');
|
||||||
refetch();
|
refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ const ContainerMonitoring = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Container monitoring error:', error);
|
// console.error('Container monitoring error:', error);
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
||||||
<Sidebar collapsed={sidebarCollapsed} />
|
<Sidebar collapsed={sidebarCollapsed} />
|
||||||
|
|||||||
@@ -29,22 +29,39 @@ const Dashboard = () => {
|
|||||||
navigate("/login");
|
navigate("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch all services
|
// Fetch all services with 1-minute polling for real-time updates
|
||||||
const { data: services = [], isLoading, error } = useQuery({
|
const { data: services = [], isLoading, error } = useQuery({
|
||||||
queryKey: ['services'],
|
queryKey: ['services'],
|
||||||
queryFn: serviceService.getServices,
|
queryFn: serviceService.getServices,
|
||||||
refetchInterval: 10000, // Refresh data every 10 seconds
|
refetchInterval: 60000, // 1 minute as requested
|
||||||
|
staleTime: 30000, // Data is fresh for 30 seconds
|
||||||
|
gcTime: 120000, // Keep in cache for 2 minutes
|
||||||
|
refetchOnWindowFocus: true, // Refetch when window gains focus
|
||||||
|
refetchOnMount: true, // Refetch on mount
|
||||||
|
refetchOnReconnect: true, // Refetch on reconnect
|
||||||
|
retry: 2,
|
||||||
|
retryDelay: 3000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start monitoring all active services when the dashboard loads
|
// Start monitoring all active services when the dashboard loads - only once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let hasStarted = false;
|
||||||
|
|
||||||
const startActiveServices = async () => {
|
const startActiveServices = async () => {
|
||||||
|
if (hasStarted) return;
|
||||||
|
hasStarted = true;
|
||||||
|
|
||||||
await serviceService.startAllActiveServices();
|
await serviceService.startAllActiveServices();
|
||||||
// console.log("Active services monitoring started");
|
// console.log("Active services monitoring started");
|
||||||
};
|
};
|
||||||
|
|
||||||
startActiveServices();
|
// Only start once and add a delay to prevent immediate execution
|
||||||
}, []);
|
const timeoutId = setTimeout(startActiveServices, 2000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, []); // Remove services dependency to prevent re-runs
|
||||||
|
|
||||||
// Show the loading state while fetching data
|
// Show the loading state while fetching data
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import { Sidebar } from "@/components/dashboard/Sidebar";
|
|||||||
import { Header } from "@/components/dashboard/Header";
|
import { Header } from "@/components/dashboard/Header";
|
||||||
import { ServerStatsCards } from "@/components/servers/ServerStatsCards";
|
import { ServerStatsCards } from "@/components/servers/ServerStatsCards";
|
||||||
import { ServerTable } from "@/components/servers/ServerTable";
|
import { ServerTable } from "@/components/servers/ServerTable";
|
||||||
|
import { AddServerAgentDialog } from "@/components/servers/AddServerAgentDialog";
|
||||||
import { serverService } from "@/services/serverService";
|
import { serverService } from "@/services/serverService";
|
||||||
import { Server, ServerStats } from "@/types/server.types";
|
import { Server, ServerStats } from "@/types/server.types";
|
||||||
import { useSidebar } from "@/contexts/SidebarContext";
|
import { useSidebar } from "@/contexts/SidebarContext";
|
||||||
import { authService } from "@/services/authService";
|
import { authService } from "@/services/authService";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
const InstanceMonitoring = () => {
|
const InstanceMonitoring = () => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -27,6 +30,7 @@ const InstanceMonitoring = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [currentUser, setCurrentUser] = useState(authService.getCurrentUser());
|
const [currentUser, setCurrentUser] = useState(authService.getCurrentUser());
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
|
|
||||||
const { data: servers = [], isLoading, error, refetch } = useQuery({
|
const { data: servers = [], isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['servers'],
|
queryKey: ['servers'],
|
||||||
@@ -49,11 +53,15 @@ const InstanceMonitoring = () => {
|
|||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAgentAdded = () => {
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
||||||
<Sidebar collapsed={sidebarCollapsed} />
|
<Sidebar collapsed={sidebarCollapsed} />
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Header
|
<Header
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
@@ -82,7 +90,7 @@ const InstanceMonitoring = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
||||||
<Sidebar collapsed={sidebarCollapsed} />
|
<Sidebar collapsed={sidebarCollapsed} />
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Header
|
<Header
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
@@ -90,33 +98,43 @@ const InstanceMonitoring = () => {
|
|||||||
toggleSidebar={toggleSidebar}
|
toggleSidebar={toggleSidebar}
|
||||||
/>
|
/>
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 overflow-auto">
|
||||||
<div className="mx-[20px] my-[20px]">
|
<div className="p-4 sm:p-6 lg:p-8 space-y-6">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div className="mb-6 lg:mb-8">
|
<div>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h1 className="text-2xl font-bold text-foreground">
|
<h1 className="text-2xl lg:text-2xl font-bold text-foreground">
|
||||||
Instance Monitoring
|
Instance Monitoring
|
||||||
</h1>
|
</h1>
|
||||||
<p className={`text-muted-foreground mt-1 sm:mt-2 transition-all duration-300 ${sidebarCollapsed ? 'text-base sm:text-lg' : 'text-sm sm:text-base'}`}>
|
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">
|
||||||
Monitor and manage your server instances in real-time
|
Monitor and manage your server instances in real-time
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={() => setAddDialogOpen(true)} className="flex-shrink-0">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Server Agent
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards Section */}
|
{/* Stats Cards Section */}
|
||||||
<div className="mb-6 lg:mb-8">
|
<div>
|
||||||
<ServerStatsCards stats={stats} />
|
<ServerStatsCards stats={stats} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Server Table Section */}
|
{/* Server Table Section */}
|
||||||
<div className="min-w-0">
|
<div>
|
||||||
<ServerTable servers={servers} isLoading={isLoading} onRefresh={handleRefresh} />
|
<ServerTable servers={servers} isLoading={isLoading} onRefresh={handleRefresh} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AddServerAgentDialog
|
||||||
|
open={addDialogOpen}
|
||||||
|
onOpenChange={setAddDialogOpen}
|
||||||
|
onAgentAdded={handleAgentAdded}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -10,8 +9,7 @@ import { Header } from "@/components/dashboard/Header";
|
|||||||
import { serverService } from "@/services/serverService";
|
import { serverService } from "@/services/serverService";
|
||||||
import { authService } from "@/services/authService";
|
import { authService } from "@/services/authService";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ArrowLeft, Server, Database } from "lucide-react";
|
import { ArrowLeft, Server } from "lucide-react";
|
||||||
import { ServerMetricsCharts } from "@/components/servers/ServerMetricsCharts";
|
|
||||||
import { ServerMetricsOverview } from "@/components/servers/ServerMetricsOverview";
|
import { ServerMetricsOverview } from "@/components/servers/ServerMetricsOverview";
|
||||||
import { ServerHistoryCharts } from "@/components/servers/ServerHistoryCharts";
|
import { ServerHistoryCharts } from "@/components/servers/ServerHistoryCharts";
|
||||||
import { ServerSystemInfoCard } from "@/components/servers/ServerSystemInfoCard";
|
import { ServerSystemInfoCard } from "@/components/servers/ServerSystemInfoCard";
|
||||||
@@ -36,6 +34,70 @@ const ServerDetail = () => {
|
|||||||
enabled: !!serverId
|
enabled: !!serverId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getOSLogo = (server: any) => {
|
||||||
|
if (!server) return null;
|
||||||
|
|
||||||
|
// Parse system_info if it's a string, but handle both JSON and plain text
|
||||||
|
let systemInfo: any = {};
|
||||||
|
let systemInfoText = '';
|
||||||
|
|
||||||
|
if (server.system_info) {
|
||||||
|
if (typeof server.system_info === 'string') {
|
||||||
|
// Try to parse as JSON first
|
||||||
|
try {
|
||||||
|
systemInfo = JSON.parse(server.system_info);
|
||||||
|
} catch (error) {
|
||||||
|
// If JSON parsing fails, treat it as plain text
|
||||||
|
// console.log('system_info is plain text:', server.system_info);
|
||||||
|
systemInfoText = server.system_info.toLowerCase();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
systemInfo = server.system_info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check system_info (both JSON and plain text), then fallback to os_type
|
||||||
|
const osFromJson = systemInfo.OSName || '';
|
||||||
|
const osFromText = systemInfoText;
|
||||||
|
const osFromType = server.os_type || '';
|
||||||
|
|
||||||
|
// Combine all OS information for detection
|
||||||
|
const combinedOSInfo = `${osFromJson} ${osFromText} ${osFromType}`.toLowerCase();
|
||||||
|
|
||||||
|
// console.log('OS detection info:', { osFromJson, osFromText, osFromType, combinedOSInfo });
|
||||||
|
|
||||||
|
// Check for specific OS distributions first (most specific to least specific)
|
||||||
|
if (combinedOSInfo.includes('ubuntu')) {
|
||||||
|
return '/upload/os/ubuntu.png';
|
||||||
|
} else if (combinedOSInfo.includes('debian')) {
|
||||||
|
return '/upload/os/debian.png';
|
||||||
|
} else if (combinedOSInfo.includes('centos')) {
|
||||||
|
return '/upload/os/centos.png';
|
||||||
|
} else if (combinedOSInfo.includes('rhel') || combinedOSInfo.includes('red hat')) {
|
||||||
|
return '/upload/os/rhel.png';
|
||||||
|
} else if (combinedOSInfo.includes('fedora')) {
|
||||||
|
return '/upload/os/fedora.png';
|
||||||
|
} else if (combinedOSInfo.includes('suse') || combinedOSInfo.includes('opensuse')) {
|
||||||
|
return '/upload/os/suse.png';
|
||||||
|
} else if (combinedOSInfo.includes('arch')) {
|
||||||
|
return '/upload/os/arch.png';
|
||||||
|
} else if (combinedOSInfo.includes('alpine')) {
|
||||||
|
return '/upload/os/alpine.png';
|
||||||
|
} else if (combinedOSInfo.includes('windows')) {
|
||||||
|
return '/upload/os/windows.png';
|
||||||
|
} else if (combinedOSInfo.includes('macos') || combinedOSInfo.includes('darwin') || combinedOSInfo.includes('mac os')) {
|
||||||
|
return '/upload/os/macos.png';
|
||||||
|
} else if (combinedOSInfo.includes('freebsd')) {
|
||||||
|
return '/upload/os/freebsd.png';
|
||||||
|
} else if (combinedOSInfo.includes('linux') || combinedOSInfo.includes('gnu')) {
|
||||||
|
// Default to linux.png for any Linux-based system that doesn't match specific distributions
|
||||||
|
return '/upload/os/linux.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback - if we can't determine the OS, default to linux.png
|
||||||
|
return '/upload/os/linux.png';
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
authService.logout();
|
authService.logout();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
@@ -46,7 +108,6 @@ const ServerDetail = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (serverError) {
|
if (serverError) {
|
||||||
// console.error('Server detail error:', serverError);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
||||||
<Sidebar collapsed={sidebarCollapsed} />
|
<Sidebar collapsed={sidebarCollapsed} />
|
||||||
@@ -101,7 +162,7 @@ const ServerDetail = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
||||||
<Sidebar collapsed={sidebarCollapsed} />
|
<Sidebar collapsed={sidebarCollapsed} />
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Header
|
<Header
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
@@ -126,23 +187,31 @@ const ServerDetail = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
|
<div className="h-12 w-12 rounded bg-primary/10 flex items-center justify-center p-2">
|
||||||
<Database className="h-4 w-4 text-primary" />
|
{server && getOSLogo(server) ? (
|
||||||
|
<img
|
||||||
|
src={getOSLogo(server)}
|
||||||
|
alt="OS Logo"
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Server className="h-6 w-6 text-primary" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">
|
<h1 className="text-2xl font-bold text-foreground">
|
||||||
{server?.name || 'Server Detail'}
|
{server?.name || 'Server Detail'}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-muted-foreground mt-1 sm:mt-2 transition-all duration-300 ${sidebarCollapsed ? 'text-base sm:text-lg' : 'text-sm sm:text-base'}`}>
|
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base">
|
||||||
Monitor server performance metrics and system health
|
Monitor server performance metrics and system health
|
||||||
{server && (
|
{server && (
|
||||||
<span className="block text-xs text-foreground/80 mt-1">
|
<span className="block text-xs text-muted-foreground/70 mt-1">
|
||||||
{server.hostname} • {server.ip_address} • {server.os_type}
|
{server.hostname} • {server.ip_address} • {server.os_type}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* System Info Card */}
|
{/* System Info Card */}
|
||||||
{server && (
|
{server && (
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
@@ -159,17 +228,10 @@ const ServerDetail = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Historical Charts Section */}
|
{/* Historical Charts Section - Single comprehensive section */}
|
||||||
{server && (
|
|
||||||
<div className="mb-6 lg:mb-8">
|
|
||||||
<ServerHistoryCharts serverId={server.id} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Metrics Charts Section */}
|
|
||||||
{server && (
|
{server && (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<ServerMetricsCharts serverId={server.id} />
|
<ServerHistoryCharts serverId={server.id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,25 +5,25 @@ import { DockerContainer, DockerMetrics, DockerStats } from "@/types/docker.type
|
|||||||
class DockerService {
|
class DockerService {
|
||||||
async getContainers(): Promise<DockerContainer[]> {
|
async getContainers(): Promise<DockerContainer[]> {
|
||||||
try {
|
try {
|
||||||
console.log('Fetching all Docker containers...');
|
// console.log('Fetching all Docker containers...');
|
||||||
const records = await pb.collection('dockers').getFullList({
|
const records = await pb.collection('dockers').getFullList({
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
});
|
});
|
||||||
console.log('Docker containers fetched:', records);
|
// console.log('Docker containers fetched:', records);
|
||||||
return records as DockerContainer[];
|
return records as DockerContainer[];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching Docker containers:', error);
|
// console.error('Error fetching Docker containers:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContainersByServerId(serverId: string): Promise<DockerContainer[]> {
|
async getContainersByServerId(serverId: string): Promise<DockerContainer[]> {
|
||||||
try {
|
try {
|
||||||
console.log('Fetching Docker containers for server ID:', serverId);
|
// console.log('Fetching Docker containers for server ID:', serverId);
|
||||||
|
|
||||||
// First, try to get the server details to find the correct hostname
|
// First, try to get the server details to find the correct hostname
|
||||||
const server = await pb.collection('servers').getOne(serverId);
|
const server = await pb.collection('servers').getOne(serverId);
|
||||||
console.log('Server details:', server);
|
// console.log('Server details:', server);
|
||||||
|
|
||||||
// Try multiple filter approaches to find containers
|
// Try multiple filter approaches to find containers
|
||||||
const filters = [
|
const filters = [
|
||||||
@@ -36,57 +36,57 @@ class DockerService {
|
|||||||
let containers: DockerContainer[] = [];
|
let containers: DockerContainer[] = [];
|
||||||
|
|
||||||
for (const filter of filters) {
|
for (const filter of filters) {
|
||||||
console.log('Trying filter:', filter);
|
// console.log('Trying filter:', filter);
|
||||||
try {
|
try {
|
||||||
const records = await pb.collection('dockers').getFullList({
|
const records = await pb.collection('dockers').getFullList({
|
||||||
filter: filter,
|
filter: filter,
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
});
|
});
|
||||||
console.log(`Filter "${filter}" returned:`, records);
|
// console.log(`Filter "${filter}" returned:`, records);
|
||||||
|
|
||||||
if (records.length > 0) {
|
if (records.length > 0) {
|
||||||
containers = records as DockerContainer[];
|
containers = records as DockerContainer[];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (filterError) {
|
} catch (filterError) {
|
||||||
console.warn(`Filter "${filter}" failed:`, filterError);
|
// console.warn(`Filter "${filter}" failed:`, filterError);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no containers found with filters, get all and log for debugging
|
// If no containers found with filters, get all and log for debugging
|
||||||
if (containers.length === 0) {
|
if (containers.length === 0) {
|
||||||
console.log('No containers found with filters, fetching all for debugging...');
|
// console.log('No containers found with filters, fetching all for debugging...');
|
||||||
const allContainers = await pb.collection('dockers').getFullList({
|
const allContainers = await pb.collection('dockers').getFullList({
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
});
|
});
|
||||||
console.log('All available Docker containers:', allContainers);
|
// console.log('All available Docker containers:', allContainers);
|
||||||
console.log('Looking for containers that might match server:', {
|
// console.log('Looking for containers that might match server:', {
|
||||||
serverId,
|
//// serverId,
|
||||||
serverHostname: server.hostname,
|
// serverHostname: server.hostname,
|
||||||
serverIp: server.ip_address
|
// serverIp: server.ip_address
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
|
|
||||||
return containers;
|
return containers;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching Docker containers by server ID:', error);
|
// console.error('Error fetching Docker containers by server ID:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContainerMetrics(dockerId: string): Promise<DockerMetrics[]> {
|
async getContainerMetrics(dockerId: string): Promise<DockerMetrics[]> {
|
||||||
try {
|
try {
|
||||||
console.log('Fetching metrics for docker ID:', dockerId);
|
// console.log('Fetching metrics for docker ID:', dockerId);
|
||||||
const records = await pb.collection('docker_metrics').getFullList({
|
const records = await pb.collection('docker_metrics').getFullList({
|
||||||
filter: `docker_id = "${dockerId}"`,
|
filter: `docker_id = "${dockerId}"`,
|
||||||
sort: '-timestamp',
|
sort: '-timestamp',
|
||||||
perPage: 100,
|
perPage: 100,
|
||||||
});
|
});
|
||||||
console.log('Docker metrics fetched:', records);
|
// console.log('Docker metrics fetched:', records);
|
||||||
return records as DockerMetrics[];
|
return records as DockerMetrics[];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching Docker metrics:', error);
|
// console.error('Error fetching Docker metrics:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export async function pauseMonitoring(serviceId: string): Promise<void> {
|
|||||||
if (intervalId) {
|
if (intervalId) {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
monitoringIntervals.delete(serviceId);
|
monitoringIntervals.delete(serviceId);
|
||||||
console.log(`Monitoring paused for service ${serviceId}`);
|
// console.log(`Monitoring paused for service ${serviceId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current timestamp formatted as a string
|
// Get current timestamp formatted as a string
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { pb } from '@/lib/pocketbase';
|
|||||||
import { monitoringIntervals } from '../monitoringIntervals';
|
import { monitoringIntervals } from '../monitoringIntervals';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start monitoring for a specific service
|
* Start monitoring for a specific service with optimized performance
|
||||||
*/
|
*/
|
||||||
export async function startMonitoringService(serviceId: string): Promise<void> {
|
export async function startMonitoringService(serviceId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -22,7 +22,7 @@ export async function startMonitoringService(serviceId: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log(`Starting monitoring for service ${serviceId} (${service.name})`);
|
// console.log(`Starting optimized monitoring for service ${serviceId} (${service.name})`);
|
||||||
|
|
||||||
// Update the service status to active/up in the database
|
// Update the service status to active/up in the database
|
||||||
await pb.collection('services').update(serviceId, {
|
await pb.collection('services').update(serviceId, {
|
||||||
@@ -30,19 +30,23 @@ export async function startMonitoringService(serviceId: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The actual service checking is now handled by the Go microservice
|
// The actual service checking is now handled by the Go microservice
|
||||||
// This frontend service just tracks the monitoring state
|
// This frontend service just tracks the monitoring state with much less frequency
|
||||||
const intervalMs = (service.heartbeat_interval || 60) * 1000;
|
const intervalMs = Math.max((service.heartbeat_interval || 60) * 1000, 60000); // Minimum 1 minute
|
||||||
// console.log(`Service ${service.name} monitoring delegated to backend service`);
|
// console.log(`Service ${service.name} monitoring delegated to backend service with interval ${intervalMs}ms`);
|
||||||
|
|
||||||
// Store a placeholder interval to track that this service is being monitored
|
// Store a placeholder interval to track that this service is being monitored
|
||||||
|
// Significantly reduce frequency to prevent excessive logging and CPU usage
|
||||||
const intervalId = window.setInterval(() => {
|
const intervalId = window.setInterval(() => {
|
||||||
|
// Only log every 5 minutes to reduce console spam
|
||||||
|
if (Date.now() % (5 * 60 * 1000) < intervalMs) {
|
||||||
// console.log(`Monitoring active for service ${service.name} (handled by backend)`);
|
// console.log(`Monitoring active for service ${service.name} (handled by backend)`);
|
||||||
}, intervalMs);
|
}
|
||||||
|
}, Math.max(intervalMs, 300000)); // Minimum 5 minutes for logging
|
||||||
|
|
||||||
// Store the interval ID for this service
|
// Store the interval ID for this service
|
||||||
monitoringIntervals.set(serviceId, intervalId);
|
monitoringIntervals.set(serviceId, intervalId);
|
||||||
|
|
||||||
// console.log(`Monitoring registered for service ${serviceId}`);
|
// console.log(`Optimized monitoring registered for service ${serviceId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("Error starting service monitoring:", error);
|
// console.error("Error starting service monitoring:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const operationalPageService = {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
return result.items || [];
|
return result.items || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching operational pages:', error);
|
// console.error('Error fetching operational pages:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -55,7 +55,7 @@ export const operationalPageService = {
|
|||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching operational page:', error);
|
// console.error('Error fetching operational page:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -84,7 +84,7 @@ export const operationalPageService = {
|
|||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating operational page:', error);
|
// console.error('Error updating operational page:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -113,7 +113,7 @@ export const operationalPageService = {
|
|||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating operational page:', error);
|
// console.error('Error creating operational page:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -139,7 +139,7 @@ export const operationalPageService = {
|
|||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting operational page:', error);
|
// console.error('Error deleting operational page:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,183 +24,144 @@ export const serverService = {
|
|||||||
|
|
||||||
async getServerMetrics(serverId: string, timeRange?: string): Promise<any[]> {
|
async getServerMetrics(serverId: string, timeRange?: string): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
// console.log('serverService.getServerMetrics: Fetching metrics for serverId:', serverId, 'timeRange:', timeRange);
|
// console.log('🔍 serverService.getServerMetrics: Starting with serverId:', serverId, 'timeRange:', timeRange);
|
||||||
|
|
||||||
// First, get the server to find the correct server_id for metrics
|
// First, get the server to find the correct server_id for metrics
|
||||||
let server;
|
let server;
|
||||||
try {
|
try {
|
||||||
server = await this.getServer(serverId);
|
server = await this.getServer(serverId);
|
||||||
// console.log('serverService.getServerMetrics: Found server:', server);
|
// console.log('✅ serverService.getServerMetrics: Found server:', {
|
||||||
|
// id: server.id,
|
||||||
|
// server_id: server.server_id,
|
||||||
|
// name: server.name
|
||||||
|
// });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.log('serverService.getServerMetrics: Could not fetch server details:', error);
|
// console.log('❌ serverService.getServerMetrics: Could not fetch server details:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try multiple filter strategies to find data
|
// Let's first check what data exists in the database for this server
|
||||||
let filter = '';
|
// console.log('🔍 Checking all records for this server...');
|
||||||
let metricsServerId = serverId;
|
const allServerRecords = await pb.collection('server_metrics').getFullList({
|
||||||
|
filter: `server_id = "${serverId}" || server_id = "${server?.server_id}" || server_id = "${server?.id}"`,
|
||||||
|
sort: '-created',
|
||||||
|
requestKey: null
|
||||||
|
});
|
||||||
|
|
||||||
// Strategy 1: Use server.server_id if available
|
// console.log('📊 Found total records for server:', allServerRecords.length);
|
||||||
if (server && server.server_id) {
|
if (allServerRecords.length > 0) {
|
||||||
metricsServerId = server.server_id;
|
// console.log('📅 Date range of all records:', {
|
||||||
filter = `server_id = "${metricsServerId}"`;
|
// newest: allServerRecords[0]?.created,
|
||||||
// console.log('serverService.getServerMetrics: Strategy 1 - Using server.server_id for metrics:', metricsServerId);
|
// oldest: allServerRecords[allServerRecords.length - 1]?.created
|
||||||
} else {
|
// });
|
||||||
// Strategy 2: Use the serverId directly
|
|
||||||
filter = `server_id = "${serverId}"`;
|
// Check last 5 records
|
||||||
// console.log('serverService.getServerMetrics: Strategy 2 - Using serverId directly for metrics:', serverId);
|
// console.log('🔄 Last 5 records timestamps:', allServerRecords.slice(0, 5).map(r => ({
|
||||||
|
// created: r.created,
|
||||||
|
// age_minutes: Math.round((new Date().getTime() - new Date(r.created).getTime()) / (1000 * 60))
|
||||||
|
// })));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add agent_id filter if available in server data
|
// Calculate time range for filtering
|
||||||
if (server && server.agent_id) {
|
|
||||||
filter += ` && agent_id = "${server.agent_id}"`;
|
|
||||||
// console.log('serverService.getServerMetrics: Added agent_id filter:', server.agent_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add time range filter
|
|
||||||
if (timeRange) {
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
let cutoffTime;
|
let cutoffTime;
|
||||||
|
|
||||||
switch (timeRange) {
|
if (timeRange === '60m') {
|
||||||
case '60m':
|
cutoffTime = new Date(now.getTime() - (60 * 60 * 1000)); // Exactly 60 minutes
|
||||||
cutoffTime = new Date(now.getTime() - (60 * 60 * 1000));
|
// console.log('⏰ 60m filter: Looking for records newer than:', cutoffTime.toISOString());
|
||||||
break;
|
// console.log('⏰ Current time:', now.toISOString());
|
||||||
case '1d':
|
// console.log('⏰ Time difference in minutes:', Math.round((now.getTime() - cutoffTime.getTime()) / (1000 * 60)));
|
||||||
|
} else if (timeRange === '1d') {
|
||||||
cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000));
|
cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000));
|
||||||
break;
|
} else if (timeRange === '7d') {
|
||||||
case '7d':
|
|
||||||
cutoffTime = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000));
|
cutoffTime = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000));
|
||||||
break;
|
} else if (timeRange === '1m') {
|
||||||
case '1m':
|
|
||||||
cutoffTime = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000));
|
cutoffTime = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000));
|
||||||
break;
|
} else if (timeRange === '3m') {
|
||||||
case '3m':
|
|
||||||
cutoffTime = new Date(now.getTime() - (90 * 24 * 60 * 60 * 1000));
|
cutoffTime = new Date(now.getTime() - (90 * 24 * 60 * 60 * 1000));
|
||||||
break;
|
} else {
|
||||||
default:
|
|
||||||
cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000));
|
cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to get filtered records
|
||||||
|
const searchStrategies = [
|
||||||
|
server?.server_id,
|
||||||
|
serverId,
|
||||||
|
server?.id
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
let filteredRecords: any[] = [];
|
||||||
|
|
||||||
|
for (const strategy of searchStrategies) {
|
||||||
|
try {
|
||||||
const cutoffISO = cutoffTime.toISOString();
|
const cutoffISO = cutoffTime.toISOString();
|
||||||
filter += ` && created >= "${cutoffISO}"`;
|
const filter = `server_id = "${strategy}" && created >= "${cutoffISO}"`;
|
||||||
// console.log('serverService.getServerMetrics: Using time filter from:', cutoffISO, 'to now');
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log('serverService.getServerMetrics: Final filter:', filter);
|
// console.log(`🔍 Trying filter: ${filter}`);
|
||||||
|
|
||||||
// Fetch filtered records with proper sorting
|
const records = await pb.collection('server_metrics').getFullList({
|
||||||
let records = await pb.collection('server_metrics').getFullList({
|
|
||||||
filter: filter,
|
filter: filter,
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
requestKey: null
|
requestKey: null
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log('serverService.getServerMetrics: Found', records.length, 'records with primary filter');
|
// console.log(`📊 Strategy "${strategy}" found ${records.length} records within time range`);
|
||||||
|
|
||||||
// If no records found with primary strategy, try fallback strategies
|
|
||||||
if (records.length === 0) {
|
|
||||||
// console.log('serverService.getServerMetrics: No records found, trying fallback strategies...');
|
|
||||||
|
|
||||||
// Fallback 1: Try without agent_id filter
|
|
||||||
let fallbackFilter = `server_id = "${metricsServerId}"`;
|
|
||||||
if (timeRange) {
|
|
||||||
const now = new Date();
|
|
||||||
let cutoffTime;
|
|
||||||
|
|
||||||
switch (timeRange) {
|
|
||||||
case '60m':
|
|
||||||
cutoffTime = new Date(now.getTime() - (60 * 60 * 1000));
|
|
||||||
break;
|
|
||||||
case '1d':
|
|
||||||
cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000));
|
|
||||||
break;
|
|
||||||
case '7d':
|
|
||||||
cutoffTime = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000));
|
|
||||||
break;
|
|
||||||
case '1m':
|
|
||||||
cutoffTime = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000));
|
|
||||||
break;
|
|
||||||
case '3m':
|
|
||||||
cutoffTime = new Date(now.getTime() - (90 * 24 * 60 * 60 * 1000));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
const cutoffISO = cutoffTime.toISOString();
|
|
||||||
fallbackFilter += ` && created >= "${cutoffISO}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log('serverService.getServerMetrics: Trying fallback filter without agent_id:', fallbackFilter);
|
|
||||||
records = await pb.collection('server_metrics').getFullList({
|
|
||||||
filter: fallbackFilter,
|
|
||||||
sort: '-created',
|
|
||||||
requestKey: null
|
|
||||||
});
|
|
||||||
|
|
||||||
// console.log('serverService.getServerMetrics: Fallback found', records.length, 'records');
|
|
||||||
|
|
||||||
// Fallback 2: Try with different server_id strategies
|
|
||||||
if (records.length === 0) {
|
|
||||||
const alternativeIds = [serverId, server?.server_id, server?.id].filter(Boolean);
|
|
||||||
// console.log('serverService.getServerMetrics: Trying alternative server IDs:', alternativeIds);
|
|
||||||
|
|
||||||
for (const altId of alternativeIds) {
|
|
||||||
if (altId && altId !== metricsServerId) {
|
|
||||||
let altFilter = `server_id = "${altId}"`;
|
|
||||||
if (timeRange) {
|
|
||||||
const now = new Date();
|
|
||||||
let cutoffTime;
|
|
||||||
|
|
||||||
switch (timeRange) {
|
|
||||||
case '60m':
|
|
||||||
cutoffTime = new Date(now.getTime() - (60 * 60 * 1000));
|
|
||||||
break;
|
|
||||||
case '1d':
|
|
||||||
cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000));
|
|
||||||
break;
|
|
||||||
case '7d':
|
|
||||||
cutoffTime = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000));
|
|
||||||
break;
|
|
||||||
case '1m':
|
|
||||||
cutoffTime = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000));
|
|
||||||
break;
|
|
||||||
case '3m':
|
|
||||||
cutoffTime = new Date(now.getTime() - (90 * 24 * 60 * 60 * 1000));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
const cutoffISO = cutoffTime.toISOString();
|
|
||||||
altFilter += ` && created >= "${cutoffISO}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log('serverService.getServerMetrics: Trying alternative ID filter:', altFilter);
|
|
||||||
const altRecords = await pb.collection('server_metrics').getFullList({
|
|
||||||
filter: altFilter,
|
|
||||||
sort: '-created',
|
|
||||||
requestKey: null
|
|
||||||
});
|
|
||||||
|
|
||||||
if (altRecords.length > 0) {
|
|
||||||
// console.log('serverService.getServerMetrics: Alternative ID found', altRecords.length, 'records');
|
|
||||||
records = altRecords;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log('serverService.getServerMetrics: Final result:', records.length, 'records found');
|
|
||||||
if (records.length > 0) {
|
if (records.length > 0) {
|
||||||
// console.log('serverService.getServerMetrics: Sample record:', records[0]);
|
filteredRecords = records;
|
||||||
|
// console.log('✅ Using records from strategy:', strategy);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// console.error(`❌ Error with strategy ${strategy}:`, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return records;
|
// If no filtered records found and it's 60m, let's see what we have in a larger window
|
||||||
|
if (filteredRecords.length === 0 && timeRange === '60m') {
|
||||||
|
// console.log('⚠️ No records found in 60m window, checking last 24 hours...');
|
||||||
|
|
||||||
|
const last24h = new Date(now.getTime() - (24 * 60 * 60 * 1000));
|
||||||
|
|
||||||
|
for (const strategy of searchStrategies) {
|
||||||
|
try {
|
||||||
|
const filter = `server_id = "${strategy}" && created >= "${last24h.toISOString()}"`;
|
||||||
|
|
||||||
|
const records = await pb.collection('server_metrics').getFullList({
|
||||||
|
filter: filter,
|
||||||
|
sort: '-created',
|
||||||
|
requestKey: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log(`📊 Last 24h check for "${strategy}": ${records.length} records`);
|
||||||
|
|
||||||
|
if (records.length > 0) {
|
||||||
|
// console.log('📅 Sample record ages (minutes ago):', records.slice(0, 3).map(r =>
|
||||||
|
// Math.round((now.getTime() - new Date(r.created).getTime()) / (1000 * 60))
|
||||||
|
// ));
|
||||||
|
|
||||||
|
// Return all recent records for 60m if we have any
|
||||||
|
filteredRecords = records;
|
||||||
|
break;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('Error fetching server metrics:', error);
|
// console.error(`❌ Error with 24h fallback for ${strategy}:`, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log('🎯 Final result:', filteredRecords.length, 'records found for', timeRange);
|
||||||
|
if (filteredRecords.length > 0) {
|
||||||
|
// console.log('📅 Returned records age range (minutes ago):', {
|
||||||
|
// newest: Math.round((now.getTime() - new Date(filteredRecords[0].created).getTime()) / (1000 * 60)),
|
||||||
|
// oldest: Math.round((now.getTime() - new Date(filteredRecords[filteredRecords.length - 1].created).getTime()) / (1000 * 60))
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredRecords;
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('❌ Error fetching server metrics:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
|
||||||
|
export interface ServerThreshold {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cpu_threshold: number;
|
||||||
|
ram_threshold: number;
|
||||||
|
disk_threshold: number;
|
||||||
|
network_threshold: number;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUpdateServerThresholdData {
|
||||||
|
name: string;
|
||||||
|
cpu_threshold: number;
|
||||||
|
ram_threshold: number;
|
||||||
|
disk_threshold: number;
|
||||||
|
network_threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serverThresholdService = {
|
||||||
|
async getServerThresholds(): Promise<ServerThreshold[]> {
|
||||||
|
try {
|
||||||
|
// console.log("Fetching server threshold templates");
|
||||||
|
const response = await pb.collection('server_threshold_templates').getList(1, 50, {
|
||||||
|
sort: '-created',
|
||||||
|
});
|
||||||
|
// console.log("Server threshold templates response:", response);
|
||||||
|
return response.items as unknown as ServerThreshold[];
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("Error fetching server threshold templates:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getServerThreshold(id: string): Promise<ServerThreshold> {
|
||||||
|
try {
|
||||||
|
// console.log(`Fetching server threshold template with id: ${id}`);
|
||||||
|
const response = await pb.collection('server_threshold_templates').getOne(id);
|
||||||
|
// console.log("Server threshold template response:", response);
|
||||||
|
return response as unknown as ServerThreshold;
|
||||||
|
} catch (error) {
|
||||||
|
// console.error(`Error fetching server threshold template with id ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createServerThreshold(data: CreateUpdateServerThresholdData): Promise<ServerThreshold> {
|
||||||
|
try {
|
||||||
|
// console.log("Creating new server threshold template with data:", data);
|
||||||
|
const response = await pb.collection('server_threshold_templates').create(data);
|
||||||
|
// console.log("Create server threshold template response:", response);
|
||||||
|
return response as unknown as ServerThreshold;
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("Error creating server threshold template:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateServerThreshold(id: string, data: Partial<CreateUpdateServerThresholdData>): Promise<ServerThreshold> {
|
||||||
|
try {
|
||||||
|
// console.log(`Updating server threshold template with id: ${id}`, data);
|
||||||
|
const response = await pb.collection('server_threshold_templates').update(id, data);
|
||||||
|
// console.log("Update server threshold template response:", response);
|
||||||
|
return response as unknown as ServerThreshold;
|
||||||
|
} catch (error) {
|
||||||
|
// console.error(`Error updating server threshold template with id ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteServerThreshold(id: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// console.log(`Deleting server threshold template with id: ${id}`);
|
||||||
|
await pb.collection('server_threshold_templates').delete(id);
|
||||||
|
// console.log("Server threshold template deleted successfully");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// console.error(`Error deleting server threshold template with id ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,120 +1,53 @@
|
|||||||
|
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
import { SSLCertificate } from "../types";
|
import { SSLCertificate } from "@/types/ssl.types";
|
||||||
import { determineSSLStatus } from "../sslStatusUtils";
|
|
||||||
import { sendSSLNotification } from "./sslNotificationSender";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks all SSL certificates and sends notifications for expiring ones
|
* Check a single SSL certificate - notifications are now handled by the backend
|
||||||
* This should be called once per day
|
|
||||||
*/
|
*/
|
||||||
export async function checkAllCertificatesAndNotify(): Promise<void> {
|
export const checkCertificateAndNotify = async (certificate: SSLCertificate): Promise<void> => {
|
||||||
// console.log("Starting daily SSL certificates check...");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch all SSL certificates from database
|
// console.log(`Checking certificate for ${certificate.domain}...`);
|
||||||
const response = await pb.collection('ssl_certificates').getList(1, 100, {});
|
|
||||||
// Properly cast the items as SSLCertificate
|
// The actual SSL checking and notifications are now handled by the Go service
|
||||||
|
// We just need to trigger a check by updating the check_at timestamp
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Update check_at to trigger backend check
|
||||||
|
await pb.collection('ssl_certificates').update(certificate.id, {
|
||||||
|
check_at: now.toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check all SSL certificates - backend handles the actual checking and notifications
|
||||||
|
*/
|
||||||
|
export const checkAllCertificatesAndNotify = async (): Promise<{ success: number; failed: number }> => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const response = await pb.collection('ssl_certificates').getList(1, 100);
|
||||||
const certificates = response.items as unknown as SSLCertificate[];
|
const certificates = response.items as unknown as SSLCertificate[];
|
||||||
|
|
||||||
// console.log(`Found ${certificates.length} certificates to check`);
|
let success = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
// Check each certificate
|
|
||||||
for (const cert of certificates) {
|
for (const cert of certificates) {
|
||||||
await checkCertificateAndNotify(cert);
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log("Daily SSL certificates check completed");
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("Error during SSL certificates daily check:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks a specific SSL certificate and sends notification if needed
|
|
||||||
* This respects the Warning and Expiry Thresholds set on the certificate
|
|
||||||
* Note: SSL checking is now handled by the Go service, this function focuses on notifications
|
|
||||||
*/
|
|
||||||
export async function checkCertificateAndNotify(certificate: SSLCertificate): Promise<boolean> {
|
|
||||||
// console.log(`Checking certificate for ${certificate.domain}...`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the current certificate data (updated by Go service)
|
await checkCertificateAndNotify(cert);
|
||||||
const daysLeft = certificate.days_left || 0;
|
success++;
|
||||||
|
|
||||||
// Get threshold values (ensure they are numbers)
|
|
||||||
const warningThreshold = Number(certificate.warning_threshold) || 30;
|
|
||||||
const expiryThreshold = Number(certificate.expiry_threshold) || 7;
|
|
||||||
|
|
||||||
// console.log(`Certificate ${certificate.domain} thresholds: warning=${warningThreshold}, expiry=${expiryThreshold}, days left=${daysLeft}`);
|
|
||||||
|
|
||||||
// Update status based on thresholds
|
|
||||||
const status = determineSSLStatus(daysLeft, warningThreshold, expiryThreshold);
|
|
||||||
|
|
||||||
// Check if we should send a notification based on thresholds
|
|
||||||
let shouldNotify = false;
|
|
||||||
let isCritical = false;
|
|
||||||
|
|
||||||
// Critical notifications - when days left is less than or equal to expiry threshold
|
|
||||||
if (daysLeft <= expiryThreshold) {
|
|
||||||
shouldNotify = true;
|
|
||||||
isCritical = true;
|
|
||||||
}
|
|
||||||
// Warning notifications - when days left is less than or equal to warning threshold but greater than expiry threshold
|
|
||||||
else if (daysLeft <= warningThreshold) {
|
|
||||||
shouldNotify = true;
|
|
||||||
isCritical = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log(`${certificate.domain}: ${daysLeft} days left, status: ${status}, should notify: ${shouldNotify}, critical: ${isCritical}`);
|
|
||||||
|
|
||||||
// Update certificate status in database
|
|
||||||
await pb.collection('ssl_certificates').update(certificate.id, {
|
|
||||||
status: status
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send notification if needed
|
|
||||||
if (shouldNotify && certificate.notification_channel) {
|
|
||||||
// console.log(`Sending notification for ${certificate.domain}`);
|
|
||||||
|
|
||||||
// Different message based on expiry threshold
|
|
||||||
const message = isCritical
|
|
||||||
? `🚨 CRITICAL: SSL Certificate for ${certificate.domain} will expire in ${daysLeft} days!`
|
|
||||||
: `⚠️ WARNING: SSL Certificate for ${certificate.domain} will expire in ${daysLeft} days.`;
|
|
||||||
|
|
||||||
// Send the notification using our specialized SSL notification sender
|
|
||||||
const notificationSent = await sendSSLNotification(certificate, message, isCritical);
|
|
||||||
|
|
||||||
if (notificationSent) {
|
|
||||||
// Update last_notified timestamp
|
|
||||||
await pb.collection('ssl_certificates').update(certificate.id, {
|
|
||||||
last_notified: new Date().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
// console.log(`Notification sent for ${certificate.domain}`);
|
|
||||||
// Show toast for manual checks
|
|
||||||
toast.success(`Notification sent for ${certificate.domain}`);
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
// console.error(`Failed to send notification for ${certificate.domain}`);
|
|
||||||
// Show error toast for manual checks
|
|
||||||
toast.error(`Failed to send notification for ${certificate.domain}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else if (shouldNotify && !certificate.notification_channel) {
|
|
||||||
// console.log(`No notification channel set for ${certificate.domain}, skipping notification`);
|
|
||||||
toast.info(`No notification channel set for ${certificate.domain}, skipping notification`);
|
|
||||||
} else {
|
|
||||||
// console.log(`No notification needed for ${certificate.domain} (${daysLeft} days left)`);
|
|
||||||
// For manual checks, inform the user that thresholds weren't met
|
|
||||||
toast.info(`Certificate for ${certificate.domain} is valid (${daysLeft} days left)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error(`Error checking certificate for ${certificate.domain}:`, error);
|
failed++;
|
||||||
toast.error(`Error checking certificate: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { success, failed };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -27,7 +27,7 @@ export const statusPageComponentsService = {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
return result.items || [];
|
return result.items || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching status page components:', error);
|
// console.error('Error fetching status page components:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -56,7 +56,7 @@ export const statusPageComponentsService = {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
return result.items || [];
|
return result.items || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching status page components by operational ID:', error);
|
// console.error('Error fetching status page components by operational ID:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -79,7 +79,7 @@ export const statusPageComponentsService = {
|
|||||||
headers['Authorization'] = `Bearer ${authToken}`;
|
headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Creating status page component with data:', data);
|
// console.log('Creating status page component with data:', data);
|
||||||
|
|
||||||
const baseUrl = getCurrentEndpoint();
|
const baseUrl = getCurrentEndpoint();
|
||||||
const response = await fetch(`${baseUrl}/api/collections/status_page_components/records`, {
|
const response = await fetch(`${baseUrl}/api/collections/status_page_components/records`, {
|
||||||
@@ -95,10 +95,10 @@ export const statusPageComponentsService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('Created component:', result);
|
// console.log('Created component:', result);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating status page component:', error);
|
// console.error('Error creating status page component:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -124,7 +124,7 @@ export const statusPageComponentsService = {
|
|||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting status page component:', error);
|
// console.error('Error deleting status page component:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
|
|
||||||
|
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
|
||||||
export interface NotificationTemplate {
|
export interface NotificationTemplate {
|
||||||
@@ -37,62 +38,62 @@ export interface CreateUpdateTemplateData {
|
|||||||
export const templateService = {
|
export const templateService = {
|
||||||
async getTemplates(): Promise<NotificationTemplate[]> {
|
async getTemplates(): Promise<NotificationTemplate[]> {
|
||||||
try {
|
try {
|
||||||
console.log("Fetching notification templates");
|
// console.log("Fetching server notification templates");
|
||||||
const response = await pb.collection('notification_templates').getList(1, 50, {
|
const response = await pb.collection('server_notification_templates').getList(1, 50, {
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
});
|
});
|
||||||
console.log("Templates response:", response);
|
// console.log("Server templates response:", response);
|
||||||
return response.items as unknown as NotificationTemplate[];
|
return response.items as unknown as NotificationTemplate[];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching templates:", error);
|
// console.error("Error fetching server templates:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getTemplate(id: string): Promise<NotificationTemplate> {
|
async getTemplate(id: string): Promise<NotificationTemplate> {
|
||||||
try {
|
try {
|
||||||
console.log(`Fetching template with id: ${id}`);
|
// console.log(`Fetching server template with id: ${id}`);
|
||||||
const response = await pb.collection('notification_templates').getOne(id);
|
const response = await pb.collection('server_notification_templates').getOne(id);
|
||||||
console.log("Template response:", response);
|
// console.log("Server template response:", response);
|
||||||
return response as unknown as NotificationTemplate;
|
return response as unknown as NotificationTemplate;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching template with id ${id}:`, error);
|
// console.error(`Error fetching server template with id ${id}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async createTemplate(data: CreateUpdateTemplateData): Promise<NotificationTemplate> {
|
async createTemplate(data: CreateUpdateTemplateData): Promise<NotificationTemplate> {
|
||||||
try {
|
try {
|
||||||
console.log("Creating new template with data:", data);
|
// console.log("Creating new server template with data:", data);
|
||||||
const response = await pb.collection('notification_templates').create(data);
|
const response = await pb.collection('server_notification_templates').create(data);
|
||||||
console.log("Create template response:", response);
|
// console.log("Create server template response:", response);
|
||||||
return response as unknown as NotificationTemplate;
|
return response as unknown as NotificationTemplate;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating template:", error);
|
// console.error("Error creating server template:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateTemplate(id: string, data: Partial<CreateUpdateTemplateData>): Promise<NotificationTemplate> {
|
async updateTemplate(id: string, data: Partial<CreateUpdateTemplateData>): Promise<NotificationTemplate> {
|
||||||
try {
|
try {
|
||||||
console.log(`Updating template with id: ${id}`, data);
|
// console.log(`Updating server template with id: ${id}`, data);
|
||||||
const response = await pb.collection('notification_templates').update(id, data);
|
const response = await pb.collection('server_notification_templates').update(id, data);
|
||||||
console.log("Update template response:", response);
|
// console.log("Update server template response:", response);
|
||||||
return response as unknown as NotificationTemplate;
|
return response as unknown as NotificationTemplate;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error updating template with id ${id}:`, error);
|
// console.error(`Error updating server template with id ${id}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteTemplate(id: string): Promise<boolean> {
|
async deleteTemplate(id: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
console.log(`Deleting template with id: ${id}`);
|
// console.log(`Deleting server template with id: ${id}`);
|
||||||
await pb.collection('notification_templates').delete(id);
|
await pb.collection('server_notification_templates').delete(id);
|
||||||
console.log("Template deleted successfully");
|
// console.log("Server template deleted successfully");
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error deleting template with id ${id}:`, error);
|
// console.error(`Error deleting server template with id ${id}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface Server {
|
|||||||
hostname: string;
|
hostname: string;
|
||||||
ip_address: string;
|
ip_address: string;
|
||||||
os_type: string;
|
os_type: string;
|
||||||
status: 'up' | 'down' | 'warning';
|
status: 'up' | 'down' | 'warning' | 'paused';
|
||||||
uptime: string;
|
uptime: string;
|
||||||
ram_total: number;
|
ram_total: number;
|
||||||
ram_used: number;
|
ram_used: number;
|
||||||
@@ -19,6 +19,7 @@ export interface Server {
|
|||||||
last_checked: string;
|
last_checked: string;
|
||||||
server_token: string;
|
server_token: string;
|
||||||
template_id: string;
|
template_id: string;
|
||||||
|
threshold_id: string;
|
||||||
notification_id: string;
|
notification_id: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
connection: string;
|
connection: string;
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
export const copyToClipboard = async (text: string) => {
|
||||||
|
console.log('copyToClipboard called with text:', text); // Debug log
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try modern clipboard API first
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
console.log('Using modern clipboard API'); // Debug log
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast({
|
||||||
|
title: "Copied!",
|
||||||
|
description: "Content copied to clipboard successfully.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Using fallback clipboard method'); // Debug log
|
||||||
|
|
||||||
|
// Fallback for older browsers or non-secure contexts
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.style.position = "fixed";
|
||||||
|
textArea.style.left = "-9999px";
|
||||||
|
textArea.style.top = "-9999px";
|
||||||
|
textArea.style.opacity = "0";
|
||||||
|
textArea.style.pointerEvents = "none";
|
||||||
|
textArea.style.zIndex = "-1";
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
|
||||||
|
// Focus and select the text
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
textArea.setSelectionRange(0, textArea.value.length);
|
||||||
|
|
||||||
|
// Use execCommand as fallback
|
||||||
|
const successful = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
|
||||||
|
if (successful) {
|
||||||
|
console.log('Copy successful with execCommand'); // Debug log
|
||||||
|
toast({
|
||||||
|
title: "Copied!",
|
||||||
|
description: "Content copied to clipboard successfully.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('Copy command failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy to clipboard:', error);
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
toast({
|
||||||
|
title: "Copy Failed",
|
||||||
|
description: "Unable to copy automatically. Please select and copy the text manually.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# CheckCle Server Monitoring Agent - One-Click Installation Script
|
||||||
|
# This script provides fully automated installation using environment variables
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CONFIG_FILE="/etc/monitoring-agent/monitoring-agent.env"
|
||||||
|
|
||||||
|
# GitHub release base URL
|
||||||
|
GITHUB_BASE_URL="https://github.com/operacle/checke-server-agent/releases/download/v1.0.0"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Helper functions
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
check_root() {
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
log_error "This script must be run as root (use sudo)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect system architecture and package format
|
||||||
|
detect_system() {
|
||||||
|
log_info "Detecting system architecture and package format..."
|
||||||
|
|
||||||
|
# Detect architecture
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
case $ARCH in
|
||||||
|
x86_64)
|
||||||
|
PACKAGE_ARCH="amd64"
|
||||||
|
;;
|
||||||
|
aarch64|arm64)
|
||||||
|
PACKAGE_ARCH="arm64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "Unsupported architecture: $ARCH"
|
||||||
|
log_info "Supported architectures: x86_64 (amd64), aarch64/arm64"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Detect package format preference
|
||||||
|
if command -v dpkg >/dev/null 2>&1; then
|
||||||
|
PACKAGE_FORMAT="deb"
|
||||||
|
PACKAGE_MANAGER="dpkg"
|
||||||
|
elif command -v rpm >/dev/null 2>&1; then
|
||||||
|
PACKAGE_FORMAT="rpm"
|
||||||
|
if command -v yum >/dev/null 2>&1; then
|
||||||
|
PACKAGE_MANAGER="yum"
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
PACKAGE_MANAGER="dnf"
|
||||||
|
else
|
||||||
|
PACKAGE_MANAGER="rpm"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_error "No supported package manager found (dpkg or rpm required)"
|
||||||
|
log_info "This script supports Debian/Ubuntu (.deb) and RHEL/CentOS/Fedora (.rpm) systems"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Construct package filename and URL
|
||||||
|
PACKAGE_FILENAME="monitoring-agent_1.0.0_${PACKAGE_ARCH}.${PACKAGE_FORMAT}"
|
||||||
|
PACKAGE_URL="${GITHUB_BASE_URL}/${PACKAGE_FILENAME}"
|
||||||
|
|
||||||
|
log_success "System detection complete:"
|
||||||
|
log_info " Architecture: $ARCH -> $PACKAGE_ARCH"
|
||||||
|
log_info " Package format: $PACKAGE_FORMAT"
|
||||||
|
log_info " Package manager: $PACKAGE_MANAGER"
|
||||||
|
log_info " Package URL: $PACKAGE_URL"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate required environment variables - check both current and sudo environments
|
||||||
|
validate_environment() {
|
||||||
|
# Check if SERVER_TOKEN is available in current environment or passed as argument
|
||||||
|
if [[ -z "$SERVER_TOKEN" ]]; then
|
||||||
|
log_error "SERVER_TOKEN environment variable is required"
|
||||||
|
log_info "Usage examples:"
|
||||||
|
log_info " SERVER_TOKEN=your-token sudo -E bash $0"
|
||||||
|
log_info " sudo SERVER_TOKEN=your-token bash $0"
|
||||||
|
log_info " curl -L script-url | SERVER_TOKEN=your-token sudo bash"
|
||||||
|
log_info ""
|
||||||
|
log_info "Required: SERVER_TOKEN"
|
||||||
|
log_info "Optional: POCKETBASE_URL, SERVER_NAME, AGENT_ID, HEALTH_CHECK_PORT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Environment validation passed"
|
||||||
|
log_info "SERVER_TOKEN: ${SERVER_TOKEN:0:8}..."
|
||||||
|
[[ -n "$POCKETBASE_URL" ]] && log_info "POCKETBASE_URL: $POCKETBASE_URL"
|
||||||
|
[[ -n "$SERVER_NAME" ]] && log_info "SERVER_NAME: $SERVER_NAME"
|
||||||
|
[[ -n "$AGENT_ID" ]] && log_info "AGENT_ID: $AGENT_ID"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Download package based on detected system
|
||||||
|
download_package() {
|
||||||
|
local temp_dir="/tmp/monitoring-agent-install"
|
||||||
|
mkdir -p "$temp_dir"
|
||||||
|
|
||||||
|
log_info "Downloading monitoring agent package..."
|
||||||
|
log_info "URL: $PACKAGE_URL"
|
||||||
|
|
||||||
|
if curl -L -f -o "$temp_dir/$PACKAGE_FILENAME" "$PACKAGE_URL"; then
|
||||||
|
DOWNLOADED_PACKAGE="$temp_dir/$PACKAGE_FILENAME"
|
||||||
|
log_success "Package downloaded successfully: $PACKAGE_FILENAME"
|
||||||
|
else
|
||||||
|
log_error "Failed to download package from: $PACKAGE_URL"
|
||||||
|
log_info "Please check:"
|
||||||
|
log_info " 1. Internet connectivity"
|
||||||
|
log_info " 2. Package availability for your architecture ($PACKAGE_ARCH)"
|
||||||
|
log_info " 3. GitHub repository access"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install package based on detected package manager
|
||||||
|
install_package() {
|
||||||
|
log_info "Installing monitoring agent package using $PACKAGE_MANAGER..."
|
||||||
|
|
||||||
|
case $PACKAGE_MANAGER in
|
||||||
|
dpkg)
|
||||||
|
# Update package lists
|
||||||
|
apt-get update -qq
|
||||||
|
|
||||||
|
# Install the package
|
||||||
|
if dpkg -i "$DOWNLOADED_PACKAGE" 2>/dev/null; then
|
||||||
|
log_success "DEB package installed successfully"
|
||||||
|
else
|
||||||
|
log_warning "Package installation had dependency issues, fixing..."
|
||||||
|
apt-get install -f -y
|
||||||
|
log_success "Dependencies resolved and package installed"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
rpm)
|
||||||
|
# Install the package directly
|
||||||
|
if rpm -ivh "$DOWNLOADED_PACKAGE" 2>/dev/null; then
|
||||||
|
log_success "RPM package installed successfully"
|
||||||
|
else
|
||||||
|
log_error "RPM package installation failed"
|
||||||
|
log_info "Try installing manually: sudo rpm -ivh $DOWNLOADED_PACKAGE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
yum)
|
||||||
|
if yum localinstall -y "$DOWNLOADED_PACKAGE"; then
|
||||||
|
log_success "Package installed successfully via YUM"
|
||||||
|
else
|
||||||
|
log_error "YUM package installation failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
dnf)
|
||||||
|
if dnf localinstall -y "$DOWNLOADED_PACKAGE"; then
|
||||||
|
log_success "Package installed successfully via DNF"
|
||||||
|
else
|
||||||
|
log_error "DNF package installation failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
log_error "Unsupported package manager: $PACKAGE_MANAGER"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auto-detect system information
|
||||||
|
detect_system_info() {
|
||||||
|
log_info "Auto-detecting system information..."
|
||||||
|
|
||||||
|
# Detect hostname
|
||||||
|
DETECTED_HOSTNAME=$(hostname)
|
||||||
|
log_info "Detected hostname: $DETECTED_HOSTNAME"
|
||||||
|
|
||||||
|
# Detect IP address
|
||||||
|
DETECTED_IP=$(ip route get 8.8.8.8 2>/dev/null | head -1 | awk '{print $7}' | head -1)
|
||||||
|
if [[ -z "$DETECTED_IP" ]]; then
|
||||||
|
DETECTED_IP=$(hostname -I | awk '{print $1}')
|
||||||
|
fi
|
||||||
|
log_info "Detected IP address: $DETECTED_IP"
|
||||||
|
|
||||||
|
# Detect OS
|
||||||
|
if [[ -f /etc/os-release ]]; then
|
||||||
|
DETECTED_OS=$(grep "^ID=" /etc/os-release | cut -d'=' -f2 | tr -d '"')
|
||||||
|
else
|
||||||
|
DETECTED_OS="linux"
|
||||||
|
fi
|
||||||
|
log_info "Detected OS: $DETECTED_OS"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configure agent using environment variables
|
||||||
|
configure_agent() {
|
||||||
|
log_info "Configuring agent with provided settings..."
|
||||||
|
|
||||||
|
# Use environment variables or auto-detected defaults
|
||||||
|
SERVER_NAME="${SERVER_NAME:-$DETECTED_HOSTNAME}"
|
||||||
|
POCKETBASE_URL="${POCKETBASE_URL:-http://localhost:8090}"
|
||||||
|
IP_ADDRESS="${IP_ADDRESS:-$DETECTED_IP}"
|
||||||
|
HOSTNAME="${HOSTNAME:-$DETECTED_HOSTNAME}"
|
||||||
|
OS_TYPE="${OS_TYPE:-$DETECTED_OS}"
|
||||||
|
AGENT_ID="${AGENT_ID:-monitoring-agent-$(hostname -s)}"
|
||||||
|
HEALTH_CHECK_PORT="${HEALTH_CHECK_PORT:-8081}"
|
||||||
|
|
||||||
|
log_info "Final configuration:"
|
||||||
|
log_info " Server Name: $SERVER_NAME"
|
||||||
|
log_info " Agent ID: $AGENT_ID"
|
||||||
|
log_info " PB API URL: $POCKETBASE_URL"
|
||||||
|
log_info " IP Address: $IP_ADDRESS"
|
||||||
|
log_info " OS Type: $OS_TYPE"
|
||||||
|
log_info " Health Check Port: $HEALTH_CHECK_PORT"
|
||||||
|
|
||||||
|
write_config
|
||||||
|
}
|
||||||
|
|
||||||
|
# Write configuration to file
|
||||||
|
write_config() {
|
||||||
|
log_info "Writing configuration to $CONFIG_FILE..."
|
||||||
|
|
||||||
|
# Create directory if it doesn't exist
|
||||||
|
mkdir -p "$(dirname "$CONFIG_FILE")"
|
||||||
|
|
||||||
|
cat > "$CONFIG_FILE" << EOF
|
||||||
|
# CheckCle Server Monitoring Agent Configuration
|
||||||
|
# Generated on $(date)
|
||||||
|
|
||||||
|
# Basic Configuration
|
||||||
|
AGENT_ID=$AGENT_ID
|
||||||
|
CHECK_INTERVAL=30s
|
||||||
|
HEALTH_CHECK_PORT=$HEALTH_CHECK_PORT
|
||||||
|
|
||||||
|
# PocketBase Configuration
|
||||||
|
POCKETBASE_ENABLED=true
|
||||||
|
POCKETBASE_URL=$POCKETBASE_URL
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
SERVER_NAME=$SERVER_NAME
|
||||||
|
HOSTNAME=$HOSTNAME
|
||||||
|
IP_ADDRESS=$IP_ADDRESS
|
||||||
|
OS_TYPE=$OS_TYPE
|
||||||
|
SERVER_TOKEN=$SERVER_TOKEN
|
||||||
|
|
||||||
|
# Remote Control
|
||||||
|
REMOTE_CONTROL_ENABLED=true
|
||||||
|
COMMAND_CHECK_INTERVAL=10s
|
||||||
|
|
||||||
|
# Monitoring Settings
|
||||||
|
REPORT_INTERVAL=5m
|
||||||
|
MAX_RETRIES=3
|
||||||
|
REQUEST_TIMEOUT=10s
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Set proper permissions
|
||||||
|
chown root:monitoring-agent "$CONFIG_FILE" 2>/dev/null || chown root:root "$CONFIG_FILE"
|
||||||
|
chmod 640 "$CONFIG_FILE"
|
||||||
|
|
||||||
|
log_success "Configuration written successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start and enable service
|
||||||
|
start_service() {
|
||||||
|
log_info "Starting monitoring agent service..."
|
||||||
|
|
||||||
|
# Reload systemd
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# Enable service
|
||||||
|
systemctl enable monitoring-agent
|
||||||
|
log_success "Service enabled for auto-start"
|
||||||
|
|
||||||
|
# Start service
|
||||||
|
if systemctl start monitoring-agent; then
|
||||||
|
log_success "Service started successfully"
|
||||||
|
else
|
||||||
|
log_error "Failed to start service"
|
||||||
|
log_info "Check logs with: journalctl -u monitoring-agent -f"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check service status
|
||||||
|
sleep 2
|
||||||
|
if systemctl is-active --quiet monitoring-agent; then
|
||||||
|
log_success "Service is running"
|
||||||
|
else
|
||||||
|
log_warning "Service may have issues, checking status..."
|
||||||
|
systemctl status monitoring-agent --no-pager
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test installation
|
||||||
|
test_installation() {
|
||||||
|
log_info "Testing installation..."
|
||||||
|
|
||||||
|
# Test health endpoint
|
||||||
|
local health_port=${HEALTH_CHECK_PORT:-8081}
|
||||||
|
log_info "Testing health endpoint at http://localhost:$health_port/health"
|
||||||
|
|
||||||
|
# Wait a moment for service to fully start
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
if curl -s "http://localhost:$health_port/health" > /dev/null; then
|
||||||
|
log_success "Health endpoint is responding"
|
||||||
|
else
|
||||||
|
log_warning "Health endpoint not responding yet (service may still be starting)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show recent logs
|
||||||
|
log_info "Recent service logs:"
|
||||||
|
journalctl -u monitoring-agent --no-pager -n 5
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show post-installation information
|
||||||
|
show_post_install_info() {
|
||||||
|
echo
|
||||||
|
echo "============================================="
|
||||||
|
echo " CheckCle Server Agent Installation Complete!"
|
||||||
|
echo "============================================="
|
||||||
|
echo
|
||||||
|
log_success "CheckCle Monitoring Agent installed and configured successfully"
|
||||||
|
echo
|
||||||
|
echo "System Information:"
|
||||||
|
echo " Architecture: $ARCH ($PACKAGE_ARCH)"
|
||||||
|
echo " Package: $PACKAGE_FILENAME"
|
||||||
|
echo " Package Manager: $PACKAGE_MANAGER"
|
||||||
|
echo
|
||||||
|
echo "Configuration: $CONFIG_FILE"
|
||||||
|
echo "Service status: systemctl status monitoring-agent"
|
||||||
|
echo "Service logs: journalctl -u monitoring-agent -f"
|
||||||
|
echo "Health check: curl http://localhost:${HEALTH_CHECK_PORT:-8081}/health"
|
||||||
|
echo
|
||||||
|
echo "The monitoring agent is now running and will appear in your dashboard."
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up temporary files
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$DOWNLOADED_PACKAGE" && -f "$DOWNLOADED_PACKAGE" ]]; then
|
||||||
|
rm -f "$DOWNLOADED_PACKAGE"
|
||||||
|
rm -rf "$(dirname "$DOWNLOADED_PACKAGE")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main installation function
|
||||||
|
main() {
|
||||||
|
echo "============================================="
|
||||||
|
echo " CheckCle Server Monitoring Agent"
|
||||||
|
echo " One-Click Installation"
|
||||||
|
echo "============================================="
|
||||||
|
echo
|
||||||
|
|
||||||
|
check_root
|
||||||
|
validate_environment
|
||||||
|
detect_system
|
||||||
|
detect_system_info
|
||||||
|
|
||||||
|
log_info "Starting automated installation..."
|
||||||
|
|
||||||
|
# Set up cleanup trap
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# Download and install package
|
||||||
|
download_package
|
||||||
|
install_package
|
||||||
|
|
||||||
|
# Configure and start service
|
||||||
|
configure_agent
|
||||||
|
|
||||||
|
if start_service; then
|
||||||
|
test_installation
|
||||||
|
show_post_install_info
|
||||||
|
else
|
||||||
|
log_error "Service failed to start properly"
|
||||||
|
log_info "Check configuration: sudo nano $CONFIG_FILE"
|
||||||
|
log_info "Restart service: sudo systemctl restart monitoring-agent"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle script arguments
|
||||||
|
case "${1:-}" in
|
||||||
|
--help|-h)
|
||||||
|
echo "CheckCle Server Monitoring Agent - One-Click Installer"
|
||||||
|
echo
|
||||||
|
echo "Usage: SERVER_TOKEN=your-token [OPTIONS] sudo bash $0"
|
||||||
|
echo " or: sudo SERVER_TOKEN=your-token [OPTIONS] bash $0"
|
||||||
|
echo " or: curl -L script-url | SERVER_TOKEN=your-token [OPTIONS] sudo bash"
|
||||||
|
echo
|
||||||
|
echo "System Requirements:"
|
||||||
|
echo " - Linux with systemd"
|
||||||
|
echo " - Supported architectures: x86_64 (amd64), aarch64/arm64"
|
||||||
|
echo " - Supported distributions: Debian/Ubuntu (.deb), RHEL/CentOS/Fedora (.rpm)"
|
||||||
|
echo
|
||||||
|
echo "Required Environment Variables:"
|
||||||
|
echo " SERVER_TOKEN Server authentication token"
|
||||||
|
echo
|
||||||
|
echo "Optional Environment Variables:"
|
||||||
|
echo " POCKETBASE_URL PocketBase URL (default: http://localhost:8090)"
|
||||||
|
echo " SERVER_NAME Server name (default: hostname)"
|
||||||
|
echo " AGENT_ID Agent identifier (default: monitoring-agent-hostname)"
|
||||||
|
echo " HEALTH_CHECK_PORT Health check port (default: 8081)"
|
||||||
|
echo
|
||||||
|
echo "Examples:"
|
||||||
|
echo " SERVER_TOKEN=abc123 sudo -E bash $0"
|
||||||
|
echo " sudo SERVER_TOKEN=abc123 POCKETBASE_URL=https://pb.example.com bash $0"
|
||||||
|
echo
|
||||||
|
echo "Options:"
|
||||||
|
echo " --help, -h Show this help message"
|
||||||
|
echo " --uninstall Uninstall the monitoring agent"
|
||||||
|
echo
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--uninstall)
|
||||||
|
check_root
|
||||||
|
log_info "Uninstalling monitoring agent..."
|
||||||
|
systemctl stop monitoring-agent 2>/dev/null || true
|
||||||
|
systemctl disable monitoring-agent 2>/dev/null || true
|
||||||
|
|
||||||
|
# Remove based on detected package manager
|
||||||
|
if command -v dpkg >/dev/null 2>&1; then
|
||||||
|
dpkg -r monitoring-agent 2>/dev/null || true
|
||||||
|
elif command -v rpm >/dev/null 2>&1; then
|
||||||
|
rpm -e monitoring-agent 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf /etc/monitoring-agent 2>/dev/null || true
|
||||||
|
log_success "Monitoring agent uninstalled"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
main "$@"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_45665081")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("select4246785570")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(9, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(10, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1582905952",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "method",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(11, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1414600993",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "payload_template",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(7, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2168550802",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "trigger_events",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_45665081")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(4, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select4246785570",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "event_filters",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"down",
|
||||||
|
"up",
|
||||||
|
"ssl_expired",
|
||||||
|
"warning",
|
||||||
|
"ssl_ok",
|
||||||
|
"high_cpu",
|
||||||
|
"high_memory",
|
||||||
|
"agent_offline",
|
||||||
|
"custom_alert"
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("text1579384326")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("text1582905952")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("text1414600993")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(8, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2168550802",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "timeout",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1938176441")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(10, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select1358543748",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "status",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"disabled",
|
||||||
|
"enabled"
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1938176441")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(10, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "select1358543748",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"name": "enabled",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "select",
|
||||||
|
"values": [
|
||||||
|
"true",
|
||||||
|
"false"
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1938176441")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(14, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1553704459",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "webhook_id",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1938176441")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("text1553704459")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1998570700")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(19, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3065852031",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "message",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1998570700")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("text3065852031")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1998570700")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(20, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text772177811",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "detail",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(19, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3065852031",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "error_message",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1998570700")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("text772177811")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(19, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3065852031",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "message",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1998570700")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(20, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text772177811",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "details",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1998570700")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(20, new Field({
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text772177811",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "detail",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||